shipped

Spec: F14 — Chat with Lumi

Status: shipped · Audited: 2026-07-14 · Contract: partial until browser proof

1. Problem & CEO Job

Decide fast. The Brief answers “what should I look at?”; chat answers the follow-up: “việc nào cần tôi quyết?”, “ai đang bị kẹt?”, “tóm tắt project X”. It is a query layer over the organization’s own data graph — customers, staff, tasks, commitments, risks, projects, and suppliers that no general assistant can see. It answers only from that data and says “không có” when none exists: a wrong-but-fluent answer is worse than a dumb-but-honest one. The model is table-stakes; the graph makes answers specific to this company.

2. User Stories & Acceptance Criteria

  • JTBD (CEO — history): “When I view my team’s pulse history, I want to see trends in concerns over time, so I can identify recurring themes — a customer complaint surfacing in three consecutive weeks — without reading every pulse.”

    • Given the thread message list, the history is keyboard-navigable (Tab / arrow keys reach each message; Enter opens or expands). Screen-reader labels on message bubbles (aria-label with sender and timestamp). (ChatPage.tsx: message divs have role="article", tabIndex={0}, aria-label with sender+time; container has onKeyDown handler for ArrowUp/ArrowDown)
    • Given recurring keywords across multiple pulses (e.g., “công nợ” appearing in five pulses over two weeks), the chat answer to “rủi ro gần đây?” reflects the pattern — not just the most recent pulse. (2026-07-02: _recurring_domain_phrases + augmented risk branch in _route_chat_intent; TestRecurringRiskPulseTrend in test_chat_service.py)
    • Given recurring commitment keywords across multiple pulses (e.g., “sẽ chuyển” appearing in five pulses), the chat answer to “cam kết gần đây?” reflects the pattern — not just the open Commitment records. (2026-07-05: _COMMITMENT_PATTERNS imported; commitment branch augmented with _recurring_domain_phrases(org_id, _COMMITMENT_PATTERNS); commitment_trend_header + commitment_trend_line template keys; TestRecurringCommitmentPulseTrend (4 cases) in test_chat_service.py)
    • Given the CEO has never submitted a pulse (empty org history), the message area shows an explicit empty state (“Chưa có dữ liệu pulse — hãy bắt đầu check-in đầu tiên”) rather than a blank screen. (ChatPage.tsx: fetches pulsesApi.list() on mount; hasPulses=false → shows t(‘no_pulse_empty_state’) in the empty panel)
  • As a CEO, I want to ask in natural Vietnamese and get structured answers from my org’s data.

    • Given “việc nào cần quyết?”, then open DecisionRequests (top 5) as a list — not prose around them. — covered by TestChatService (decision intent; §11 audit summary: not a listed gap)
    • Given “rủi ro” / “cam kết” / “task” / “brief” intents, then the corresponding org-scoped list with the fields that matter (severity, due date, owner). — covered by TestChatService (§11 audit summary: not a listed gap)
    • Given “task nào quá hạn?” (overdue keywords), then only due_date < today open/in_progress tasks — not all open tasks. — 2026-06-10 fix; TestChatService overdue tests (§11 row 2 ✅)
    • Given “ai đang bị kẹt?” (blocked keywords), then status="waiting" tasks grouped by owner with counts; empty → “không có ai đang bị kẹt”. — 2026-06-10 intent; TestChatService blocked test (§11 row 3 ✅)
    • Given an empty result, then an explicit “hiện không có…” — never fabricated items. — honesty bar enforced by mock templates + test_chat_fallback_for_unknown; §11 rows 3 + 5 ✅; audit summary: not a listed gap
    • Given an unrecognized question, then Lumi states what it can answer (capability transparency, no fake comprehension). — covered by test_chat_fallback_for_unknown in TestChatService
  • As a CEO, I want threads, so that a line of questioning has context.

    • Threads persist per member; messages ordered; metadata_json available for provenance. — covered by test_chat_messages, test_chat_messages_ordered_with_metadata, test_chat_message_metadata_json_on_send in TestChat
  • Tenant absolute: every answer queries organization_id from request.tenant — a cross-org leak through chat is the worst possible bug in this feature. — covered by test_chat_tenant_isolation, test_chat_message_tenant_isolation in TestChat, and test_chat_service_tenant_isolation in TestChatService

3. Scope Boundaries

  • In: thread CRUD, message send/receive, retrieval over the org’s data graph + LLM synthesis (configured provider per F37) of a grounded answer in the member’s language, assistant action proposals with confirm-before-commit, voice/file capture, progressive web delivery, capability fallback, and recurring-keyword trend aggregation.
  • Out: open-world general-knowledge answers, irreversible/external actions without explicit confirmation, and native/KMP progressive-event parity in this backend/web slice.

§3a — Retrieval → grounded synthesis (the real-mode answer path)

The keyword intents below are no longer the answer — they are the retrieval router. The answer is synthesized by the LLM from the retrieved rows, in the member’s language, and may never exceed what those rows say:

  1. Retrieve (org-scoped, always): from msg_lower, select the relevant slices — open decisions, high/critical risks, overdue tasks, blocked (waiting) tasks grouped by owner, overdue/open commitments, today’s brief items, and a recurring-keyword roll-up across recent pulses (closes the §2 trend AC). Every query filters organization_id from request.tenant. Cap rows per slice (≤20) and serialize to a compact DATA block with stable item references — never raw UUIDs surfaced to the CEO.
  2. Synthesize: call the provider through language_check.check_and_retry(...) with build_system_prompt(member, "chat") + build_chat_prompt(question, data_context). The system prompt’s GROUNDING rules force: answer only from DATA; empty DATA → an honest “nothing on that yet” in the member’s language; never invent. Persist the contributing item references in ChatMessage.metadata_json (provenance).
  3. Mock mode (default in dev/CI): no LLM — the existing deterministic per-intent answers are retained but emitted via member-language templates so vi/en/th round-trip and the honesty bar (“không có” / “nothing yet”) is preserved. This keeps tests deterministic and coverage ≥99%.
  • Failure isolation: LLMProviderError → fall back to the deterministic mock answer for the matched intent (an honest grounded answer beats a 500), logged. Empty content → 400.
  • Tenant isolation is absolute — unchanged and re-asserted: every retrieval query is org-scoped; a cross-org leak through chat is the worst possible bug in this feature.

