Smart Upload Assistant — Design
Date: 2026-07-03
Status: Approved (brainstorm with user)
Modules: ai (license-gated, end-to-end) · core dms upload flow
Goal
When a user drops a file into the Upload Document modal, Obscura analyzes it with AI and
proposes a summary, a classification, and tags — which the user reviews, edits,
and confirms before the document is saved. Nothing is persisted until Save; cancelling
leaves zero rows. The already-extracted text is reused on Save so the file is never
OCR'd/extracted twice.
Title and folder suggestions already exist (filename autofill + FolderSuggestBox) and are
unchanged. Key-entity extraction stays out of the modal (the background enrichment job still
produces entities for the doc-detail AI Insights card).
Decisions (made during brainstorm)
- Trigger: automatic on file drop, admin-toggleable. Analysis auto-runs when the file
lands in the dropzone. A new admin AI setting (upload_analyze_enabled, default true)
can switch it off org-wide. - Suggestions in scope: classification, tags, summary. Entities (parties/dates/amounts)
are explicitly out of scope for the modal. - Architecture: stateless analyze (Option A). A new endpoint analyzes the raw bytes and
caches the result server-side under a short-livedanalysis_id. The document is created
only on Save, with the confirmed values. No orphan documents, no staging table.
Architecture & data flow
Upload modal (file dropped)
│ auto-run iff: `ai` module enabled (from /me) AND admin toggle on
▼
POST /api/v1/ai/analyze-upload multipart: file (+ optional title form field)
│ server, synchronous, nothing persisted:
│ 1. extract-sidecar: SidecarExtractor.Extract(bytes, mime, filename) → text
│ 2. enrich.Analyze(title, text):
│ ai.Summarize(text) → summary (metered: "summarize")
│ ai.ClassifyDocument(title, text) → {sensitivity, tags} (metered: "classify")
│ 3. analysisCache.Put(analysisID, {ownerID, text, summary, classification, tags, model})
│ TTL 30 min, owner-scoped
▼
{ analysis_id, summary, classification, tags, text_chars }
│
│ user edits summary / accepts or overrides classification / picks tags, folder, title
▼
Save (client sequence, mostly existing):
1. POST /api/v1/documents ← classification = confirmed value
2. POST /api/v1/documents/{id}/versions ← multipart "file"
+ form fields: analysis_id, summary (the possibly-edited text)
server, when analysis_id resolves in cache AND cache owner == caller:
a. seed documents.content_text from cached text (clip 800 KiB)
+ mark content_extracted_version = current → async extract skipped, no double OCR
b. upsert document_enrichment {summary, suggested_classification, suggested_tags,
model} + set its version marker → hourly enrich sweep skips this version
cache entry consumed (deleted) on use; cache miss/expiry → normal async pipeline,
upload still succeeds (201)
3. existing tags mutation applies the confirmed tags
The cache serves two purposes: it carries suggestions between analyze and Save, and it lets
Save reuse the extracted text. Losing it (restart, TTL) degrades gracefully to today's async
pipeline — never an error.
Components
Backend — new
POST /api/v1/ai/analyze-upload(go/internal/httpapi/handlers_ai_analyze.go)- Gates: session auth →
requireModule("ai")→ admin toggle (see Settings) → upload
validation (same extension allowlist + size limit as AddVersion). - Reads the multipart
file(+ optionaltitleform field), runs extract → analyze,
stores the cache entry, returns:
{"analysis_id": "...", "summary": "...", "classification": "internal", "tags": ["..."], "text_chars": 12345}. - Empty extraction (scan with OCR off, unsupported format) → 200 with
{"analysis_id": "", "no_text": true}— the UI shows "couldn't read text". - Toggle off → 409
ai.upload_analyze_disabled. Budget exhausted → the existing
429ai.budget_exhaustedfrommeter(). Provider/sidecar failure → 502-mapped
kernel errorai.analyze_failed. All are soft-failures in the UI. -
Endpoint timeout bounded (extract 60s + two model calls); handler uses the request
context so an abandoned modal aborts the work. -
enrich.Service.Analyze(ctx, title, text) (Analysis, error)
(go/internal/enrich/app/service.go) — same Summarize + ClassifyDocument calls the
backfill uses (same clipping constants), but returns
Analysis{Summary, Classification, Tags []string, Model}without touching the store.
SkipsExtractFields(entities out of scope; saves a model call on the hot path).
Respects the existingenabled()live-license check. -
analysisCache(go/internal/httpapi/analysis_cache.go) — in-process, mutex-guarded
map keyed by randomanalysis_id(UUID). Entry:{ownerID, filename, text (≤800 KiB), summary, classification, tags, model, expiresAt}. TTL 30 min; max 64 entries (evict
oldest); entries removed on use. Owner check on read: a caller can only consume an entry
it created. Lives in httpapi because both the analyze and AddVersion handlers (delivery
layer) are its only users. Single-instance by design (Obscura is a single-tenant
monolith).
Backend — modified
- AddVersion handler (
go/internal/httpapi/handlers_dms.go): accept optional
analysis_id+summarymultipart form fields. After the version commit succeeds,
resolve the cache entry (owner-scoped); on hit: calldms.SetContentTextwith the cached
text (which also fires the embed hook → immediate semantic indexing), mark
content_extracted_version, and callenrich.Service.RecordConfirmed(ctx, docID, version, analysis, summaryOverride)— a new service method that upserts the
document_enrichmentrow (user-confirmed summary wins over cached AI summary) with its
version marker, keeping the enrich store private to its context. All best-effort:
failures log and fall back to the async pipeline; the upload response is unchanged. - AI settings (
go/internal/ai/app/store.go,adapters/pg.go,
httpapi/handlers_ai_admin.go): newupload_analyze_enabled boolon the Settings
struct, GET/PUT wiring, no extra validation beyond bool. - Migration
00078_ai_upload_analyze.sql:
ALTER TABLE ai_settings ADD COLUMN upload_analyze_enabled BOOLEAN NOT NULL DEFAULT TRUE; - wire.go: hand the enrich service (for
Analyze+RecordConfirmed) to httpapi
Deps; construct theanalysisCache.
Frontend
web/src/api/ai.ts:analyzeUpload(file, title?)— rawfetchmultipart (same
pattern asuploadVersion; openapi-fetch would JSON-serialise), returns the typed
response; treats 409ai.upload_analyze_disabledas{disabled: true}and memoizes that
for the session so later uploads don't re-attempt.web/src/api/files.ts:uploadVersion(docID, file, extra?)gains optional
{analysisId, summary}appended as FormData fields.UploadDocumentModal.tsx: on file drop, whenmoduleEnabled('ai'), call
analyzeUploadand render an AI suggestions panel under the existing fields:- Analyzing… inline skeleton/spinner state (upload remains fully usable; Save never
waits on analysis). - On result: editable Summary textarea; the modal's existing Classification
select pre-set to the suggestion with an "AI" tag — but only when the suggested value
matches an entry in the (admin-managed, dynamic) classification registry; a suggestion
outside the registry is silently dropped. User can override; DLP declassify rules
unchanged — the server still enforces on create. Tags as clickable chips (all
pre-selected, click to exclude). - Soft-fail states:
no_text→ "Couldn't read any text from this file"; 429/502 →
"AI suggestions unavailable"; disabled/unlicensed → panel not rendered at all. - Save passes the confirmed classification into the create body (existing field), then
uploadVersion(..., {analysisId, summary}), then applies selected tags via the
existing tags mutation. AiTab.tsx(Admin → AI → Settings): a toggle "Analyze uploads with AI" bound to
upload_analyze_enabled.- i18n: en/id strings co-located per the features/*/i18n.ts pattern.
Error handling
Analysis is always optional decoration on the upload flow:
| Failure | Behavior |
|---|---|
Extraction empty (no_text) |
200 + notice; user fills metadata manually |
| Token budget exhausted (429) | soft notice, panel collapses, upload unaffected |
| Provider/sidecar down | ai.analyze_failed, soft notice, upload unaffected |
| Admin toggle off | 409 ai.upload_analyze_disabled, panel hidden, memoized for session |
ai module unlicensed |
route 403s via requireModule; UI never calls (module gate) |
| Cache expired before Save | upload proceeds; async extract/enrich pipeline covers the doc |
| Enrichment upsert fails on Save | logged, upload still 201; hourly sweep retries |
Security
analyze-uploadenforces the same extension allowlist and size cap as AddVersion — it
must not become a laxer file-ingestion path.- Cache entries are owner-scoped: only the creating principal can consume an
analysis_id; a guessed/foreign id is ignored (normal async path, no error leak). - The user-supplied
summaryform field is stored as the enrichment summary (plain text,
rendered safely by the existing AI Insights card) — equivalent trust to any other
user-authored metadata. - Server-side classification rules (declassify gate) still apply on create; an AI
suggestion never bypasses them.
Testing (repo discipline: never go test against the live demo DB)
- Build/vet:
cd go && go build ./... && go vet ./.... - Curl e2e on the deployed stack: analyze a small PDF → assert suggestions + usage rows
(ai_usageday/feature summarize+classify increment); Save withanalysis_id→ assert
content_textseeded,content_extracted_versionmarked,document_enrichmentrow
matches the edited summary, doc-detail AI Insights shows it; cancel path → assert no
rows anywhere; toggle off via PUT settings → 409; foreignanalysis_id→ normal upload. - Frontend:
npx tsc --noEmit && npx vite build; manual click-through of analyzing /
done / no_text / failed / disabled states. - Post-deploy invariant:
/me enabled_modules == [ai, correspondence, esign, semantic, watermarking], demo intact.
Out of scope (deliberate)
- Key entities in the modal (background enrichment still extracts them for doc detail).
- The Write-document (editor) flow — upload only.
- Multi-instance/distributed cache — single-process map is correct for this deployment
model. - A manual "re-analyze" button — replacing the file re-triggers analysis.
- Auto-applying anything without user confirmation.