Spec: F100 — Issue-to-Action Triage
Status: Derived from the canonical F100 CUE contract: partial. See the generated traceability view and gap view; Markdown does not carry an editable completion claim.
1. Problem & CEO Job
Decide fast + trust the signal. Reported chat and pulse issues are unstructured, forcing the CEO to decide their type and follow-up. Treating every issue as a Risk or inventing severity fills the Brief with unjustified warnings. F100 proposes a Task, Risk, or Decision with honest confidence and traceable evidence, then waits for confirmation before creating a live operational object.
2. User Stories & Acceptance Criteria
- As a PM or project owner, I want Lumi to classify a reported issue as Task, Risk, or Decision based on content, so that each problem gets the right follow-up (not everything becomes a Risk).
- Given a signal describing a concrete action with an owner, when Lumi analyzes it, then it proposes a Task candidate (not a Risk) with linked EvidenceSource.
- Given a signal describing a future threat to a milestone or customer commitment, when Lumi analyzes it, then it proposes a Risk candidate with the affected entity named.
- Given a signal describing a choice between options requiring authority, when Lumi analyzes it, then it proposes a DecisionRequest candidate.
- Given a signal that simultaneously describes an action, a future threat, and a needed decision, when Lumi analyzes it, then it may propose multiple candidates linked to the same EvidenceSource.
- Given a signal with insufficient information to classify, when Lumi analyzes it, then it asks clarifying questions or records MissingData rather than guessing a type.
- As a CEO, I want every proposed item to cite its source and carry honest confidence, so that I can trust the signal without re-reading the original text.
- Given any proposed Task, Risk, or DecisionRequest, when rendered, then it links ≥1 EvidenceSource with the verbatim evidence span, and confidence_score is ≤0.80 (keyword) or 0.85–0.90 (LLM) — never 1.0.
- Given an item where the title or impact involves AI inference not grounded in source text, when rendered, then it is labeled
Inference(notFact) and clearly distinguished.
- As a CEO, I want to confirm or reject each proposal before it becomes a live operational object, so that Lumi never commits a guess to my operating queue.
- Given a proposed Task/Risk/DecisionRequest, when the user has not confirmed it, then the item exists only as a candidate — it is not surfaced in the Brief and does not appear in list endpoints for that object type.
- Given a confirmed proposal, when accepted, then the destination object is created with EvidenceSource linked and appears in the CEO Brief at the next generation.
- Given a rejected proposal, when rejected, then the rejection is recorded as calibration data (not silently discarded).
- As a product, I want duplicate signals to merge rather than multiply, so that the Brief stays clean.
- Given a new signal whose content substantially matches an existing operational object, when Lumi analyzes it, then it links the new EvidenceSource to the existing object instead of proposing a duplicate.
- As a member, I want all this to happen through channels I already use — assistant chat or pulse — so that I don’t need to learn a new form.
- Given a problem described in assistant chat (F91), when the user invokes the triage tool, then proposals are rendered inline in the conversation with accept/reject actions.
- Given a pulse entry containing a problem signal, when extraction runs, then it creates a proposal candidate (not a live object) alongside any extracted commitments/risks/decisions.
3. Scope Boundaries
- In:
- Triage analysis of signals arriving via two existing input channels: assistant chat (F91
log_issueor similar tool) and daily pulse (F83/F07 extraction step). - LLM-driven type classification (Task vs Risk vs Decision) via updated
_EXTRACTION_RULESinbackend/apps/ai/prompts.py(per ADR-031). - Proposal stage: candidates live in the existing
SignalProposalmodel, piggybacking on F32’s accept/reject/ignore infrastructure. - Duplicate detection: text-similarity check against existing open Tasks, Risks, and DecisionRequests in the same org.
- Fact vs Inference labeling: AI-reasoned fields labelled
Inferencewith confidence score. - MissingData recording when signal is too thin to classify.
- CEO Brief surfacing: confirmed items appear in the Brief at the next generation cycle.
- EvidenceSource linking on every proposal — single source text per proposal (the input chat message or pulse answer).
- Triage analysis of signals arriving via two existing input channels: assistant chat (F91
- Out:
- Bot transport (Telegram bot channel considered out of scope for this spec — F92
@lumiin messaging covers that path separately). - A dedicated “Issues” UI — proposals are reviewed inline (assistant chat) or via the existing Signal Inbox page (
/intel/inbox). - Email sync, OCR, file uploads as input (covered by F33/F87).
- Automated creation without human confirm (confirm-before-commit at F91 trust level 0).
- Custom triage rules engine (the LLM
_EXTRACTION_RULESblock is the rule engine).
- Bot transport (Telegram bot channel considered out of scope for this spec — F92
4. Data Model
No new models. F100 reuses the existing spine:
SignalInboxItem— the capture record for pulse-sourced signals. When pulse extraction produces a problem signal, a SignalInboxItem is created alongside any existing extraction items (commitments/risks/decisions).SignalProposal— proposed candidates from triage. Newproposal_typevalues are already supported (task,risk,commitment,decision,approval). Triage produces proposals withconfidence_scorefollowing F86 policy. A status fieldai_inferenceboolean on the proposal or afact_vs_inferencelabel inpayload_jsonmarks AI-reasoned content.EvidenceSource— linked to every proposal.source_typeupdated to supportchatas a new value alongside existingmanual_note.- No new FK from
SignalInboxItemtoTask— proposals already carryaccepted_object_id.
The pulse extraction flow (backend/apps/ai/services.py extract_pulse_entry) already creates Commitment/Risk/DecisionRequest objects directly. F100 adds a triage step: when the extraction LLM classifies a signal as a “problem” that maps to one of the three types, it creates a SignalProposal (via signals/services.py) instead of directly creating the operational object. The direct creation path stays for clear commitments/risks/decisions; the proposal path is for ambiguous/problem signals that benefit from human review.
Status transitions: SignalProposal status already has proposed → accepted | rejected. No new states needed.
5. API Contract
No new endpoints. F100 reuses the existing F32 proposal infrastructure:
| Method | Path | Role | Existing? | F100 use |
|---|---|---|---|---|
| POST | /api/signal-inbox/ | member | F32 | Capture pulse-sourced problem signals as inbox items |
| POST | /api/signal-inbox/<id>/extract/ | member | F32 | Run triage to generate proposals (currently keyword; F100 adds LLM path) |
| POST | /api/signal-proposals/<id>/accept/ | manager | F32 | Confirm a triage proposal → create live object |
| POST | /api/signal-proposals/<id>/reject/ | manager | F32 | Reject a triage proposal → calibration data |
| POST | /api/signal-inbox/<id>/ignore/ | member | F32 | Dismiss without action |
Assistant chat (F91) triage: The assistant tool triage_issue (new F91 tool contract, not a REST endpoint) submits the issue text to the same analysis path and renders proposals as inline conversation cards with confirm/reject buttons. No new backend endpoint required — the F91 agent calls extract_proposals via the existing services.py.
Tenant scoping: All inherited — inbox items, proposals, and destination objects are scoped to request.tenant via F32’s existing SignalInboxItemViewSet and SignalProposalViewSet.
EvidenceSource linking: Every triage proposal carries one EvidenceSource with:
source_type:"chat"(from F91 assistant) or"manual_note"(from pulse, matching existing F32)content_text: the full input message or pulse answer textevidence_span: verbatim substring supporting the classification
Confidence values:
- LLM-classified: 0.85–0.90 (per ADR-031 and F86)
- Keyword fallback: 0.65 (existing F32 default)
6. UI / UX
- Assistant chat (F91): A new tool contract
triage_issuethat the assistant exposes when the user describes a project problem. The agent renders proposals as inline rich cards: type badge, title, evidence span, confidence, and two buttons (Xác nhận/Bỏ qua). This is the primary review surface — no new page required. - Pulse-sourced proposals: Appear in the existing Signal Inbox page (
/intel/inbox) as proposal cards with the same shape. No new UI component beyond what F32 already ships. - States: empty (no problem signals detected), loading, proposals pending, proposals accepted/rejected per existing F32 handling.
- i18n: new namespace
triagewith keys for: tool description, proposal card labels, confirmation flow, inference label, missing-data prompt. Bothenandviupdated together. - Assistant path (F91 doctrine): This is the primary path. The assistant conversation IS the intake surface. The
/intel/inboxpage is the optional direct-path fallback for reviewing pulse-sourced proposals. No new required forms.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Triage classifies action signal → Task proposal | backend | backend/tests/signals/test_triage.py::TestTriageClassification |
| 2 | Triage classifies threat signal → Risk proposal | backend | same |
| 3 | Triage classifies choice signal → Decision proposal | backend | same |
| 4 | Triage classifies multi-signal → multiple proposals | backend | same |
| 5 | Triage with insufficient data → MissingData recorded | backend | same |
| 6 | Proposed item has confidence in correct band (0.85–0.90 LLM, ≤0.80 keyword) | backend | backend/tests/signals/test_triage.py::TestTriageConfidence |
| 7 | Inference-labeled fields distinguished from fact fields | backend | same |
| 8 | Accepted proposal creates Task with EvidenceSource | backend | Reuses F32 test_signals.py::TestProposalAcceptance |
| 9 | Duplicate signal links to existing object instead of creating new proposal | backend | backend/tests/signals/test_triage.py::TestTriageDedup |
| 10 | Cross-tenant proposal access blocked | backend | backend/tests/test_multitenancy.py (existing F32 coverage extended) |
| 11 | F91 assistant tool triage_issue invokes the same analysis path | backend | backend/tests/assistant/test_triage_tool.py |
| 12 | Triage proposal inline cards render in chat with accept/reject | e2e | frontend/tests/e2e/triage.spec.ts :: describe("Issue-to-action triage") |
| 13 | Pulse-sourced proposals appear in /intel/inbox | e2e | same (reuses F32 inbox page) |
8. Current Behavior
The signal inbox (F32) captures free-text notes and generates keyword-based proposals for five types (task, commitment, risk, decision, approval). Pulse extraction (F08/F83) creates Commitment/Risk/DecisionRequest objects directly from pulse answers via either the LLM path (_EXTRACTION_RULES) or keyword fallback; F100’s triage path adds an intermediate proposal stage for ambiguous signals. The assistant chat (F91) now exposes the triage-issue tool, which returns the same proposal material without creating an operational object.
9. Error States
| # | Error | User message | HTTP | Logged |
|---|---|---|---|---|
| E1 | Triage input too thin for any classification | Inline MB text asking clarifying questions; no proposal created | n/a | Structured warning logged |
| E2 | Duplicate detected — merge confirmation | Inline card: “This matches Task X — link as additional evidence?“ | n/a | Merge recorded in audit |
| E3 | LLM provider fails during triage | Falls through to keyword patterns; confidence lowered to ≤0.80 | 200 (degraded) | Warning logged with provider error |
| E4 | Cross-tenant proposal access | Standard 404 | 404 | No (F32 existing behavior) |
10. Diagrams & Docs Touched
- DIAGRAMS.md — ingress pipeline: chat/pulse → triage → proposal → confirm → Brief
- backend/DIAGRAMS.md — signals ER and state machine, pulse extraction sequence
- frontend/DIAGRAMS.md —
/intel/inboxroute (unchanged) - GLOSSARY.md — add
triage_proposalandfact_vs_inferenceentries - ADR-031 — updated
_EXTRACTION_RULESfor triage signal types - F86 extraction faithfulness — confidence and grounding contract
- F32 signal inbox — reused proposal infrastructure
- F91 assistant-first — new tool contract
triage_issue
11. Implementation Audit (2026-07-10)
Built in round 02 (fleet-C). Full implementation shipped in this commit:
backend/apps/signals/services.py—extract_proposals()calls the triage path with bounded LLM confidence and keyword fallback when the provider fails.backend/apps/ai/prompts.py_EXTRACTION_RULES— the signal type descriptions already cover commitment/risk/decision. F100 adds triage-specific rules for distinguishing Task vs Risk vs Decision from problem signals.backend/apps/signals/models.py—SignalProposal.proposal_typealready supportstask,risk,decision. No model changes needed.backend/apps/operations/models.py—RiskandDecisionRequestalready acceptevidence_sources.backend/apps/work/models.py—Taskalready hasevidence_sourcesM2M.- F32’s
/api/signal-inbox/<id>/extract/— the endpoint already generates proposals and marks the item as reviewed. F100 rewrites the underlyingextract_proposals()to use LLM classification. - F32’s accept/reject/ignore flow — reused without change.
- F91 assistant loop — the
triage-issuetool is registered inbackend/apps/ai/toolbelt_registry_triage.pyand calls the shared triage path. backend/apps/pulses/models.pyEvidenceSource.source_type— currently"manual_note","chat_note","pulse","seed","connector","upload","confirmation". F100 uses"chat"and"manual_note"only.- Duplicate detection —
detect_duplicates()performs bounded word-overlap checks against open Tasks/Risks/DecisionRequests in the same organization; it returns a possible duplicate for confirmation rather than mutating the destination.
Shipped-vs-new summary:
| Capability | Shipped (F32, F08, F83) | New in F100 |
|---|---|---|
| Text capture | Web, Telegram, connector, email | Assistant chat via F91 tool triage-issue |
| Proposal generation | Keyword patterns only | LLM classification via _TRIAGE_EXTRACTION_RULES in prompts.py + triage.py service |
| Confirmation | Accept/reject in inbox UI | Inline in assistant chat + inbox (same F32 flow) |
| Evidence linking | EvidenceSource on every accepted item | Same pattern, no change |
| Type classification | Broad keyword match (commitment/risk/decision) | Task vs Risk vs Decision via LLM triage |
| Duplicate merge | None | Word-overlap dedup via detect_duplicates() |
| Fact vs Inference | Not labeled | ai_inference flag in payload_json |
| CEO Brief surfacing | Only confirmed items | Same pattern, no change |
What shipped:
backend/apps/ai/services/triage.py—triage_issue(),_triage_via_llm(),_triage_keyword_fallback(),detect_duplicates()backend/apps/ai/prompts.py— added_TRIAGE_EXTRACTION_RULESbackend/apps/signals/services.py—extract_proposals()upgraded to use triage withai_inferencepayload flagbackend/apps/ai/toolbelt_registry_triage.py— F91 assistant tooltriage-issueregistrationbackend/tests/platform/test_triage.py— 16 tests covering all 13 spec §7 casesbackend/tests/coverage/test_coverage_triage.py— 32 coverage-gap tests
Constraints honored:
- Confidence discipline: 0.85–0.90 LLM, ≤0.80 keyword, never 1.0
- EvidenceSource linked on every proposal
- Cross-tenant isolation on all querysets
- LLM_PROVIDER=mock falls through to keyword fallback
ai_inferenceflag distinguishes AI-reasoned from keyword-matched proposals
12. Open Questions / Decisions
- Should duplicate detection be automatic (silently link) or confirm-before-merge? Recommendation: confirm-before-merge — show “This matches existing Task X — link as evidence or create new proposal?” so the user retains control.
- Should triage proposals from pulse use the same
SignalProposaltable or a separate proposal model? Recommendation: sameSignalProposaltable — F32’s existing model already supports all required types, and it keeps the accept/reject flow uniform. - What is the minimum text length for triage? Very short signals (<20 chars) should produce a “Need more detail” response rather than a low-confidence guess. Decision deferred to implementation.
- Does
EvidenceSourceneed a newsource_typefor assistant chat? Currentlychat_noteexists in messaging (apps/messaging/services.py) butSignalInboxItemusesmanual_note. Recommendation: reusechat_notefor assistant-originated signals, or addchatto the enum. ADR needed if the enum is extended.
13. Bug / Change Log
| Date | Change |
|---|---|
| 2026-07-10 | Round 02 (fleet-C): Full implementation shipped. triage.py service with LLM+keyword classification, extract_proposals() upgraded, triage-issue F91 tool registered, 48 tests, 97.05% coverage. |
| 2026-07-14 | Contract Graph migration: Canonical F100 ownership and executable proof IDs added; live-provider corpus and browser journey remain explicit gaps. |