Why the answer is defensible — the data graph

Chat is a query layer over the org’s own operating graph, not a generic assistant bolted onto a product. A Vietnamese fit-out SME does not run a workspace tool whose chat we must out-answer — its real-time state lives in Zalo threads, an Excel file, and the owner’s head, and none of that is queryable. The defensibility is not the AI (any model can read text — that is table-stakes); it is the accumulated multiplayer graph Lumi builds from pulses, tasks, commitments, risks, customers, and suppliers. Ask “việc nào đang kẹt?” and chat answers from this company’s status="waiting" tasks grouped by owner, with evidence — an answer no open-world chatbot can produce because it cannot see the graph. Every day the org uses Lumi, the graph deepens and the answers get sharper; that compounding is the moat.

Success Metrics

MetricTargetWhy
Weekly chat usage rate>40% of active membersChat must be a daily habit, not a curiosity. Measures whether Lumi’s answers are useful enough to return to.
Intent recognition accuracy>85% of questions correctly routed to the right intentWrong-intent answers destroy trust faster than a blank response. Measured by F15 feedback on chat messages.
CEO answer satisfaction>70% “useful” on chat-message feedbackThe CEO is the primary persona. If chat doesn’t help them decide fast, it fails its job.
Felt latencyFirst useful visible feedback <2s; first proposal <5s; full turn <8s p95 on the approved live suiteCEOs need evidence that work is landing, not empty typing theatre. Mock locks deterministic UI timing; live-provider p95 requires credential-bound evidence.

4. Data Model

chat.ChatThread (member-owned), chat.ChatMessage (role, author_kind, status, metadata_json, agent_trace_json). The normal path enters through apps/chat/agent_loop.py, executes in task_runtime_execution.py, and uses AgentToolbelt for tenant/role-filtered reads and proposals. The synchronous grounded service remains the broker-down fallback.

5. API Contract

MethodPathMin roleNotes
GET/api/chat/threads/memberMy threads
POST/api/chat/threads/create/memberNew thread
GET/api/chat/threads/<id>/messages/memberCursor history ordered by immutable UUIDv7 id, 100/page; compatible {count,count_is_capped,next,previous,results} envelope. count is exact through the documented cap and cap+1 thereafter, so history reads never scan an unbounded thread.
POST/api/chat/threads/<id>/messages/send/memberSend → 201 with a complete reply on the synchronous fallback path, or 202 with a tenant/member-scoped pending assistant message when the agent loop is enabled. The async path emits assistant_progress, grounded assistant_proposals, validated assistant_token, then terminal message_updated events; polling remains the fallback if SSE fails.

6. UI / UX

  • /intel/chat (ChatPage.tsx; /hub/chat permanently redirects). The dock and full page share the same proposal/action derivation and SSE consumer.
  • First-run empty state teaches by example: 3–4 tappable sample questions (“Việc nào cần tôi quyết?”) — this is the training, there is no other.
  • States: the optimistic pending reply immediately gives a topic-specific acknowledgment; tool progress, grounded proposal/action batches, and validated answer chunks replace that acknowledgment as they arrive. The composer is disabled during send. On failure, a toast appears and the typed text is restored so the CEO never retypes it.
  • Message-load failure is an authored inline state, never a false empty. When chatApi.messages() fails, the message panel shows an EmptyState (TriangleAlert icon, error_load_title + error_load_messages copy, error_load_retry action) that re-fetches on click — it must not fall through to the positive first-run prompt panel, which would falsely tell the CEO the thread is empty.
  • i18n: chat.

7. Test Plan

#CaseTypeWhere
1Thread/message endpoints, tenant scopingbackendtests/test_api.py::TestChat
2Each intent: data answer + empty answer + fallbackbackendtests/test_api.py::TestChatService
3Send + render flowe2edescribe("Chat with Lumi")
4Page states: message-load error + retry recovery, send appends user/assistant bubbles, send failure preserves composer textunitfrontend/src/test/ChatPage.test.tsx
5Composer attachment state: a send failure preserves the attached file chip + publicUrl so the CEO retries without re-picking; a send success clears the chip. The pair locks the preserve-on-failure / clear-on-success branch behavior in handleSend.unitfrontend/src/test/ChatPage.test.tsx (preserves the attached file on send failure… + clears the attached file on send success)
6Dock ↔ full-page proposal/action parity (W-AIQ-03): dock renders a ProposalCard, confirm calls the real confirm mutation and shows the executed state, dock renders an ActionCard toounitfrontend/src/test/dock/ChatLumiPanelProposals.test.tsx
7Chat cache race (W-AIQ-02): a send-written proposal survives a stale, deferred-MSW initial-GET response that resolves AFTER the send’s cache writeunitfrontend/src/test/hooks/useChatSendRace.test.tsx
8Markdown XSS-safe loading fallback (W-AIQ-04): raw source renders as inert plain text before the marked/dompurify chunk resolves (no live <script> element ever), then swaps to sanitized HTMLunitfrontend/src/test/markdown/Markdown.test.tsx
9Progressive async turn: first visible feedback is under 2s on mock, each grounded proposal batch renders before final completion, multiple batches accumulate, and both full-page and dock surfaces consume the same member-scoped SSE contractbackend + unit + e2ebackend/tests/assistant/test_f91_p1a_latency_narration.py; frontend/src/test/intel/ChatPage.progress.test.tsx; frontend/src/test/dock/ChatLumiPanelProposals.test.tsx; frontend/tests/e2e/f91-progressive-chat.spec.ts

8. Current Behavior

Shipped. Ask-Lumi chat has real thread, message, and answer flows in the web product and remains distinct from F29 internal human chat.

9. Error States

Backend

