Spec: F08 — AI Extraction
Status: shipped · Audited: 2026-07-14
1. Problem & CEO Job
Trust the signal. This is the plumbing that turns chaos into the graph: six free-text answers in, structured Commitments/Risks/DecisionRequests out. Extraction itself is table-stakes — any AI can parse text. The moat is what it builds: the accumulated multiplayer data graph (customers/staff/tasks/commitments/risks/projects/suppliers) that no Zalo thread or Excel file holds. The value is converting the owner’s messy Zalo/Excel/screenshot reality into that graph, one pulse at a time. The quality bar is asymmetric: a missed signal costs little (tomorrow’s pulse repeats it); a fabricated or inflated signal costs trust the product never gets back. Fewer, honest, traceable items beat coverage — always.
This extraction also powers the wedge: the F70 Chaos Import → Boss Brief flow runs ON this pipeline — pasted Zalo/Excel/screenshot is just another input source feeding the same structured-item output.
2. User Stories & Acceptance Criteria
- As a CEO, I want every extracted item traceable to who said what, so that I can verify before acting.
- Given any AI-created item, then ≥1
EvidenceSourcelinked (commitments, risks, decisions) ANDextracted_from_pulse_entryset (commitments ✅, risks ✅, decisions ✅ — FK exists atoperations/models.py:154; set in LLM path and keyword Commitment/Risk paths; keyword Decision path does not set it) — a floating insight is a release-blocking bug.
- Given any AI-created item, then ≥1
- As a CEO, I want honest confidence, so that I learn when to trust Lumi.
- Given keyword extraction, then
confidence_score∈ [0.60, 0.80], computed from signal strength; LLM mode 0.85–0.90;1.0never occurs (test-enforced).
- Given keyword extraction, then
- As a member, I want my words to become readable items, so that the Brief doesn’t echo my raw text back.
- Given extraction, then item titles are the matched sentence/clause — never a
raw_text[:100]dump (see §9).
- Given extraction, then item titles are the matched sentence/clause — never a
- As an operator, I want extraction to be deterministic in mock mode, so that dev/CI behave identically (ADR-002).
- Given one pulse, then ≤10 items total (more = noise; confidence-filter before create).
- Given extraction failure, then
PulseEntry.status=failedand the error is retrievable (error_messagefield set by bothsubmit_pulseand/process/— see F07 §13 ES2/ES7); partial item creation must not markprocessed.
Extraction contract (mock mode)
| Signal | Keywords (vi) | Creates |
|---|---|---|
| Commitment | hứa, sẽ gửi, cần gửi, deadline, cam kết, trước ngày, trước giờ | Commitment (owner = pulse author, created_by_type=ai) |
| Risk | kẹt, trễ, rủi ro, chưa phản hồi, công nợ, quá hạn, mất, hỏng, lỗi | Risk (+ ai_reasoning_summary naming the matched keyword) |
| Decision | cần ceo, cần duyệt, quyết, approve, cần quản lý, xin phép | DecisionRequest (requested_by = author) |
3. Scope Boundaries
- In: pulse → items pipeline, LLM extraction (primary) + keyword fallback behind
LLM_PROVIDER, evidence linking, confidence policy, status transitions. - Out: extraction from chat or tasks, embeddings/semantic search, model fine-tuning, auto-actions (extraction proposes; humans/Brief dispose).
§3a — LLM Extraction Contract (primary path, ADR-031)
When LLM_PROVIDER != "mock", extraction calls the configured provider via _extract_via_llm() before the keyword loops. On success, keyword extraction is skipped entirely. On LLMProviderError or invalid JSON, a warning is logged and execution falls through to the keyword path — the Brief must always have items.
System prompt: build_system_prompt(submitter_member, "extraction") — English instructions + member-language response directive (F37). The _EXTRACTION_RULES block defines signal types (commitment/risk/decision), grounding rules, and verbatim evidence span requirement.
User prompt: build_extraction_prompt(qa_text, today_iso) — concatenated Q&A text + today’s date + the JSON output schema.
Output schema (returned by LLM as response_json=True):
{
"items": [
{
"type": "commitment" | "risk" | "decision",
"title": "concise summary ≤80 chars in the pulse language",
"evidence_span": "verbatim substring from the input text",
"confidence": 0.85–0.90,
"due_date": "YYYY-MM-DD or null",
"owner_name": "person name or null",
"risk_type": "cash" | "customer" | "delivery" | "operations" | null,
"severity": "low" | "medium" | "high" | "critical" | null
}
]
}
Faithfulness check (F86): each item’s evidence_span must be a verbatim substring of qa_text; items failing this check are dropped (not the whole batch). Confidence is clamped to [0.85, 0.90] regardless of what the LLM returns.
Cap: ≤10 items total (same as keyword path; _cap_extracted_items applies after both paths).
Do NOT grow keyword lists for new signal vocabulary. Instead, update the _EXTRACTION_RULES block in prompts.py so the LLM understands the new signal. Keyword patterns (_RISK_PATTERNS, _COMMITMENT_PATTERNS, _DECISION_PATTERNS) remain as the fallback floor for mock mode and LLM failures — they should be touched only when mock-mode coverage genuinely regresses.
4. Data Model
No own models. Writes Commitment/Risk/DecisionRequest (F09–F11), reads PulseEntry, links EvidenceSource (F07). All writes carry organization_id from the pulse — extraction must never widen tenant scope.
5. API Contract
Service layer only: apps/ai/services/extraction.py::extract_pulse_entry(pulse_entry_id, evidence_id=None, organization_id=None). Invoked by POST /api/pulses/<id>/process/ (and, per F07 decision, by submit auto-trigger, which passes its own evidence_id). Receives IDs, not objects (Celery-safe). Production HTTP callers pass the resolved organization ID so the initial pulse lookup is tenant-scoped. Early-returns if status == "processed" (idempotent retry guard). The optional organization argument is a temporary compatibility seam for legacy internal callers and offline tests; F08 remains blocked until those callers are migrated and the argument becomes required.
6. UI / UX
No direct UI. Its UX is the quality of items appearing in /intel/commitments, /intel/risks, /intel/decisions, and the Brief — which is why title readability (§2) is an acceptance criterion here, not a nicety.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Keyword → correct item type, evidence linked, title is matched answer not raw dump | backend | tests/test_api.py::TestAIExtraction, tests/test_lumi.py::TestMockAIExtraction |
| 2 | Confidence within band; never 1.0; keyword=0.6–0.75; LLM=0.85–0.90 | backend | same + tests/test_llm_extraction.py |
| 3 | No-signal pulse → zero items, still processed | backend | same |
| 4 | Failure path → failed, retry works idempotently | backend | tests/test_api.py::TestPulse |
| 5 | LLM mode: valid JSON → commitment/risk/decision created with LLM confidence | backend | tests/test_llm_extraction.py::TestLLMExtractionCommitment, TestLLMExtractionRisk, TestLLMExtractionDecision |
| 6 | LLM mode: evidence_span not in text → item dropped (faithfulness), rest kept | backend | same |
| 7 | LLM mode: LLMProviderError → falls back to keyword extraction (items still created) | backend | same |
| 8 | LLM mode: invalid JSON → falls back to keyword extraction | backend | same |
| 9 | LLM mode: confidence clamped to [0.85, 0.90] regardless of LLM output | backend | same |
| 10 | Mock mode: provider never called; keyword path used | backend | same |
8. Current Behavior
Shipped with mock-default local development and Gemini, OpenRouter, and DeepSeek support. Extraction creates evidence-linked operational items with bounded confidence. Before any supported external-model submission, known email/phone/credential patterns are minimized and user/tool payloads are marked as untrusted data beneath the system boundary across complete, tool-call, and stream paths. This does not represent arbitrary business text as anonymous. Numeric invoice, order, customer-ID, and currency-amount facts remain intact rather than being mistaken for unformatted phone numbers.
F86 (2026-06-24): each extracted item now routes through the shared apps/ai/faithfulness.py::assert_grounded guard (the matched-sentence span must be a verbatim substring of the answer; confidence clamped into the keyword band; ungrounded items dropped + logged), and the linked EvidenceSource is stamped with evidence_span + grounding_verified. See F86.
9. Error States
ES1 — PulseEntry.DoesNotExist (invalid pulse ID)
extract_pulse_entry calls PulseEntry.objects.get(id=pulse_entry_id) without a DoesNotExist guard. If the pulse ID is invalid or the pulse was deleted, DoesNotExist propagates unhandled. The /process/ view calls self.get_object() first (which returns 404 if not found), so this path is unreachable via the API. Direct service-layer calls (e.g., from Celery tasks) would crash.
ES2 — Extraction failure on submit (caught)
submit_pulse wraps extract_pulse_entry in try/except. On exception: pulse.status = "failed" + logger.exception(...). The submission itself returns HTTP 201 — user answers are never lost. The failed pulse is visible on the frontend with a retry button. Covered: test_submit_extraction_failure_sets_failed.
ES3 — Extraction failure on /process/ retry (unhandled)
POST /api/pulses/<id>/process/ does NOT wrap extract_pulse_entry in try/except. If extraction fails on retry, the exception propagates as HTTP 500. The pulse status remains unchanged (not reset to failed). This is different from submit behavior. Covered: documented in F07 spec §11 ES6; red test test_red_r18.py::test_process_endpoint_sets_failed_on_error (xfail — unimplemented).
ES4 — Partial item creation before crash
If extraction creates a Commitment but crashes during Risk.objects.create, the pulse is NOT marked processed. The commitment persists in the DB. On retry (/process/), the early-return guard if pulse.status == "processed" does not trigger (status is still submitted or failed), so a new commitment may be created — resulting in duplicates. There is no transaction.atomic wrapping extraction. The break after first match limits blast radius to at most one duplicate per type.
ES5 — Missing evidence (evidence_id=None, no records)
If no EvidenceSource exists for the org+user, evidence is None. All if evidence: guards skip the .add(evidence) call. Items are created without any evidence link. This violates §2’s acceptance criterion (“every AI-created item: ≥1 EvidenceSource linked”). In practice, submit_pulse always creates the evidence before calling extraction, so this path only triggers for orphaned manual /process/ calls.
ES6 — LLM provider errors (real mode, future)
LLMProviderError from get_provider() (missing API key, unknown provider) or from provider.complete() (HTTP error, timeout, malformed payload) propagates unhandled. In mock mode (current default), extraction never calls the provider — keyword matching is fully local. Real-mode extraction (not yet built) will need try/except around provider calls with pulse.status = "failed" on error.
ES7 — Provider timeout (30s default)
GeminiProvider and OpenRouterProvider use timeout=30 on HTTP requests. A timeout raises requests.Timeout → wrapped in LLMProviderError. This is a real-mode concern only. In mock mode, extraction is in-process with no network call.
ES8 — Duplicate items on retry after partial failure
If extraction partially succeeds (e.g., commitment created, risk creation crashes) and the pulse is not marked processed, a retry via /process/ re-runs full extraction. The commitment loop matches the same answer again and creates a second Commitment. There is no deduplication check (e.g., “already has a commitment linked to this pulse”). The idempotency guard only triggers on status == "processed".
ES9 — /process/ endpoint evidence fallback
The /process/ view searches for evidence by (org, source_type=pulse_entry, created_by=pulse.user) ordered by -created_at. If multiple evidence records exist, it picks the most recent — which may not be the one from the original submission. This is a weaker contract than submit_pulse which passes the exact evidence.id.
ES10 — Race condition on concurrent submissions
Two simultaneous submit_pulse calls for the same (org, user, date) both hit update_or_create. One succeeds; the other either updates the same row (if it runs after the first commit) or raises IntegrityError (if both try to INSERT before either commits). Django’s update_or_create does not use SELECT ... FOR UPDATE, so the race window exists at the DB level.
ES11 — Exception during pulse.save() after extraction
After all items are created, extract_pulse_entry calls pulse.save() to set status="processed" and processed_at. If this save fails (DB connection lost, serialization error), the pulse stays in submitted status despite items existing. A retry would create duplicate items (same as ES8).
10. Diagrams & Docs Touched
DIAGRAMS.md AI pipeline · docs/AI_PIPELINE.md · docs/AGENTS.md → AI Agent.
11. Implementation Audit (2026-06-12)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Keyword extraction → correct item type, evidence linked, matched-answer title (not raw dump) | ✅ match | apps/ai/services/extraction.py; TestAIExtraction; 2026-06-10 quality PR |
| 2 | Confidence ∈ [0.60, 0.80]; never 1.0; slot-based (0.6 default, 0.75 preferred-slot) | ✅ match | apps/ai/services/extraction.py inline in the extraction loops — no _compute_confidence function exists; confidence is assigned inline per extraction loop; test_confidence_reflects_signal |
| 3 | No-signal pulse → 0 items, still processed | ✅ match | test_no_extraction_on_clean_text |
| 4 | Extraction failure on submit → status=failed, error_message set | ✅ match | submit_pulse try/except; test_submit_extraction_failure_sets_failed; PulseEntry.error_message (migration 0002, 2026-06-12) |
| 5 | _cap_extracted_items enforces ≤10 items per pulse (drop lowest-confidence) | ✅ match | services.py line 74/237; A-r22 tests pass; xfail removed |
| 6 | All items carry ≥1 EvidenceSource; evidence_id passed explicitly from submit_pulse | ✅ match | submit_pulse evidence creation before extract; test_evidence_is_submissions_own |
| 7 | POST /api/pulses/<id>/process/ does not catch extraction errors → 500, not status=failed | ✅ fixed | views.py process() wrapped in try/except; status=failed + error_message set; test_red_r18.py::TestF08ExtractionFailure::test_process_endpoint_sets_failed_on_error passes (2026-06-26) |
| 8 | No transaction.atomic — partial item creation before crash leaves orphaned items on retry | ✅ fixed | extract_pulse_entry now delegates to _extract_pulse_entry_atomic inside transaction.atomic() — all items + status=processed commit atomically or roll back together (2026-06-26) |
| 9 | DecisionRequest lacks extracted_from_pulse_entry FK; traces via evidence_sources M2M only | ✅ fixed | FK exists: operations/models.py:154. Set in LLM extraction path (services.py:651). Keyword fallback path (services.py:1022–1034) does not set it — keyword path is mock/fallback only (LLM is primary per ADR-031). Effectively closed for production. |
| 10 | Cross-tenant service-layer gap: extract_pulse_entry retrieves pulse by ID with no tenant guard | ⚠️ gap | E12; safe via view-layer IsOrgMember, but service direct call is tenant-unsafe |
Summary: 9/10 match; the service-layer tenant guard is partially closed. Production submit/process callers now pass organization_id, and the service rejects a mismatched organization before reading the pulse. The optional compatibility path remains a blocking gap until all internal callers are migrated. Gaps 7 and 8 fixed 2026-06-26; gap 9 closed 2026-07-02 (FK exists and set in primary LLM path).
Edge Cases Catalog (formerly §8, 2026-06-12; E1–E14): Key items integrated above. Remaining: E1 empty pulse → 0 items, processed (✅ test_no_extraction_on_clean_text); E3 whitespace-only answer → no match (correct behavior, no guard needed); E4 same keyword in multiple answers → break after first match, one item per type per pulse (covered by code, no dedicated test for second-match ignored); E5 multi-keyword single answer → up to 3 items (correct); E6 title truncation at 120 chars (no overflow risk); E7 slot-based confidence 0.6 vs 0.75 (✅ tested); E9 company detection post-creation — exception propagates, item already committed (best-effort enrichment); E10 resubmission update_or_create → orphaned items from previous extraction not cleaned up (documented, accepted); E11 draft status dead code; E13 extract_message_signal always 0.6 (correct, no slot context); E14 nested import inside loop (Python cache makes fast). Full catalog in git history.
12. Open Questions
- Real-mode prompt design (resolved — ADR-031):
build_extraction_prompt()+_EXTRACTION_RULESinapps/ai/prompts.pydefine the contract. LLM is the primary path whenLLM_PROVIDER != "mock"; keywords are the fallback. See §3a for the full contract. - Vietnamese-only keyword classifiers (F69 blocker): Keyword fallback is Vietnamese-only. For orgs writing pulse text in English, Malay, Arabic, or Bahasa, none match — every item defaults to operations/internal. Deal context strings in
ai/services.pyalso use Vietnamese violating F37 prompts-in-English rule. Resolve before F69 activates any non-Vietnamese org. Preferred fix: LLM-based classification (F37 already requires this).
13. Bug / Change Log
2026-07-14 — External-model privacy and instruction boundary
Type: Security hardening
- Added one shared payload boundary for known direct identifiers and credentials across Gemini, OpenRouter, and DeepSeek.
- Wrapped user and tool-result content as untrusted data for complete, tool, cache, and stream paths.
- Escaped untrusted text so customer/tool content cannot close reserved boundary tags.
- Tightened phone recognition to preserve ISO dates and ordinary hyphenated business references.
- Preserved explicitly labelled unformatted business IDs and currency amounts.
- Added payload regression tests and F08 Contract Graph proof; live-provider policy/eval evidence remains explicit.
- Extended the same boundary to OpenAI-compatible complete, tool-call, and stream payloads after final review found raw provider-specific paths.
2026-07-13 — Thread tenant context through production extraction callers
Type: Security hardening / Contract Graph proof
Summary:
extract_pulse_entryaccepts an explicitorganization_idand applies it to the firstPulseEntrylookup.- The submit auto-trigger,
/process/retry endpoint, and demo seed path pass the organization context they already own. - Added explicit same-tenant and cross-tenant service proofs. A mismatched organization raises
PulseEntry.DoesNotExistbefore extraction or writes begin. - The argument remains optional only for legacy direct callers and offline tests; F08.CUE records that migration as a blocking gap rather than claiming full closure.
2026-07-02 — Upgrade DecisionRequest FK gap to ✅ and fix confidence evidence (batch-2 spec audit)
Type: Documentation Fix
Summary:
- §11 row 9: upgraded from ⚠️ gap to ✅ fixed —
extracted_from_pulse_entryFK exists onDecisionRequest(operations/models.py:154) and IS set in the LLM extraction path (services.py:651). The keyword fallback path (services.py:1022–1034) omits it, but that path is mock/fallback only under ADR-031. Summary gap count corrected from 2 to 1. - §11 row 2: corrected evidence — there is no
_compute_confidencefunction; confidence is assigned inline at ~922/~965/~1008 within each keyword extraction loop. Behaviour claim (slot-based 0.6/0.75) is correct. - §2 AC row 1: updated the
decisionsFK note to reflect that the FK exists and is set in the primary path; keyword-path omission noted.
2026-07-02 — Stale test-class reference corrected (§7 row 5)
Type: Documentation Fix
Summary:
- §7 row 5 previously referenced
tests/test_llm_extraction.py::TestLLMExtraction, a class name that never existed in the file.test_llm_extraction.pyhas always split coverage by item type. Updated to the three classes that actually assert LLM extraction of each item type:TestLLMExtractionCommitment,TestLLMExtractionRisk,TestLLMExtractionDecision(all verified by grep).
2026-06-26 — LLM-first extraction implemented (ADR-031)
Type: Feature (primary extraction path)
Summary:
_extract_via_llm() added to apps/ai/services/extraction.py:
- When
LLM_PROVIDER != "mock", the LLM is called first viacheck_and_retry()(F37 language contract). - User prompt:
build_extraction_prompt(qa_text, today_iso)— verbatim Q&A text + JSON schema. - System prompt:
build_system_prompt(member, "extraction")— includes_EXTRACTION_RULESsignal definitions andRESPONSE LANGUAGEdirective. - Response: JSON
{"items": [...]}per F08 §3a schema; parsed and validated. - F86 faithfulness guard: each item’s
evidence_spanmust be a verbatim substring of the pulse text; items that fail faithfulness check are dropped (not logged as errors). - Confidence clamped to [0.85, 0.90]; out-of-range values are silently clamped.
- Items with types already produced by F84 field-service ontology pass are skipped (avoids double-counting).
- On
LLMProviderErroror invalid JSON: logs warning, returnsFalse, falls through to keyword extraction. - On success (including 0 items): returns
True, caps items, marks pulse processed — keyword loops skipped.
_EXTRACTION_RULES and build_extraction_prompt() added to apps/ai/prompts.py:
_EXTRACTION_RULESdefines signal types (commitment/risk/decision), grounding rules, confidence policy, and output language directive.build_extraction_prompt(qa_text, today_iso)→ user turn with raw text + JSON output schema.- Rule: do NOT add to
_RISK_PATTERNS,_COMMITMENT_PATTERNS,_DECISION_PATTERNSfor new vocabulary — update_EXTRACTION_RULESinstead (ADR-031).
Tests: backend/tests/test_llm_extraction.py — 18 tests covering commitment/risk/decision creation, evidence linking, faithfulness guard, confidence clamping, LLM fallback to keywords, mock-mode bypass, zero-items case, multi-item, and prompt shape. All 18 pass.
2026-06-26 — Vocabulary expansion: one-pager coverage + transaction.atomic hardening
Type: Feature (extraction quality)
Summary:
Atomicity (§11 rows 7–8):
extract_pulse_entrydelegates to_extract_pulse_entry_atomicinsidetransaction.atomic()— all items +status=processedcommit as one unit; no orphaned items on crash.POST /api/pulses/<id>/process/now setsstatus=failed+error_messageon extraction error instead of returning 500 with partial data.
Risk pattern expansion (_RISK_PATTERNS):
- Order cancellation:
hủy,hủy đơn,khách hủy - Operational cash dry-up:
hết tiền,không có tiền,vốn hết - Workforce unavailability:
cách ly,bị cách ly,bị thương,nhập viện - Financial penalty:
bị phạt,phạt tiền,tiền phạt - Supply in transit:
chờ hàng,chờ vật tư,chờ linh kiện,chờ giao,chờ về - Workload capacity:
quá tải,không đủ nhân sự,thiếu người
Commitment pattern expansion (_COMMITMENT_PATTERNS): đảm bảo, nhận việc
Decision pattern expansion (_DECISION_PATTERNS): không có quyền, không được phép, không thẩm quyền, vượt thẩm quyền — captures authority-gap blockers that require CEO resolution.
Risk type classification (_infer_risk_type):
- Penalty keywords now route to
cash(previously fell through tooperations) - Cancellation keywords now route to
customer(previously fell through tooperations)
Due-date parsing (_extract_commitment_due_date):
thứ X tuần sau/tới→ specific weekday of next week (e.g. “thứ hai tuần sau” → next Monday). Checked BEFORE plain “tuần sau” so the weekday is not dropped.trong tuần/tuần này(no explicit weekday) → coming Friday as default.
Tests: backend/tests/test_pulse_v2_p3.py — 12 new test classes covering each pattern group (196 total); TestOnePagerExactExample (9 cases) verifies the canonical one-pager input end-to-end.
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /commitments -> /intel/commitments, /risks -> /intel/risks, /decisions -> /intel/decisions (app-wide IA restructure; API paths unchanged).
2026-06-14 — Strategy v2 realignment (positioning)
Type: Documentation Change
Summary:
- §1 reframed: extraction is no longer cast as “the transformation that justifies Lumi’s existence.” It is now framed as table-stakes plumbing; the moat is the multiplayer data graph (customers/staff/tasks/commitments/risks/projects/suppliers) the extraction builds by converting messy Zalo/Excel/screenshot input into structured items.
- Added a cross-link noting that the F70 wedge (Chaos Import → Boss Brief) runs ON this extraction pipeline — pasted Zalo/Excel/screenshot is an input source. The pipeline is not redesigned.
Reason:
- Aligns F08 with competitive-strategy-v2.md and the MVP_SCOPE.md “Positioning doctrine (strategy v2, 2026-06-14)”: AI is table-stakes; the data graph is the moat. No spec may claim AI “justifies Lumi’s existence.”
Impact:
- Positioning only — no behavior, API, data-model, test, or status change. The extraction pipeline, confidence policy, and evidence contract are untouched.
Tests: None — doc-only positioning edit.
Notes:
- No behavior change is implied or required by this realignment.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8.
- §8 Edge Cases absorbed into §11 note; §9 Error States stays §9; §10 Diagrams stays §10; §11 Implementation Audit (narrative §11 + table §11a) merged into single 10-row table; §12 Open Questions stays; §13 Bug Log stays.
- §11 audit: 10 rows, 6 ✅, 4 gaps (/process/ error handling, partial rollback, DecisionRequest FK, service-layer tenant guard).
2026-06-12 — Close extraction item cap acceptance criterion (A-r22)
Type: Documentation Change
Summary:
- Marked
[x]for the acceptance criterion: “Given one pulse, then ≤10 items total.” - Verified:
_cap_extracted_items(pulse_id)exists inapps/ai/services/extraction.py, called at end ofextract_pulse_entry. Tests intest_red_r18.pypass; xfail markers removed.
2026-06-12 — error_message field + Risk.extracted_from_pulse_entry FK shipped
Type: Feature
Summary:
PulseEntry.error_messageTextField added (migration 0002) — extraction failure detail is now retrievable.Risk.extracted_from_pulse_entryFK added (operations migration 0006) — mirrors Commitment’s existing FK for extraction provenance.services/extraction.py::extract_pulse_entrysetsextracted_from_pulse_entry=pulseon created items.
2026-06-12 — Red tests for two unstruck acceptance criteria (E-r18)
Type: Test Coverage (RED)
Summary:
- Added
tests/test_red_r18.py::TestF08ExtractionItemCap::test_extraction_caps_at_ten_items(xfail, strict). - Added
tests/test_red_r18.py::TestF08ExtractionFailure::test_pulse_entry_has_error_message_field(xfail, strict). - Added
tests/test_red_r18.py::TestF08ExtractionFailure::test_process_endpoint_sets_failed_on_error(xfail, strict).
2026-06-12 — Edge Cases and Error States sections added
Type: Documentation Change
Summary:
- Added Edge Cases (E1–E14): empty pulse, missing answers_json, whitespace answers, multi-match break, multi-keyword single answer, title truncation, slot-based confidence, evidence fallback ambiguity, company detection post-creation, resubmission update-or-create, draft dead code, cross-tenant service-layer gap, extract_message_signal no-slot, nested import.
- Added Error States (ES1–ES11): PulseEntry.DoesNotExist, extraction failure on submit (caught), extraction failure on /process/ retry (unhandled 500), partial item creation without rollback, missing evidence link, LLM provider errors, provider timeout, duplicate items on retry, /process/ evidence fallback, race condition, exception during pulse.save().
2026-06-12 — Provider switch: OpenAI out, Gemini + OpenRouter in
Type: Behavior Change
Summary:
- Replaced the declared-but-never-implemented OpenAI provider with
apps/ai/providers.pyexposingcomplete(system_prompt, user_prompt)behindget_provider()—MockProvider(default),GeminiProvider(RESTgenerateContent),OpenRouterProvider(OpenAI-compatible REST). - Settings:
OPENAI_API_KEYremoved;GEMINI_API_KEY/GEMINI_MODELandOPENROUTER_API_KEY/OPENROUTER_MODELadded.
Tests:
backend/tests/test_ai_providers.py— 14 tests.
2026-06-10 — Extraction quality: matched-answer titles, per-submission evidence, keyword classification
Type: Behavior Change
Summary:
- Item titles/descriptions now come from the specific
answers_jsonentry that matched a keyword (trimmed to 120 chars), notraw_text[:100]of the whole pulse. extract_pulse_entry(pulse_id, evidence_id)now receives the evidence ID created by this submission.- Risk classification by keyword group. Confidence reflects signal strength: single keyword 0.6, keyword in preferred question slot 0.75 (never 1.0).