Spec: F87 — Multimodal Chaos Capture (screenshot OCR · voice-note transcription · spreadsheet parsing)
Status: 🚧 built and contract-backed (backend + frontend shipped 2026-06-24; E2E describe added, make e2e not yet run green; Contract Graph migration 2026-07-14)
This completes the wedge. Lumi’s pitch is “Paste the mess. Get the brief.” — and the pitch is explicit that the mess is multimodal: “Zalo thread · Excel sheet · screenshot · voice note — any of it.” (pitch — “THE WEDGE” + “HOW IT WORKS”). Today F70 Chaos Import only accepts pasted chat text and typed notes. F70 §3 promises “screenshot (OCR’d to text), pasted Excel rows, voice-note transcript” but F70 §11 explicitly defers them: “Still not built: screenshot/voice-note upload path.” This spec owns the contract that turns a screenshot, a voice note, or a spreadsheet into text that flows into the existing F70 / F08 pipeline as a normal, evidence-linked source.
It builds no parallel system. It reuses F34 uploads for storage, F70 + F08 for extraction,
EvidenceSourcefor provenance, the F37 provider layer for the Vietnamese-capable models, and defers the faithfulness invariant itself to F86. F87 is the capture-to-text stage that sits in front of all of them.
Contract Graph migration note (2026-07-14)
F87 is now declared in contracts/features/F87.cue. The contract owns the provider/parser boundary, tenant-scoped evidence and confidence handling, and processing endpoint lifecycle. Its executable backend proofs pass in the repository’s mock-default environment. This does not make the feature production-validated: live Gemini/provider calibration, consented multimodal field data, and browser/native runtime evidence remain explicit gaps in the generated graph and the implementation audit.
1. Problem & CEO Job
Capture with near-zero friction + see clearly. The buyer is an owner-operator of a Vietnamese field-service or specialty-contractor firm (HVAC/MEP, fit-out) whose real operating data lives in Zalo groups, Excel exports, and the owner’s memory (pitch — “THE BUYER”). In that world the owner’s morning input is almost never clean typed text: it is a screenshot of a Zalo thread, a voice note dictated from a job site, or an Excel sheet of jobs and deposits. If Lumi can only ingest text the owner types into a box, the wedge fails for the majority of real inputs — exactly the inputs the pitch promises Lumi accepts (“Ingests chat paste, screenshots, voice notes, spreadsheets” — pitch “EVIDENCE” slide, listed as built).
The reason this is viable now is cost (pitch — “WHY NOW”): “Structuring slang, typos, voice-note transcripts and screenshots in Vietnamese costs cents per interaction. Multimodal models only recently crossed the reliability and cost threshold that makes this viable for low-ACV businesses.” Without F87, the owner who lives in screenshots and voice notes must transcribe their own chaos before Lumi will read it — which is the manual labor Lumi exists to remove. With F87, the owner drops in whatever form the mess is already in and still gets today’s brief in under 10 minutes.
2. User Stories & Acceptance Criteria
-
As an owner (first session, on a phone in the field), I want to upload a screenshot of a Zalo thread and get a brief, so that I do not retype my own chats.
- Given a legible screenshot of a Vietnamese chat thread, when I upload it via the chaos-import surface, then the image is OCR’d to text, that text is extracted by the F70/F08 pipeline, and a Boss Brief renders with items each citing a span of the OCR’d text (not the raw image).
- Given the OCR engine returns low confidence for the whole image (e.g. blurry photo), when processing completes, then the owner sees an honest “Chưa đọc rõ ảnh này — thử chụp lại hoặc gửi đoạn khác” state — never an invented brief built from guessed text.
- Given OCR succeeds but a single region is unreadable, when items are produced, then the unreadable region lowers (never inflates) the confidence of any item derived near it, and items with no verbatim OCR-text span are dropped (F86 invariant).
-
As an owner, I want to upload a voice note I recorded walking the site, so that my spoken updates become tracked commitments and blockers.
- Given an audio file (m4a/mp3/ogg/wav) of a Vietnamese spoken update, when I upload it, then it is transcribed to text, the transcript flows into extraction, and produced items cite spans of the transcript.
- Given transcription confidence is low (background noise, dialect), when items render, then the transcript confidence is surfaced on the source and folded into each item’s confidence; a whole-clip transcription failure yields the honest “Chưa nghe rõ — thử ghi lại” state, never a fabricated transcript.
-
As an owner, I want to upload an Excel/CSV export of my jobs or receivables, so that the rows become structured signal.
- Given a
.xlsxor.csvfile (or pasted tab/comma-separated rows), when I upload it, then it is parsed into normalized rows of text, those rows flow into extraction, and a produced item (e.g. “Client B deposit ₫50M — overdue”) cites the exact source row as its evidence span. - Given a row contains a payment/amount/date, when it is extracted, then F86’s no-invented-numbers rule holds against the parsed cell text — the number must appear verbatim in a parsed row or the item is dropped.
- Given a
-
As an owner, I want to see the upload being processed and then merge into the same brief, so that mixed inputs in one session produce one brief.
- Given an upload, when processing is running, then the surface shows an honest processing state (skeleton, not a fake spinner) and resolves to the brief or an error within the <10-minute first-value budget (F70 §1).
- Given a member of another org, when they request my upload’s processing status or extracted text, then they receive 404 — uploads and their extracted text never cross tenants.
-
As any role, I want the same role/permission rules as the rest of Lumi, so that capture does not become a security hole.
- Given a viewer role, when they attempt the presign/upload, then they receive 403 (inherited from
F34’s
IsOrgMemberexcluding viewer).
- Given a viewer role, when they attempt the presign/upload, then they receive 403 (inherited from
F34’s
3. Scope Boundaries
- In:
- (a) Screenshot / image → text — OCR of an uploaded image (PNG/JPG/WebP/HEIC), Vietnamese-capable.
- (b) Voice note → transcript — ASR of an uploaded audio file (m4a/mp3/ogg/wav), Vietnamese-capable.
- (c) Spreadsheet → structured rows — parse
.xlsx/.csv, or pasted tab/comma-separated Excel rows, into normalized text rows. - The async processing pipeline that detects the upload’s kind, routes it to OCR / ASR / sheet-parse,
produces normalized text + a per-source confidence, and feeds that text into the existing F70 /
F08 extraction so produced items link an
EvidenceSourceof the correct type. - A provider-swappable service interface for OCR and ASR behind a single entry point (mirrors the
F37
get_provider()pattern), with a documented default and honest cost/latency expectations. - Surfacing OCR/ASR confidence honestly and folding it into item confidence (the mechanism; the invariant is owned by F86).
- Out:
- New storage. Reuse F34 pre-signed S3 POST +
Uploadmodel. F87 adds no bucket, no upload endpoint, no second storage path. - The extraction engine. Reuse F08 / F70. F87 produces text; it does not classify commitments/risks/decisions itself.
- The faithfulness invariant. F86 owns “every item cites a verbatim span; never invent.” F87 only guarantees that the source text fed to extraction is the OCR’d / transcribed / parsed text, and that source confidence is surfaced.
- Real-time / streaming audio (live dictation), video, multi-page PDFs, document layout analysis, handwriting recognition — all later, none in v1.
- Voice-note capture in F32 Signal Inbox — F32 §3 explicitly lists “OCR, file uploads” as out; F32 §12 defers voice-note transcription. F87 is where OCR/ASR/sheet-parse now live; F32 may consume an F87-produced upload reference later, but that wiring is out of this spec.
- New storage. Reuse F34 pre-signed S3 POST +
4. Data Model
Reuses the F34 spine — extends the existing Upload model rather than adding a parallel one.
Reused (unchanged)
Upload(F34,apps/uploads/models.py) —UUIDModel,organizationFK (required),userFK,filename,content_type,byte_size,s3_key,status(pending → completed → expired),public_url, andevidence_sourceOneToOne topulses.EvidenceSource. F34’scomplete/already creates theEvidenceSource.EvidenceSource(apps/pulses/models.py) — already hassource_typewithuploaded_fileamong its choices, pluscontent_text(the OCR’d / transcribed / parsed text lands here) andmetadata_json(the per-source OCR/ASR confidence + provider lands here).ChaosImport(F70 §4) — already hasinput_kind ∈ {chat_paste, excel_paste, screenshot, voice_note, typed_note}and anEvidenceSource/Brieflinkage. F87 supplies the text that thescreenshot/voice_note/excel_pastekinds were always meant to carry.
Proposed additions (mark clearly — implemented under F87)
Extend Upload with the multimodal-processing fields (all nullable/defaulted so F34’s existing rows are
untouched; Upload is already UUIDModel + tenant-scoped via organization FK):
# apps/uploads/models.py — F87 additions to Upload
PROCESSING_STATUS_CHOICES = [
("none", "None"), # default — non-multimodal upload (existing F34 behavior)
("queued", "Queued"), # async job enqueued
("processing", "Processing"),
("processed", "Processed"), # extracted_text populated, confidence set
("failed", "Failed"), # honest failure — see failure_reason
]
processing_status = models.CharField(max_length=12, choices=PROCESSING_STATUS_CHOICES, default="none")
modality = models.CharField(max_length=12, blank=True) # "" | "image" | "audio" | "spreadsheet"
extracted_text = models.TextField(blank=True) # OCR'd / transcribed / row-flattened text
source_confidence = models.FloatField(null=True, blank=True) # 0–1, engine-reported; NEVER 1.0, never hardcoded
processing_provider = models.CharField(max_length=40, blank=True) # which OCR/ASR/parser produced the text
failure_reason = models.CharField(max_length=40, blank=True) # "unreadable_image" | "no_speech" | "empty_sheet" | "provider_error"
processing_statusstate machine (update backend/DIAGRAMS.md + GLOSSARY.md):none(F34 default) → on multimodal complete:queued → processing → processed|failed.failedis terminal until the owner retries (a retry re-enqueues toqueued).EvidenceSourcesource subtypes. v1 keepssource_type="uploaded_file"(already in the enum,max_length=20) and records the precise modality inmetadata_json({"modality": "screenshot", "ocr_confidence": 0.74, "provider": "..."}) — no migration to the enum required. Proposed (optional, needs themax_lengthalready covers it): addvoice_noteandscreenshotas first-classSOURCE_TYPE_CHOICESonly if the brief UI needs to badge them distinctly; until thenmetadata_jsoncarries the subtype. Decision deferred to §12.- Scale-check. No new table;
Uploadis already indexed by(organization, -created_at)and(organization, status).extracted_textis aTextFieldon an already-bounded per-upload row — no growth-with-usage join table, no unbounded count. Add an index on(organization, processing_status)for the status-poll query.
5. API Contract
F87 adds no new upload endpoint — uploading reuses F34’s presign → S3 POST → complete. F87 adds a
processing trigger + status surface and teaches the F70 chaos-import endpoint to accept an upload reference.
| Method | Path | Auth/Role | Request | Response | Errors |
|---|---|---|---|---|---|
| POST | /api/uploads/presign/ | F34 — IsOrgMember (no viewer) | F34 contract (filename, content_type, byte_size) | F34 contract (presigned POST + upload_id) | F34: 400/403/429 |
| POST | /api/uploads/{id}/complete/ | IsOrgMember | (empty) | 200 {id, status, public_url, evidence_source_id, processing_status, modality} — if content_type is image/audio/sheet, the modality is detected and an async job is enqueued (processing_status="queued") | 404 cross-org; 409 already completed |
| GET | /api/uploads/{id}/processing/ | Member of owning org | — | 200 {processing_status, modality, source_confidence?, extracted_text_preview?, failure_reason?} | 404 cross-org / not found |
| POST | /api/uploads/{id}/reprocess/ | Member of owning org | — | 202 {processing_status:"queued"} (re-enqueue after a failed job) | 404 cross-org; 409 not in failed/processed |
| POST | /api/chaos-import/ | F70 — IsOrgMember | F70 gains upload_id? alongside {input_kind, content?}: when upload_id is given, the import uses the upload’s extracted_text as the source instead of pasted content | F70 contract (201 {chaos_import_id, status}) | 400 if upload_id upload not yet processed; 404 cross-org upload; 409 upload failed |
- Tenant scoping (Rule 1): every query filters by
organization_id=self.request.organization_id. The processing-status and chaos-import endpoints resolveupload_idonly within the caller’s org; a cross-org id returns 404 (no existence confirmation, matching F34/F70). Celery tasks receivetenant_id: strandupload_id: str, never model objects (per backend/AGENTS.md). - AI output / EvidenceSource (Rule 2): the produced text becomes
EvidenceSource.content_textwithsource_type="uploaded_file"andmetadata_jsoncarrying{modality, source_confidence, provider}. Downstream items (Commitment/Risk/DecisionRequest/Task via F70/F08) each cite a verbatim span of thatcontent_text. Honest confidence: the item confidence is the extraction confidence multiplied/ floored by the source confidence — low OCR/ASR confidence can only lower an item’s confidence, never raise it; never 1.0, never hardcoded. An item with no verbatim span in the extracted text is dropped (F86 invariant), not shown. - Fail honest: on
failed, the chaos-import endpoint refuses to fabricate a brief from a guessed transcript — it surfaces the honest error state (§9). Empty/garbage OCR or ASR output is treated as a low-signal failure, never auto-extracted.
Processing service interface (proposed — mirrors F37 get_provider())
# apps/uploads/processing.py (proposed)
class CaptureResult(NamedTuple):
text: str
confidence: float # 0–1, engine-reported; clamped to a realistic band, never 1.0
provider: str
def get_ocr_provider() -> OCRProvider # selected by settings.OCR_PROVIDER
def get_asr_provider() -> ASRProvider # selected by settings.ASR_PROVIDER
def parse_spreadsheet(blob, content_type) -> CaptureResult # deterministic, in-process (openpyxl / csv)
OCRProvider.recognize(image_bytes, language_hint="vi", mime_type=None) -> CaptureResultASRProvider.transcribe(audio_bytes, language_hint="vi", mime_type=None) -> CaptureResult- Both fail loud with a normalized
CaptureProviderError(mirrors F37’sLLMProviderError); the Celery task catches it, setsprocessing_status="failed"+failure_reason, and never falls back to a fabricated transcript. A mock provider is the dev/CI default (ADR-002 pattern): deterministic text + a fixed sub-1.0 confidence, so the whole pipeline round-trips offline. geminiis now implemented (OCR_PROVIDER=gemini/ASR_PROVIDER=gemini): both modalities route through Gemini Flash’sgenerateContentendpoint via inline base64 data (mime_typefrom the upload’scontent_type), reusing F37’s exact plumbing and the singleGEMINI_API_KEY. Confidence is the model’s self-assessed legibility/clarity, clamped into[0.50, 0.90](never 1.0). Empty text →unreadable_image/no_speech; network/5xx →TransientCaptureError(retried); 4xx/malformed → permanentCaptureProviderError. See ADR-028. Chirp 3 / Cloud STT (ASR_PROVIDER=google_stt) is the documented future opt-in behind this sameASRProviderinterface.
6. UI / UX
- Route / page: the existing F70
/intel/importChaosImportPage(ChaosImportInput) gains capture affordances; no new route. The shared F34useUploadhook +FileAttachButtonare the integration surface (presign → S3 → complete), extended with the modality types. - Affordances (mobile-first — this is field use, DESIGN.md + TASTE.md):
- Image — “Chụp / tải ảnh màn hình” (camera capture on mobile via
<input capture>, gallery on desktop). - Audio — “Ghi âm / tải voice note” (in-browser record button + file upload fallback).
- Spreadsheet — “Tải Excel / CSV” (file picker) and the existing paste box already accepts pasted
Excel rows (
input_kind="excel_paste").
- Image — “Chụp / tải ảnh màn hình” (camera capture on mobile via
- States to design: empty (paste/upload prompt with owner-vocabulary placeholder), processing
(honest skeleton per upload with the modality label — “Đang đọc ảnh…” / “Đang nghe voice note…” /
“Đang đọc bảng…”, polled via
GET /uploads/{id}/processing/, not a fake spinner), populated (the upload’s extracted items merge into the same Boss Brief preview with evidence badges + a source-confidence cue), low-confidence / failed (honest “chưa đọc/nghe rõ” with a Thử lại retry callingreprocess/), error, no-permission (viewer cannot upload). - Mixed-input merge: multiple uploads + a paste in one session resolve into one brief; each item’s evidence badge names its origin (screenshot / voice note / row), reusing F70’s sectioned brief (Việc cần xử lý / Cần chốt với bên ngoài / Rủi ro / Tin nhắn nên gửi).
- shadcn components (check
frontend/src/components/ui/first):Button,Card,Badge(confidence + modality),Skeleton(processing),Tabs(input kinds, already in F70),Progress(upload/record). No raw Tailwind, no stock palette — Lumi semantic tokens only (Rule 4). - i18n (Rule 3): extend the F70
chaosImportnamespace with F87 keys in bothen/andvi/(Vietnamese written first per F18):capture_image,capture_audio,capture_sheet,processing_image,processing_audio,processing_sheet,low_confidence_image,low_confidence_audio,error_unreadable_image,error_no_speech,error_empty_sheet,source_confidence_label,retry_processing. Owner vocabulary, never “AI” or “OCR/ASR” in the owner-facing copy.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Image upload → OCR → extracted_text populated, processing_status="processed", confidence < 1.0 | backend | backend/tests/test_uploads.py::TestMultimodalOCR (mock OCR provider, fixture image) |
| 2 | Audio upload → ASR → transcript populated; confidence surfaced (mocked) | backend | backend/tests/test_uploads.py::TestMultimodalASR (mock ASR provider, fixture audio) |
| 3 | Spreadsheet .xlsx/.csv/pasted rows → normalized rows of text | backend | backend/tests/test_uploads.py::TestSpreadsheetParse |
| 4 | Low/zero OCR confidence → failed + failure_reason="unreadable_image"; no brief, no invented text | backend | backend/tests/test_uploads.py::TestMultimodalFailHonest |
| 5 | Low/zero ASR output → failed + failure_reason="no_speech"; honest state, never fabricated transcript | backend | same |
| 6 | Cross-tenant isolation — uploads, extracted_text, processing status never leak across orgs (mandatory) | backend | backend/tests/test_multitenancy.py |
| 7 | Faithfulness against OCR/transcribed text: every produced item’s span is a verbatim substring of extracted_text; unsourced item dropped | backend | backend/tests/test_chaos.py::TestChaosImportEvidence (extends F70/F86) |
| 8 | Source confidence floors item confidence (low OCR → lower item confidence, never inflated) | backend | backend/tests/test_uploads.py::TestConfidenceFolding |
| 9 | chaos-import with upload_id not yet processed → 400; failed → 409 | backend | backend/tests/test_chaos.py |
| 10 | Viewer role rejected at presign (F34 inheritance) | backend | backend/tests/test_uploads.py |
| 11 | reprocess/ re-enqueues a failed upload | backend | backend/tests/test_uploads.py |
| 12 | Full upload→processing→brief flow: upload screenshot → processing state → brief with evidence badge | e2e | frontend/tests/e2e/lumi.spec.ts describe("Multimodal Chaos Capture") (mocked S3 + mock providers) |
| 13 | Page renders processing skeleton then merges into brief; failed upload shows honest retry | frontend | frontend/src/test/ChaosImportPage.test.tsx (extend F70 tests) |
Backend coverage must stay ≥97% (CLAUDE.md) / ≥99% where the surrounding suite requires; providers always
mocked (deterministic). New E2E describe block required for this user-facing flow.
8. Current Behavior
- F34 upload exists and is shipped —
apps/uploads/(Uploadmodel,/api/uploads/presign/,/api/uploads/{id}/complete/, S3 key + CDN URL,EvidenceSource(source_type="uploaded_file")linkage), plus theuseUploadhook +FileAttachButtonon the frontend. - OCR / ASR / spreadsheet-parse are not built. Grep over
backend/finds no Whisper/vision/OCR/ transcription code and noopenpyxldependency; the onlysource_typewrites are existinguploaded_file/external_event/manual_notepaths. - F70 defers them. F70 §11 — “Still not built: screenshot/voice-note upload path.” F70 §12 lists
“Screenshot OCR — which service/model” and “Voice-note transcription — source and language handling
(defer to F37)” as open questions. The F70
ChaosImport.input_kindenum already reservesscreenshot,voice_note, andexcel_paste, but no code produces text for them. - F32 Signal Inbox is text-only by design (§3 Out: “OCR, file uploads”; §12 defers voice-note).
- Celery is available (
celery>=5.4,config/celery.py,@shared_taskwithautoretry_for/retry_backoffalready used inapps/briefs/tasks.pyandapps/followups/tasks.py; tests runCELERY_TASK_ALWAYS_EAGER). - The F37 provider abstraction exists (
apps/ai/providers.py,get_provider(),LLM_PROVIDER, mock default) — the exact pattern F87’s OCR/ASR provider layer must mirror.
This is a new capture stage; no F87 behavior exists today.
9. Error States
| Condition | failure_reason | Owner message (i18n key) | HTTP / behavior |
|---|---|---|---|
| Image unreadable / OCR empty or all-low-confidence | unreadable_image | chaosImport.error_unreadable_image (“Chưa đọc rõ ảnh này — thử chụp lại hoặc gửi đoạn khác”) | job → failed; no brief; Thử lại retry via reprocess/ |
| Audio silent / no speech detected | no_speech | chaosImport.error_no_speech (“Chưa nghe rõ — thử ghi lại”) | job → failed; honest state |
| Spreadsheet empty / no parseable rows | empty_sheet | chaosImport.error_empty_sheet (“Bảng này trống — thử file khác”) | job → failed |
| OCR/ASR provider outage or timeout | provider_error | generic processing-failed copy + retry | job → failed; logged to Sentry with upload id + modality; never falls back to a fabricated transcript |
chaos-import references an upload still processing | — | processing-state copy (poll continues) | 400 still_processing |
chaos-import references a failed upload | — | the upload’s failure copy | 409 |
Unsupported content_type for any modality | — | uploads.errors.invalid_mime (F34) | 400 at presign (F34 allowlist) |
| Oversized file | — | uploads.errors.file_too_large (F34) | 400 at presign (F34 limit) |
- Retry: the async job uses Celery
autoretry_for/retry_backoff(mirroringapps/briefs/tasks.py) for transient provider errors only; a genuine unreadable input is not retried automatically — it surfaces the honest failure with a manual Thử lại. Retries respect the <10-min first-value budget. - Never silently swallow a processing error, never fabricate text to fill the brief, never inflate item confidence to mask a low-confidence source.
10. Diagrams & Docs Touched
- DIAGRAMS.md: AI pipeline — add the capture → OCR/ASR/parse → text → F70 extraction stage in front of the paste→brief path.
- backend/DIAGRAMS.md:
Upload.processing_statusstate machine (none → queued → processing → processed | failed); the async processing request lifecycle (complete → enqueue → process → chaos-import). - frontend/DIAGRAMS.md:
/intel/importstate set gains the per-upload processing/failed/retry states (no new route). - GLOSSARY.md: add OCR, ASR / transcription, transcript confidence / source confidence, modality.
- ADRs: depends on ADR-002 (mock-default provider pattern); a new ADR is required only if a single OCR/ASR vendor is locked in (see §12).
11. Implementation Audit
Built (2026-06-24)
Shipped — backend + frontend. F87 was implemented on the F34/F70/F37 spine exactly as specified; the async capture stage now turns screenshots, voice notes, and spreadsheets into evidence-linked text that flows into the existing chaos-import/extraction pipeline. What shipped:
- Model (
apps/uploads/models.py) —Uploadextended withprocessing_status(none → queued → processing → processed | failed, defaultnone),modality(image | audio | spreadsheet),extracted_text,source_confidence(0–1, never 1.0),processing_provider,failure_reason(unreadable_image | no_speech | empty_sheet | provider_error). New index(organization, processing_status). No new table, no new bucket. - Processing layer (
apps/uploads/processing.py) —CaptureResultNamedTuple;get_ocr_provider()/get_asr_provider()selected bysettings.OCR_PROVIDER/settings.ASR_PROVIDERwith a deterministic mock provider as the dev/CI default (mirrors F37 / ADR-002);parse_spreadsheet()deterministic in-process via openpyxl/csv; normalizedCaptureProviderError. - Async task (
apps/uploads/tasks.py) —@shared_task process_upload(tenant_id, upload_id)enqueued fromcomplete/for image/audio/spreadsheet content types; routes to OCR/ASR/parse; writesextracted_text+source_confidenceand setsEvidenceSource.content_text+metadata_json {modality, source_confidence, provider}. Fail-honest — setsfailure_reason, never fabricates text or inflates confidence. Takestenant_id+upload_idstrings, never model objects. - Endpoints —
GET /api/uploads/{id}/processing/(tenant-scoped status poll),POST /api/uploads/{id}/reprocess/(re-enqueue afailedupload toqueued);complete/response extended withprocessing_status+modality;POST /api/chaos-import/acceptsupload_idand usesupload.extracted_text(400 not-yet-processed, 409 failed, 404 cross-org). - Confidence folding — item confidence is the extraction confidence floored/multiplied by
source_confidence(lower-only, never 1.0). F86 faithfulness holds againstextracted_text; unsourced items dropped. - Frontend —
/intel/importChaosImportPagegains image/audio/spreadsheet capture affordances; honest per-upload processing skeleton polled via/uploads/{id}/processing/with TanStack Query; failed/retry (reprocess/) state; items merge into the same Boss Brief. NewchaosImporti18n keys in en/ + vi/. - Tests — backend cases per §7 (OCR/ASR/sheet-parse, fail-honest, cross-tenant isolation,
faithfulness against extracted text, confidence folding, chaos-import upload_id 400/409, viewer 403,
reprocess); frontend
ChaosImportPageprocessing/retry; E2Edescribe("Multimodal Chaos Capture")added (mocked S3 + mock providers). - Dependency —
openpyxladded tobackend/pyproject.toml, installed viauv.
Outstanding (honest): make e2e was not run green in the implementing session — the E2E describe
block exists but the full Playwright suite has not yet been verified end-to-end (MVP_SCOPE E2E cell stays
🔲). The OCR/ASR vendor defaults remain mock; a production vendor lock-in still requires the ADR per §12.
Original constraints (at spec creation) — preserved
- Reuse F34 storage —
apps/uploads/Upload+ presign/complete already exist and create theEvidenceSource. F87 extendsUpload, it does not add a second upload path. - Reuse the F70 / F08 pipeline — produced text must enter
run_chaos_import()/ F08 extraction as a normal source; do not fork extraction. TheChaosImport.input_kindenum already reserves the modalities. - Reuse the F37 provider pattern — OCR/ASR providers behind
get_*_provider()with a mock default andCaptureProviderError; never hardcode a vendor in call sites. - F86 faithfulness must hold against the OCR’d/transcribed text — the source text becomes the evidence substrate; F87 surfaces source confidence and folds it into item confidence (lower-only), but the no-invented-span invariant itself is owned by F86. (F86 may not exist yet at spec-creation time — referenced by ID.)
- Async must respect the <10-min first-value budget (F70 §1) — Celery is already wired
(
CELERY_TASK_ALWAYS_EAGERin tests,@shared_taskwith retry/backoff inapps/briefs/apps/followups); tasks taketenant_id+upload_idstrings, never objects. - New dependency:
openpyxl(spreadsheet parsing) must be added tobackend/pyproject.tomland installed viauv(CLAUDE.md). OCR/ASR are remote provider calls — no heavy local model dependency.
12. Open Questions / Decisions
- OCR provider + cost. RESOLVED (ADR-028, 2026-06-25).
Default (when enabled) to Gemini Flash (
gemini-2.5-flash) viagenerateContent— a Vietnamese-capable multimodal model sharing the F37 provider plumbing and the singleGEMINI_API_KEY, ~$0.0004/image (“cents per interaction”). One provider surface, strong Vietnamese diacritics. A dedicated-OCR escape hatch remains available behind the sameOCRProviderinterface. Mock stays the dev/CI default. - ASR provider + cost. RESOLVED (ADR-028, 2026-06-25).
Default (when enabled) to the same Gemini Flash model via
generateContentinline audio — one key, Vietnamese-capable, ~$0.06/hour of audio (field voice notes <60s ⇒ cents). Chirp 3 / Cloud Speech-to-Text (ASR_PROVIDER=google_stt) is the documented future opt-in behind the sameASRProviderinterface for calibrated word-level confidence or streaming — not built now (it needs GCP service-account auth, a different auth model than theGEMINI_API_KEYAPI key). - EvidenceSource subtype. Keep
uploaded_file+metadata_json.modality, or promotescreenshot/voice_noteto first-classSOURCE_TYPE_CHOICES? Recommendation:metadata_jsonfor v1 (no migration); promote only if the brief UI needs distinct badges. (max_length=20already accommodates both strings.) - Sync vs async UX. A spreadsheet parse is fast and in-process (could be synchronous); OCR/ASR are remote and slow. Recommendation: a single async path for all modalities for UI consistency (one processing state), with spreadsheet parse simply resolving fastest. Revisit if the async hop hurts the spreadsheet first-value time.
- Confidence band. RESOLVED (partial). Real Gemini OCR/ASR confidence is the model’s self-assessed
legibility/clarity, clamped into
[0.50, 0.90](missing → floor) — a proxy, not a calibrated engine score (ADR-028). It folds lower-only viasource_confidence. Final per-item folding (multiply vs floor) is still to be tuned against the F70 G5 / F86 benchmark.
13. Bug / Change Log
2026-06-24 — Spec created
Type: Feature
Summary:
- Created F87 to own Lumi’s multimodal capture contract — screenshot OCR, voice-note transcription, and spreadsheet parsing — the inputs the pitch (“Paste the mess. Get the brief.”) promises and that F70 §3 named but §11 deferred (“Still not built: screenshot/voice-note upload path”).
Reason:
- The wedge’s input is explicitly multimodal (pitch — “THE WEDGE”: “Zalo thread · Excel sheet · screenshot · voice note — any of it”; “EVIDENCE”: “Ingests chat paste, screenshots, voice notes, spreadsheets”). Today only chat-paste and typed-note work. F87 closes that gap by turning each modality into text that flows into the existing F70/F08 pipeline as a normal evidence-linked source — no parallel ingestion system.
Impact:
- New draft feature F87 in the registry. Reuses F34 storage, the F70/F08 extraction pipeline, the F37
provider pattern,
EvidenceSourceprovenance, and defers the faithfulness invariant to F86. Extends the existingUploadmodel withprocessing_status/modality/extracted_text/source_confidence; adds no new storage. No code shipped by this spec.
Tests:
- Test plan defined (§7); none implemented yet.
Notes:
- Provider-swappable, cost-aware (cents per interaction), Vietnamese-capable by default; mock provider is the dev/CI default per ADR-002. Async processing must respect F70’s <10-min first-value budget. Fail honestly (“couldn’t read this image” / “couldn’t hear this”) — never invent text or inflate confidence.
2026-06-24 — Implemented F87
Type: Feature
Summary:
- Built the multimodal capture stage end to end on the F34/F70/F37 spine: extended
Uploadwithprocessing_status/modality/extracted_text/source_confidence/processing_provider/failure_reason(+(organization, processing_status)index); addedapps/uploads/processing.py(CaptureResult,get_ocr_provider()/get_asr_provider()with mock defaults, deterministicparse_spreadsheet(),CaptureProviderError) andapps/uploads/tasks.py(@shared_task process_upload). New endpointsGET /uploads/{id}/processing/andPOST /uploads/{id}/reprocess/;complete/returnsprocessing_status+modality;chaos-import/acceptsupload_id./intel/importgained image/audio/ spreadsheet capture affordances with an honest polled processing skeleton and failed/retry state.
Reason:
- Closes the deferred half of the wedge — F70 §11 “Still not built: screenshot/voice-note upload path.” The pitch promises Lumi ingests “Zalo thread · Excel sheet · screenshot · voice note — any of it”; until now only chat-paste/typed-note worked. F87 turns each modality into evidence-linked text that flows into the existing extraction pipeline, so the owner drops in whatever form the mess is already in.
Impact:
- F87 flips to 🚧 in the registry (Backend ✅, Frontend ✅, E2E 🔲). No new table, no new bucket — extends
F34
Uploadand reuses F70/F08 extraction,EvidenceSourceprovenance, and the F37 mock-default provider pattern. Confidence is folded lower-only bysource_confidence(never 1.0); F86 faithfulness holds againstextracted_text; failures are honest (unreadable_image/no_speech/empty_sheet/provider_error), never fabricated. New dependency:openpyxl.
Tests:
- Backend §7 cases implemented (OCR/ASR/sheet-parse, fail-honest, cross-tenant isolation, faithfulness
against extracted text, confidence folding, chaos-import upload_id 400/409, viewer 403, reprocess);
frontend
ChaosImportPageprocessing/retry; E2Edescribe("Multimodal Chaos Capture")added. Backend coverage held ≥97%.make e2enot yet run green this session — E2E cell stays 🔲 until verified.
Notes:
- OCR/ASR vendor defaults remain mock providers; locking a single production vendor still requires an ADR (§12). Diagrams updated: DIAGRAMS.md §4a (capture→OCR/ASR/parse→text→F70), backend/DIAGRAMS.md §23a (Upload processing state machine) + §23b (async lifecycle), frontend/DIAGRAMS.md §6a (per-upload processing/failed/retry states), GLOSSARY.md (OCR, ASR/transcription, source confidence, modality, Upload.processing_status).
2026-07-02 — Spec drift correction: check all shipped ACs against §11 audit
Type: Documentation
Summary:
- Corrected all 10 acceptance criteria from
[ ]to[x]based on §11 Implementation Audit confirmation. - Criteria checked: (1) image OCR → extraction → brief with OCR-text citations; (2) low OCR confidence → honest
unreadable_imagefailure state; (3) partial unreadable region lowers item confidence, unsourced items dropped (F86); (4) audio ASR → transcript → extraction → citations; (5) low transcription confidence surfaced, whole-clip failure → honestno_speechstate; (6) xlsx/csv → normalized rows → extraction → source row citation; (7) F86 no-invented-numbers holds against parsed cell text; (8) honest processing skeleton (TanStack Query poll, not fake spinner); (9) cross-tenant 404 for upload/extracted-text/processing-status; (10) viewer role → 403 at presign.
Evidence: §11 “Shipped — backend + frontend” (2026-06-24); model fields, processing layer, async task, endpoints, confidence folding, frontend, and tests all confirmed ✅; §11 backend §7 cases listed explicitly (OCR/ASR/sheet-parse, fail-honest, cross-tenant isolation, faithfulness, confidence folding, upload_id 400/409, viewer 403, reprocess); 2026-06-24 and 2026-06-25 change log entries confirm scope.
2026-06-25 — Wired Gemini Flash OCR + ASR providers
Type: Feature
Summary:
- Extended the F87 provider interface with real Gemini Flash OCR + ASR behind the existing swappable
selectors. Added
GeminiOCRProvider/GeminiASRProvidertoapps/uploads/processing.py: both POST a multimodalgenerateContentrequest (inline base64 data +responseMimeType=application/json) mirroringapps/ai/providers.py::GeminiProviderexactly — same URL,x-goog-api-keyheader, 30s timeout. Added an optionalmime_typekwarg torecognize()/transcribe()on the mock + new providers;process_uploadpassesmime_type=upload.content_type.get_ocr_provider()/get_asr_provider()now acceptmock | gemini. Settings gainedOCR_MODEL/ASR_MODEL(defaultGEMINI_MODEL);OCR_PROVIDER/ASR_PROVIDERdefaults unchanged (mock).
Reason:
- Resolves the §12 OCR/ASR provider open questions (ADR-028):
one key, Vietnamese-capable, cents/interaction, reuses F37 plumbing — without GCP service-account auth.
Chirp 3 / Cloud STT recorded as the documented future opt-in (
ASR_PROVIDER=google_stt).
Impact:
- F87 stays 🚧 (Backend ✅); no registry status change, no frontend change. Mock stays the dev/CI
default (ADR-002); real providers are opt-in and never silently fall back to mock. Confidence is the
model’s self-assessed value clamped into
[0.50, 0.90](never 1.0). Fail-honest preserved: empty text →unreadable_image/no_speech; network/5xx →TransientCaptureError(retried); 4xx/malformed → permanentCaptureProviderError.
Tests:
tests/test_uploads.py: request-shape+parse, mime defaulting, confidence clamp (high/low/missing), empty-text fail-honest, 5xx→transient, ConnectionError→transient, 4xx/empty-candidates/non-JSON/ non-string→permanent, constructor-without-key, gemini selectors, and a task-level test thatprocess_uploadwithOCR_PROVIDER=geminiwritesextracted_text+ folds confidence. HTTP always mocked.apps/uploads/processing.pyat 100% line coverage.