ErrorHTTPTriggerHandling
Empty content400content missing or empty string in send request{"detail": "Content required"}
LLM provider failure200/201LLMProviderError from Gemini/OpenRouter (network, timeout, bad payload)Caught inside answer_chat_message, which degrades to a grounded mock answer — the reply is still useful, not an error.
Unexpected AI failure503Any non-LLMProviderError exception while generating the reply in-requestCaught in send_message: user message persisted, a failed assistant message returned, exception reported to Sentry. Frontend shows t('error_send') toast. Replaces the old async-placeholder path that left empty/pending bubbles (LUMI-BE-7).
Thread not found404Invalid UUID or thread belongs to different orgget_object_or_404 in thread_messages and send_message (fixed 2026-06-13)
Language detection silent failure200record_message_language errors are not caught — a failure here would crash the send. Current implementation is pure Python (regex + ORM save), so failure is unlikely.Would surface as 500 on send

Frontend

ErrorTriggerUX
error_load_threadsuseChatThreads query errors with no cached listInline EmptyState (error_load_threads_title + error_load_threads) with an error_load_retry button that calls refetch. Replaces the old toast-only path that left the positive first-run coaching panel on screen — a thread-load failure no longer reads as “you have no conversations”. (2026-06-21: thread-load parity with the message-load path.)
error_load_messagesuseChatMessages query errors with no cached messagesInline EmptyState (error_load_title + error_load_messages) with an error_load_retry button that calls refetch. No false empty-prompt panel; toast suppressed in favor of the in-panel state. Gated on empty cache so a transient background-refetch failure never wipes a loaded conversation.
error_create_threadchatApi.createThread() failsToast error, no thread added to list
error_sendchatApi.sendMessage() failsToast error, sending resets to false, the typed text is restored to the composer, and the attached file is preserved so the CEO can retry without retyping the question or re-picking the file. Closes both halves of the Known Gap below.
error_upload_faileduseUpload hook errorToast error, attached file cleared

Composer State Preservation on Send Failure (formerly: Known Gap — closed 2026-06-22)

handleSend is now optimistic only for the typed text: setInput('') runs before the API call so the composer “feels sent”, but setAttachedFile(null) + resetUpload() were moved inside the success branch so a send failure preserves the attachment intact. The error_send catch additionally restores the typed text. Combined, a network blip on send keeps both the CEO’s words and their attached file ready for one-tap retry — the highest-traffic CEO daily surface (the AI Assistant chat) stays calm and never forces re-pick after a transient failure.

The invariant is locked by two paired regression tests in frontend/src/test/ChatPage.test.tsx:

  • preserves the attached file on send failure so the CEO can retry without re-picking
  • clears the attached file on send success

Together they make the “preserve on failure / clear on success” branch behavior uncommittable to regress.

History / Pulse-Trend Error States

IDScenarioExpected behavior
ES01Pulse history empty — CEO has never submitted a pulseChat answer to history-type queries returns an explicit “Hiện không có dữ liệu pulse” message — never fabricates items. Frontend history list shows the chat.empty_state i18n key rather than a blank area.
ES02Pulse history present but AI extraction failed on one or more entriesChat answers draw only from successfully extracted items. Messages referencing a pulse where extraction errored show the raw pulse text as context (if available) rather than silence. No fabricated extraction.
ES03Pagination edge: last page of message history has fewer items than page_sizeGET /api/chat/threads/<id>/messages/ on the final page returns "next": null. Frontend stops paginating on next: null — no extra request, no “empty page” flash.

10. Diagrams & Docs Touched

DIAGRAMS.md · docs/AGENTS.md → Chat Agent (the intent table there is the product contract).

11. Implementation Audit (2026-06-13)

#ClaimStatusEvidence
1Thread CRUD; messages ordered by created_at; metadata_json present on user + assistant messages✅ matchtest_chat_messages_ordered_with_metadata; test_chat_message_metadata_json_on_send
2Overdue intent: due_date < today filter applied (not all open tasks)✅ match2026-06-10 fix; TestChatService overdue tests
3Blocked intent: status="waiting" tasks grouped by owner; empty → “Không có ai đang bị kẹt”✅ match2026-06-10 intent; TestChatService blocked test
4Tenant isolation: cross-tenant 404 on thread read/send via get_object_or_404✅ matchtest_chat_message_tenant_isolation; test_chat_service_tenant_isolation (2026-06-13 fix from .get() to get_object_or_404)
5Unrecognized question → capability transparency (states what Lumi can answer)✅ matchtest_chat_fallback_for_unknown (strengthened 2026-06-13)
6Empty-org history → explicit “Chưa có dữ liệu pulse” empty state✅ matchChatPage.tsx empty-history check; hasPulses conditional render
7Keyboard navigation + screen-reader labels on message bubbles✅ matchrole="article", tabIndex={0}, aria-label, onKeyDown handler in ChatPage.tsx
8Recurring keyword aggregation across pulses for trend answers✅ match2026-07-02: _recurring_domain_phrases(org_id, _RISK_PATTERNS) counts distinct pulse hits per risk phrase; augmented risk branch in _route_chat_intent appends trend section when ≥2 pulses match; TestRecurringRiskPulseTrend (4 cases: repeat surfaces, threshold, tenant isolation, combined) in tests/api/test_chat_service.py
9Thread read/send scoped by org only, not member — UUID-unguessable but not member-isolated⚠️ gapEdge case “Thread Access Control”; soft isolation via UUID; post-MVP hardening recommended
10Optimistic input clear: user’s typed message + attached file lost on send failure✅ match2026-06-21: handleSend restores the typed text in the error_send catch (ChatPage.test.tsx send-failure restore test). 2026-06-22: attachment-preservation locked behind a regression test pair (preserves the attached file on send failure… + clears the attached file on send success) — §9 Known Gap closed, see §13.
11Visual QC: first-run and empty-thread chat states teach by example instead of showing a blank conversation panel✅ match2026-06-18 visual QC: ChatPage.tsx now shows coached sample prompts for no active thread and empty selected threads; screenshot flow captures fresh-thread, typed, and sent states.
12Visual QC: the conversation rail and active-thread header help users scan context before asking follow-ups✅ match2026-06-18 visual QC: left rail now shows title, preview, and recency metadata per thread; active thread header states whether the thread already has answers or is ready for its first question.
13Thread-load failure shows an inline error + retry (not a misleading positive empty state)✅ match2026-06-21: threadsQuery.isError && threads.length === 0 → inline EmptyState (error_load_threads_title) + retry; ChatPage.test.tsx::shows an inline error with retry when the thread list fails to load
14Reads use TanStack Query (useChat.ts hooks), not useEffect+manual fetch; error panels gated on empty cache so a background-refetch failure never wipes loaded data✅ match2026-06-21: ChatPage consumes useChatThreads/useChatMessages; the previously-dead useChat.ts is now live; ChatPage.test.tsx::keeps the loaded conversation visible if a background refetch fails

Summary: 14/14 match; 1 gap (member-level thread isolation — row 9, post-MVP). Commitment-domain pulse trend extended 2026-07-05 (§2 AC). Recurring-keyword risk trend closed 2026-07-02 (row 8). The optimistic-input-loss, toast-only-load-error, and convention-drift gaps were closed 2026-06-21 (rows 10, 13, 14).

Edge Cases Catalog (formerly §7, 2026-06-12): Key items integrated above. Remaining: input edge cases (empty→400, whitespace bypasses server, long message unlimited, attachment URL treated as plain text); thread behaviors (empty list → empty state, ordering by -updated_at, title fallback); query result limits (5 items per intent, no “N more” indicator; 20 for blocked tasks); language detection runs on every send (passive state update, not answer language). Full catalog in git history (2026-06-12 changelog entry).

12. Open Questions

  • Real-mode LLM chat (RAG over org data) — needs a grounding policy (“answer only from retrieved rows, cite evidence”) before enabling; the mock’s honesty is the bar to beat, not just its fluency.

13. Bug / Change Log

2026-07-16 — Empty-thread startup ownership

Type: Performance behavior preservation

The empty Chat start surface keeps its prompt, desktop thread rail, mobile trust entry, and first-thread actions without loading the active message, markdown, proposal, upload, voice, and composer stack. Conditional language/onboarding banners and confirmation/trust modules now load only when their state or user intent requires them. Existing Chat behavior suites remain green; F40 owns the source-custody and route-budget proof.

2026-07-14 — Contract Graph ownership boundary

The web /intel/chat surface is now represented by contracts/features/F14.cue. F91 remains the canonical owner of chat and assistant APIs, agent-loop grounding, confirmation, memory, autonomy, and native assistant claims; F14 intentionally owns only the route, ChatPage behavior, and web state/accessibility proof. Browser runtime remains an explicit generated gap until the registered journey executes.

2026-07-13 — Immutable UUID cursor and bounded compatibility count

F14 history now uses the unique immutable UUIDv7 id as its cursor, adds the matching org/thread/id index, and co-partitions reactions by denormalizing message_created_at. The required iOS count key remains but is bounded and accompanied by count_is_capped, eliminating an unbounded COUNT(*) on long threads. ADR-043 owns the cross-ledger invariant and conversion path.

2026-07-10 — Dock ↔ full-page proposal parity, chat cache race, XSS-safe Markdown fallback (W-AIQ-02/03/04)

Type: Bug Fix (structural gap + race condition + honesty gap) + Test coverage

Summary — three findings from the W-AIQ audit walks (docs/tests/evidence/web/W-AIQ-02/03/04/walk.md), fixed together since all three sit on the same chat rendering/cache path:

  • W-AIQ-03 (structural FAIL) — dock never rendered proposal/action cards. ChatLumiPanel.tsx mapped every message to a plain text/Markdown bubble and never imported ProposalCard/ActionCard, so a confirm-before-commit proposal the full page showed at /intel/chat was completely invisible in the dock for the identical thread. Fix: extracted the proposal/action derivation ChatPage owned inline (stable identity, dismissal, inline-cache-before-refetch) into a shared hook, frontend/src/hooks/useChatProposals.ts, and now both ChatPage.tsx and ChatLumiPanel.tsx call it — the dock renders the SAME ProposalCard/ActionCard components with the SAME confirm/reject mutations, not a fork. The dock’s own handleSend also now captures proposals/actions from the send response into the shared inline cache, exactly like ChatPage’s createHandleSend.
  • W-AIQ-02 (race, ~1-in-4-6 runs) — stale initial GET could clobber a just-sent proposal. useChatMessages(threadId)’s initial fetch (fired the instant a thread is selected/created) could resolve AFTER a send’s cache write, silently overwriting a just-rendered proposal with the thread’s pre-send (empty) snapshot — no console error, no crash, just a vanished card. Fix: useSendChatMessage (in frontend/src/hooks/queries/useChat.ts) now cancels any in-flight fetch for that exact thread’s messages query in onMutate, before the send’s own network call fires — a cancelled query’s late resolution is discarded by TanStack Query regardless of arrival order. One fix in the shared mutation hook covers every caller (ChatPage AND the dock).
  • W-AIQ-04 (blank-bubble transient) — first Markdown render could paint blank text. Markdown.tsx lazy-imports marked+dompurify (code-split, ~30-130ms cold-load) but started at useState('') and rendered dangerouslySetInnerHTML={{__html: ''}} the whole time, so the FIRST markdown bubble on a page load showed a “complete” (non- pending) assistant bubble with blank text. Fix: html now starts at null; while null, the component renders the raw source as a plain REACT TEXT NODE (never dangerouslySetInnerHTML) so the real reply text is visible immediately and a hostile <script>/onerror= payload is always inert characters, never parsed markup — then swaps to the sanitized HTML once the chunk resolves, same as before.
  • Tests — frontend/src/test/dock/ChatLumiPanelProposals.test.tsx (3: dock renders a proposal card, confirm calls the real mutation and shows the executed state, dock renders an ActionCard too), frontend/src/test/hooks/useChatSendRace.test.tsx (1: deferred-MSW race — the send-written proposal survives a stale GET that resolves after it; verified this test fails without the onMutate cancel and passes with it), frontend/src/test/markdown/Markdown.test.tsx (2: plain-text-then-sanitized-HTML swap, asserting no live <script> element and no unsanitized <script> bytes at any point). Two pre-existing ChatPage.test.tsx assertions were hardened from expect(await screen.findByText(...)).toBeInTheDocument() to await waitFor(() => expect(screen.getByText(...)).toBeInTheDocument()) — the former can hold a stale element handle across the (now real, always-present) plain-text → sanitized-HTML swap, which legitimately replaces the matched DOM node.

2026-07-09 — Auto-title chat threads from the first user message (P0 design-review defect)

Type: Bug Fix (UX defect — assistant-first doctrine)

Summary:

  • Defect. Threads were created with title="" and nothing ever named them, so the mobile/web assistant home rendered walls of “Untitled thread” — an assistant that can’t name its own conversations reads as broken (F91 doctrine). No LLM/async titling hook existed anywhere; this heuristic is the first and primary titling path.
  • Fix. New pure helper derive_thread_title(text) in apps/chat/services.py: strip markdown (fences, inline code, links/images, headings, blockquotes, list markers, emphasis) → strip emoji → collapse to a single line → truncate to 60 chars on a word boundary → drop trailing punctuation; blank/emoji-only input → "" (caller skips). maybe_autotitle_thread(thread, content) sets the title only when currently blank (user renames and the messaging-bridge sentinel title are never overwritten).
  • Wiring. Called in the message-creation service paths — apps/chat/views.py _send_message_agent_loop (both the normal path and the daily-ceiling path; the inline broker-down fallback reuses the same already-created user message) and apps/telegram/agent_bridge.py _enqueue_agent_loop — so web, mobile, and Telegram all benefit. Tenant isolation untouched (thread is resolved org-scoped before the call).
  • Tests. tests/api/test_chat_autotitle.py — 21 cases: helper units (markdown, emoji, Vietnamese text, 60-char word boundary, blank), guard units (blank-only set, never-overwrite), integration (first message titles, second doesn’t overwrite, pre-titled untouched, cross-tenant 404 leaves title blank, Telegram bridge titles).

2026-07-05 — Recurring commitment-keyword pulse trend (§2 JTBD commitment AC)

Type: Feature (AI service enhancement) + Test coverage

Summary:

  • What was missing. When a CEO asked “cam kết gần đây?” the chat returned only open Commitment objects — identical gap to what the risk branch had before 2026-07-02. The _COMMITMENT_PATTERNS list and _recurring_domain_phrases function already existed; the commitment branch just didn’t use them. Phrases like “sẽ chuyển” appearing in five consecutive pulses signal recurring payment-promise patterns even when no formal Commitment record was created.
  • Commitment intent branch augmented in _route_chat_intent: after the open-commitment list, calls _recurring_domain_phrases(organization_id, _COMMITMENT_PATTERNS) and appends a “Từ khóa cam kết lặp lại trong check-in gần đây:” section when recurring patterns are found. Both Commitment objects and trend data shown when both exist; empty state fires only when neither is present.
  • Two new template keys in apps/ai/_templates.py: commitment_trend_header (fixed, vi/en/th) and commitment_trend_line (formatted, vi/en/th, {term} + {count}).
  • 4 new tests in tests/api/test_chat_service.py::TestRecurringCommitmentPulseTrend:
    • test_commitment_query_includes_pulse_trend_when_keyword_repeats — ≥2 distinct pulses → phrase surfaces
    • test_commitment_query_trend_respects_count_threshold — 1 pulse → phrase absent
    • test_commitment_query_trend_is_tenant_scoped — cross-tenant pulses not visible
    • test_commitment_query_with_objects_and_trend_shows_both — Commitment record + trend shown together
  • Verification: 6564 passed, 97.15% coverage (≥97% gate). §2 commitment AC checked.

2026-07-02 — Recurring risk-keyword pulse trend (§11 row 8 closed)

Type: Feature (AI service enhancement) + Test coverage

Summary:

  • What was missing. When a CEO asked “rủi ro gần đây?” the chat returned only the list of open Risk objects from the database. The §2 AC required the answer to also reflect recurring patterns across pulse check-ins — e.g. “công nợ” appearing in 5 consecutive pulses signals a systemic cash-flow issue even when no explicit Risk item was created.
  • New function _recurring_domain_phrases(organization_id, patterns, *, recent=50, limit=3) in apps/ai/services.py: scans the most recent 50 pulse entries, performs phrase-level substring matching against a domain pattern list (preserving multi-word Vietnamese phrases like “công nợ” that word-tokenisation would lose), counts distinct pulses per phrase, returns top 3 phrases with count ≥ 2. Tenant-scoped via PulseEntry.filter(organization_id=...).
  • Risk intent branch augmented in _route_chat_intent: after the open-risk list, calls _recurring_domain_phrases(organization_id, _RISK_PATTERNS) and appends a “Từ khóa rủi ro lặp lại trong check-in gần đây:” section when recurring patterns are found. Both risk objects and trend data are shown when both exist; the empty state fires only when neither is present.
  • Two new template keys in apps/ai/_templates.py: risk_trend_header (fixed, vi/en/th) and risk_trend_line (formatted, vi/en/th, {term} + {count} placeholders).
  • 4 new tests in tests/api/test_chat_service.py::TestRecurringRiskPulseTrend:
    • test_risk_query_includes_pulse_trend_when_keyword_repeats — ≥2 distinct pulses → phrase surfaces
    • test_risk_query_trend_respects_count_threshold — 1 pulse → phrase absent
    • test_risk_query_trend_is_tenant_scoped — org_b pulses not visible to org_a query
    • test_risk_query_with_risks_and_trend_shows_both — Risk object + trend both appear

Verification: 4878 passed, 97.12% coverage (≥97% gate). §11 row 8 flipped ⚠️ → ✅. §2 AC checked.

2026-07-02 — Spec drift correction: check 5 ACs confirmed by §11 audit

Type: Documentation (spec drift fix)

Summary:

  • Corrected §2 AC checkboxes to reflect the actual implementation state per the §11 audit.
  • Checked 5 previously-unchecked ACs that were confirmed done by §11: decision intent (audit summary: not a listed gap), rủi ro/cam kết/task/brief intents (audit summary: not a listed gap), overdue-tasks filter (row 2 ✅), blocked-tasks grouped by owner with empty case (row 3 ✅), and the empty-result honesty guarantee (rows 3 + 5 ✅; audit summary: not a listed gap).
  • Left 1 AC as [ ]: recurring-keyword aggregation across pulses (§11 row 8 ⚠️ gap — still unimplemented in ChatService).
  • No code, test, API, or status change.

2026-06-22 — Close the §9 Known Gap: attachment preserved on send failure (regression test pair)

Type: Test (lock the invariant) + Spec correctness (close stale Known Gap)

Summary:

  • State of the code before this PR. PR #298 (feat(chat): thread-load inline error + TanStack convergence, 2026-06-21) restructured handleSend to clear setAttachedFile(null) + resetUpload() inside the success branch rather than before the API call — so the attachment-preservation half of the §9 Known Gap was already implemented in production. The spec, however, still documented the gap as open (“a restored file attachment is not re-attached on failure”). No regression test locked the new branch behavior, so a future refactor that moved the clear back outside the try block — or into a finally — would re-open the gap without anyone noticing until a CEO retried a chat with an attachment over a flaky network.
  • The fix. Two paired regression tests in frontend/src/test/ChatPage.test.tsx lock the full branch behavior:
    1. preserves the attached file on send failure so the CEO can retry without re-picking — mocks uploadsApi.presign + uploadsApi.complete + a successful S3 fetch to make the chip render, mocks chatApi.sendMessage to reject, asserts the send did fire with the attachment URL in the body (...\n\nhttps://.../y.pdf), the chip text (q4-plan.pdf) is still on screen after the failure, and the typed composer text is restored too.
    2. clears the attached file on send success — same setup, send resolves; the chip disappears. Without this symmetric assertion, a regression that preserved the chip on success too (e.g. moving the clear into a finally block) would slip past the failure-path test.
  • The spec §9 was updated: the “Known Gap: Optimistic Input Clear” section is now “Composer State Preservation on Send Failure (closed 2026-06-22)” — documenting the actual code shape (input cleared optimistically, attachment cleared only on success, both restored on failure) and pointing at the two regression tests as the invariant lock. §7 Test Plan gains row 5 naming the two tests.

Why it ships now: the AI Assistant chat is the highest-traffic CEO daily surface in Lumi (the “calm reliability” that earns the daily habit). A send-failure that drops either the words or the file is exactly the kind of paper cut that pushes a CEO off the daily-habit channel. PR #298 closed the code half a week ago; this PR locks it behind a test and updates the spec so the next engineer doesn’t accidentally re-open the gap. No new dependencies, no new copy strings, no migration, no API change, no auth/tenancy/AI-evidence touch.

Tests:

  • frontend/src/test/ChatPage.test.tsx: 10 → 12 (two new cases, both green).
  • Full frontend suite: 9968 passed / 16 skipped (134 files). tsc --noEmit clean. node scripts/check-design-tokens.mjs clean.

POLISH:

  • F14 QA was 9/10; the regression pair raises confidence that the calm-reliability invariant holds. No spec-level POLISH bump claimed without the audit doc.

2026-06-21 — IOS-9: messages envelope missing count (iOS decode failure)

Type: Fix (API contract / mobile compatibility) + Regression test

Summary:

  • Root cause. thread_messages (GET /api/chat/threads/<id>/messages/) paginates with MessageCursorPagination (a DRF CursorPagination subclass), whose envelope is {next, previous, results} — no count. Shipped iOS clients decode a generic paginated envelope that requires count (every other paginated DRF endpoint in the app is count-bearing), so the chat-history decode failed with keyNotFound "count" at (root) (Sentry lumi-ios, 6 events).
  • Fix (backward-compatible superset). MessageCursorPagination now overrides paginate_queryset to stash the full (tenant + thread scoped) queryset and get_paginated_response to emit {count, next, previous, results} — next/ previous/results unchanged from cursor pagination. This first fix used an exact .count(); ADR-043 later bounded that compatibility field at 10,001 and added count_is_capped, while preserving exact values below the cap.
  • Tests (tests/test_api.py::TestChatMessagePagination): count present and equal to thread total below the cap; count is the full total across pages (not page length); count is tenant+thread scoped (other-org read still 404/403, sibling thread doesn’t inflate); existing next/previous/results cursor behavior preserved.

2026-06-21 — Thread-load inline error + TanStack Query convergence (follow-on)

Type: Fix (UX) + Convention convergence + Test coverage

Summary:

  • Closed the remaining false-empty path. The earlier same-day fix handled the message-load failure but left the thread-list load failure toast-only — so a failed thread load still rendered the positive first-run coaching panel, falsely telling the CEO they have no conversations. ChatPage now shows an inline EmptyState (error_load_threads_title + error_load_threads) with an error_load_retry button on thread-load failure, matching the message-load path.
  • TanStack Query convergence (promoted Next-candidate ④ from the prior entry). Thread + message reads migrated from useEffect + .then() to the previously-dead useChat.ts hooks (useChatThreads/useChatMessages), which supply isError/ refetch. The manual messagesError boolean + loadMessages callback are retired. Send/create cache updates kept in the page via setQueryData so the optimistic append is unchanged. Both error panels are gated on an empty cache, so a transient background refetch failure (prod runs refetchOnWindowFocus) never wipes a loaded conversation.
  • i18n: added error_load_threads_title (en + vi, propagated); reuses the existing error_load_title/error_load_retry keys.
  • Tests: ChatPage.test.tsx gains thread-load error + retry and a background-refetch-does-not-wipe-cache regression; the send-append assertion is scoped to the conversation log (the assistant reply also legitimately previews in the thread rail). Full suite green (9878 passed).
  • No backend, API, route, or data-model change; tenant isolation unchanged. POLISH F14 already at UX 9 / QA 9; this closes the convention-drift and thread-load gaps that held the audit short of full convergence.

2026-06-21 — Inline message-load error + composer text preservation (POLISH convergence)

Type: Fix (UX/QA)

Summary:

  • Killed the false-empty on load failure. When chatApi.messages() failed, ChatPage fired a toast and left messages empty, so the positive first-run prompt panel rendered — falsely telling the CEO the thread was empty. It now renders an authored inline EmptyState (TriangleAlert, error_load_title + error_load_messages) with a working error_load_retry button that re-fetches the thread’s messages. Mirrors the F23 Deadline Horizon error pattern.
  • Composer text preserved on send failure. handleSend now captures the typed text and restores it in the error_send catch, so the CEO never retypes their question. Closes the text half of §9’s Known Gap; file-attachment restoration remains a tracked Next candidate.
  • QA coverage raised. frontend/src/test/ChatPage.test.tsx gains tests for the message-load error + retry recovery, the send/append flow, and input preservation on send failure (§7 row 4).
  • i18n: added error_load_title, error_load_retry to chat (en + vi, propagated to all locales).
  • No backend, API, route, or data-model change; tenant isolation unchanged. POLISH F14 UX 8→9, QA 7→9 (audit doc updated).

2026-06-21 — Floating Chat Lumi pod (Command Dock)

Type: Feature (UI/UX)

Summary:

  • Chat Lumi now lives in a floating, bottom-end Command Dock (frontend/src/components/dock/) present on every authenticated screen, alongside the Messages pod — so the daily AI habit follows the CEO instead of living behind a sidebar click.
  • ChatLumiPanel is a compact, fully interactive thread (continues the latest thread, opens one on first send) reusing chatApi; an arrow-out control deep-links to the full /hub/chat surface for deep work.
  • The pod carries the ambient “needs you” unread signal (notifications count) via the shared useUnreadCounts aggregator; reduced-motion respected; Escape closes; single panel open at a time.
  • Tests: frontend/src/test/CommandDock.test.tsx (pod render, toggle, single-open, Escape, unread aria).

2026-06-18 — Visual QC: chat first-run empty state coaching

Type: UX Fix

Summary:

  • The screenshot flow captured /hub/chat as a mostly blank right pane with one passive empty-state sentence.
  • This fails §6’s requirement that first-run chat teaches by example with tappable sample questions.
  • Added a coached empty state for both no active thread and empty selected threads, with sample prompts about decisions, overdue work, risks, and blocked owners.
  • The screenshot helper now clears stale per-flow PNGs before capture, and the chat flow creates a fresh thread so the gallery includes the coached state plus typed/sent states.

Verification:

  • frontend/screenshots/12-chat/02-chat-thread-ready.png shows the empty-thread coaching.
  • frontend/tests/e2e/screenshot-flow.spec.ts creates a fresh thread and captures typed/sent states.

2026-06-18 — Visual QC: conversation rail metadata and active-thread context

Type: UX Fix

Summary:

  • The chat screen still looked generic in screenshot review because the thread rail was a stack of repeated titles with little context.
  • Added a clearer rail header, per-thread preview snippets, and recency metadata so repeated brief-style threads are easier to distinguish at a glance.
  • Added an active-thread header that explains whether the thread already has context or is waiting for its first question.
  • Tightened the Vietnamese sample copy from mixed English/Vietnamese (“Task”) to a fully localized phrase.

Verification:

  • frontend/src/test/ChatPage.test.tsx covers rail metadata and active-thread context.
  • frontend/screenshots/12-chat/02-chat-thread-ready.png and 04-chat-after-send.png show the new hierarchy.

2026-06-16 — Fix: chat replies generated synchronously in-request (LUMI-BE-7)

Type: Bug Fix (production outage)

Summary: /hub/chat returned empty “Lumi” bubbles in production — every reply was blank. Root cause: the P10 async refactor offloaded reply generation to a Celery task (process_chat_async.delay), but no Celery worker was ever deployed (only backend/frontend ECS task definitions exist) and the Redis broker was unreachable (OperationalError: [Errno 111] Connection refused, Sentry LUMI-BE-7, 12 events / 2 users). The empty placeholder assistant message was therefore never filled.

Fix: send_message now generates the reply synchronously in the request via answer_chat_message and returns 201 with a complete, non-empty assistant message — no worker, no broker dependency (true scale-to-zero: $0 at idle, no cold-start latency on an interactive surface). answer_chat_message already degrades to a grounded mock on LLMProviderError; any other exception yields a 503 with a failed assistant message (user message still persisted) instead of a silent empty bubble. The process_chat_async Celery task is retained for genuine future background work but is no longer on the chat send path.

Tests: test_api.py::TestChat::test_send_message_is_synchronous_no_pending_placeholder, ::test_send_message_ai_failure_degrades_gracefully; test_performance.py::test_send_message_returns_201_with_complete_reply; test_ai_language.py::test_send_message_uses_build_system_prompt (patch target updated). View coverage 100%.

2026-06-16 — RAG-over-graph + LLM synthesis specified (answers in member’s language)

Type: Spec Update (design for implementation)

Summary:

  • Added §3a: keyword intents become the retrieval router, not the answer. Retrieved org-scoped rows (decisions, risks, overdue/blocked tasks, commitments, brief items, recurring-keyword pulse trend) are synthesized by Gemini/DeepSeek (F37) into a grounded answer in the member’s language, with provenance in metadata_json.
  • Mock mode keeps deterministic per-intent answers but emits them via member-language templates (vi/en/th); provider failure falls back to the mock answer (no 500).
  • Closes the long-standing gaps: §2 recurring-keyword trend AC (row 8), system_prompt now actually consumed (was ignored by the keyword matcher), and the F37 “answer in user language” contract now reaches chat output.

Reason:

  • §11 row 8 (no trend aggregation) and §12 (real-mode RAG needs a grounding policy) were open; the keyword matcher ignored build_system_prompt and always answered in Vietnamese. This specifies the grounded, language-correct answer path while preserving the honesty bar.

Impact:

  • Real-mode behavior + answer language change; thread/message API and tenant isolation unchanged. Status flips when implemented + tested. Mock determinism preserved for CI.

Tests: retrieval tenant-scoping, mock template language (vi/en/th), empty-data honest answer, real-mode synthesis with provider mocked + metadata provenance, provider-failure fallback, recurring-keyword trend answer.

2026-06-15 — Frontend route namespaced

Frontend route namespaced: /chat -> /hub/chat (app-wide IA restructure; API paths unchanged).

2026-06-14 — Strategy v2 realignment (positioning)

Type: Documentation Change

Summary:

  • §1 intro: reframed chat as a query layer over the org’s own data graph (the moat), with AI explicitly called table-stakes.
  • §3 out-of-scope: “general-knowledge chat (Lumi is not ChatGPT)” → “general-knowledge chat (chat answers only from the org’s own data graph, never the open world)”.
  • §3: deleted the “Competitive Positioning” table (Notion AI / Jira AI / ClickUp AI) and replaced it with a short “Why the answer is defensible — the data graph” paragraph framing the moat as the accumulated multiplayer graph, answering “việc nào đang kẹt?” from the org’s own status="waiting" tasks with evidence.

Reason:

  • Realignment to competitive-strategy-v2.md and the registry’s Positioning doctrine (v2): do not position vs US SaaS (Notion/Jira/ClickUp); AI is table-stakes and the data graph is the moat; no “Lumi is not ChatGPT”-style competitive framing.

Impact:

  • Positioning only. No product behavior, API, data model, or test changed.

Tests:

  • None (documentation).

Notes:

  • The intent contract (§2 ACs) and the honesty bar (“không có”, never invent) are unchanged — only the competitive framing around them.

2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)

Type: Documentation Change

Summary:

  • §7 Edge Cases absorbed into §11 note; Test Plan moved from §9 back to §7; “Current Behavior” numbered as §8; §8 Error States → §9; §9 Test Plan → §7; §10 Diagrams stays §10; §11 Implementation Audit stays §11 (10-row table); §12/§13 unchanged.
  • §11 audit: 10 rows, 7 ✅, 3 gaps (recurring-keyword trend, member-level thread isolation, optimistic input loss).

2026-06-14 — Perf fix: cursor pagination + ChatThread index + Subquery last_message (BE-D r02)

Type: Performance Fix

Summary:

  • G1 fixed: thread_messages uses MessageCursorPagination (100/page, cursor-based).
  • G2 fixed: ChatThread.Meta composite index (organization, user, -updated_at).
  • G3 fixed: list_threads uses Subquery annotations instead of unbounded Prefetch.
  • Score 7→8. docs/POLISH.md F14 Perf [7]→[8].

2026-06-13 — AC verification: capability transparency, thread persistence, tenant isolation tests (fleet agent T1-B, round 28)

Type: Test Coverage / Spec Update

Summary:

  • AC1 (capability transparency): strengthened test_chat_fallback_for_unknown to assert the answer includes Lumi’s capability statement.
  • AC2 (thread persistence): added test_chat_messages_ordered_with_metadata and test_chat_message_metadata_json_on_send.
  • AC3 (tenant isolation): added test_chat_message_tenant_isolation and test_chat_service_tenant_isolation. Fixed chat/views.py: changed thread_messages and send_message from .get() to get_object_or_404 so cross-tenant access returns 404 instead of 500.

2026-06-13 — JTBD story (pulse history trends), accessibility note, ES01–ES03 (fleet agent G, round 25)

Type: Spec Sharpening

Summary:

  • Added JTBD story to §2 with three ACs covering keyboard navigation, recurring-keyword surfacing, and empty-history empty state.
  • Added ES01–ES03 to error states: pulse history empty → “no data” message; AI extraction failure → raw text fallback; pagination last page → next: null.

2026-06-12 — Chat spec hardening: edge cases and error states audit

Type: Spec Update

Summary:

  • Added Edge Cases section: message input validation, thread behaviors, thread access control gap, query result limits per intent, language detection integration.
  • Added Error States section: backend errors (empty content 400, LLM provider 500, thread 404, language detection silent failure) and frontend errors (toast with i18n keys). Documented optimistic input clear gap.

2026-06-12 — Competitive positioning and success metrics (fleet agent G, round 21)

Type: Spec Sharpening

Summary:

  • Added Competitive Positioning table (Notion AI, Jira AI, ClickUp AI) and Success Metrics table to §3.

2026-06-10 — Chat intent fixes: overdue filter, blocked intent, drop raw confidence

Type: Bug Fix

Summary:

  • “quá hạn”/“overdue” keywords now filter due_date < today instead of returning all open tasks.
  • New “blocked” intent (kẹt|bị chặn|blocked) answers with status="waiting" tasks grouped by owner.
  • Decision-request answers no longer print raw confidence: 0.6 substrings.

2026-07-14 — Progressive grounded proposals on every web chat surface

Type: Performance / transport contract (#1552)

Summary:

  • The agent loop publishes each proposal/action batch immediately after the tool result has passed the existing collection and grounding seam, before final-answer synthesis finishes.
  • /intel/chat and the dock now share useChatEventStream; both accumulate proposal batches, render the same cards during the pending turn, and keep polling as the SSE fallback.
  • A useful topic-specific acknowledgment is optimistic and immediate. The deterministic Chromium proof holds the HTTP send open and observes that feedback in under two seconds.
  • Final-answer faithfulness is unchanged: only validated answer content is chunked into assistant_token events, and message_updated remains the terminal persisted truth.

Evidence:

  • backend/tests/assistant/test_f91_p1a_latency_narration.py
  • frontend/src/test/intel/ChatPage.progress.test.tsx
  • frontend/src/test/dock/ChatLumiPanelProposals.test.tsx
  • frontend/tests/e2e/f91-progressive-chat.spec.ts

Boundary: native/KMP consumption is deliberately deferred to the F30 audit; no mobile runtime claim is made. Live-provider p95 remains credential-gated and is tracked by the existing F91 live-proof gap rather than inferred from mock timing.