partial — Contract Graph migration complete; browser and live-provider evidence remain explicit gaps

Spec: F37 — AI Language Contract (Detect, Store, Answer In The User’s Language)

Status: partial — Contract Graph migration complete; browser and live-provider evidence remain explicit gaps

Contract Graph: contracts/features/F37.cue · implementation audit. The authenticated web/API language state, deterministic detection, prompt wiring, retry logic, and banner unit behavior are contract-backed. This does not establish browser execution, live LLM language quality, production configuration, or Sentry alert operation.

1. Problem & CEO Job

Trust the signal. Vietnamese is the first-class AI language for Lumi’s first market: Vietnam interior fit-out, renovation, and small M&E contractors. Ask Lumi (F14), extraction titles (F08), and Brief copy (F12) must speak it natively. Prompts are engineered in English (the language models follow it best), but user-facing output is always the user’s preferred language — detected once, stored on the profile, and honored everywhere.

The mechanism is intentionally language-agnostic (§4/§5): build_system_prompt, the detection streak, and post-check/retry serve any locale. Expansion remains behind Vietnam vertical proof: F45/F69 country and language breadth becomes mechanical only after the first vertical is won.

Without this contract, each AI feature reinvents language handling and feels foreign at its most personal moments.

2. User Stories & Acceptance Criteria

  • As a new user, I want Lumi to learn my language without being asked twice, so that setup is invisible.

    • Given registration through the web/mobile UI, when I register, then my profile language is set from my active UI locale (the i18n choice already made), not from a separate question.
    • Given my first messages to Ask Lumi are consistently in a different language than my profile (≥3 consecutive messages, detection confidence high), when the mismatch is established, then Lumi offers a one-tap “Switch to ?” affordance — it never switches silently.
  • As a member, I want every AI answer in my preferred language, so that Lumi speaks like a colleague, not a tool.

    • Given profile language=th, when I send any Ask Lumi message (in any language), then the response is in Thai.
    • Given profile language=vi, when extraction (F08) generates commitment/risk/decision titles from my pulse, then those titles are in Vietnamese.
    • Given the model replies in the wrong language (it happens), when the response is post-checked, then one retry with a reinforced language directive is made before delivering; the miss is logged.
  • As a CEO with a multilingual team, I want each member served in their own language within one org, so that language is per-person, never per-org.

    • Given members with vi, en, th profiles in one org, when each opens their Brief/Ask Lumi, then each sees their own language; shared objects (task titles created by extraction) use the author’s language.
  • As any user, I want to change my language once and have it stick everywhere, so that there is one setting, not three.

    • Given PATCH /api/auth/me/language/, when I change it, then UI locale, Telegram digests (F21), email digests (F35), push copy (F36), and all AI surfaces follow — there is exactly one language field.

3. Scope Boundaries

  • In: profile language as the single source of truth; registration-time capture from UI locale; passive content-based detection with explicit confirm; the prompt-engineering contract (English system prompts + language directive + post-check/retry); application to F14 chat and F08 real-mode extraction; shared build_system_prompt() helper all AI callers must use.
  • Out: machine-translating the UI (that’s F18), translating other users’ content (a Thai member sees a Vietnamese task title as written — human content is never auto-translated in v1), per-message language override UI, org-level default language (per-person only). In (this revision): the provider layer (Gemini/DeepSeek/OpenRouter), the live post-check/retry path, and the chat/brief user-prompt builders — these are the mechanism the contract binds, specified here and implemented under F12/F14.

4. Data Model

  • User.language — already exists (accounts/models.py, default vi). Two deltas:
    1. max_length=5 → max_length=10 (BCP-47 codes like zh-Hant are 7 chars — today’s field silently can’t store locales the UI already offers).
    2. Choices must match the F18 frontend locale list — generated from one shared list, not maintained twice.
  • User.language_detected (nullable, BCP-47) + language_detect_confidence — passive detection state; only promoted to language on explicit user confirm.
  • No new tables. Detection events are not stored per-message (no chat-log mining tables — scale-check).

Provider layer & model routing

apps/ai/providers.py exposes one interface — complete(system_prompt, user_prompt) — behind get_provider(), selected by settings.LLM_PROVIDER. Providers:

ProviderLLM_PROVIDERModel defaultRole
Mockmock (default)—Deterministic offline behavior for dev/CI (ADR-002); honors the language contract via templates
Geminigeminigemini-2.5-flashFast, cheap synthesis — brief generation, extraction
DeepSeekdeepseekdeepseek-chatStrong reasoning at low cost — Ask Lumi chat (OpenAI-compatible REST at https://api.deepseek.com)
OpenRouteropenroutergoogle/gemini-2.5-flashEscape hatch — any model by id
  • DeepSeek is added as a first-class provider: DEEPSEEK_API_KEY, DEEPSEEK_MODEL (default deepseek-chat), OpenAI-compatible chat/completions. It shares the OpenAICompatibleProvider shape with OpenRouter (one base class, two endpoints/keys).
  • complete() gains optional keyword args temperature and response_json (bool — request strict-JSON output for the brief). Callers own retries; the provider owns timeouts (30s) and error normalization (LLMProviderError) only.
  • get_provider() fails loud (LLMProviderError) on unknown provider or missing key — it never silently falls back to mock.

5. API Contract

MethodPathAuth/RoleRequestResponseErrors
PATCH/api/auth/me/language/user{language}existing endpoint — gains validation against shared locale list400 unknown code
POST/api/auth/me/language/confirm-detected/user{}promotes language_detected → language409 nothing detected

Prompt contract (the heart of this spec — binding on every AI caller)

Every system prompt is engineered in English (the language models follow most reliably) and is assembled from three layers by one shared helper:

[1] IDENTITY + ROLE      — who Lumi is, and the surface-specific job
[2] OPERATING RULES      — grounding / anti-hallucination / honesty / tone / output
[3] RESPONSE LANGUAGE    — the binding directive (appears LAST and STRONGEST)
SYSTEM (always English):
  You are Lumi — the AI Chief of Staff for the CEO of a small or mid-sized business.
  <surface role block: chat | brief | extraction | email | push>

  <surface OPERATING RULES block — see §5a>

  RESPONSE LANGUAGE: Respond ONLY in {language_name} ({bcp47}). Every word of
  user-facing output — titles, summaries, suggestions, list items — must be in
  {language_name}. Do not mix languages. Technical identifiers (codes, URLs,
  field names) stay as-is. If you are about to answer in any other language, stop
  and answer in {language_name}.

USER: <DATA block (grounded rows, see F12/F14)> + <untranslated user content>
  • One shared helper — apps/ai/prompts.py::build_system_prompt(member, surface) — produces the full block; AI callers (F12 brief, F14 chat, F08 real-mode extraction, F35 email, F36 push) are forbidden from hand-rolling system prompts.
  • Two surface-specific user-prompt builders compose the grounded DATA block: build_chat_prompt(question, data_context) (F14) and build_brief_prompt(items) (F12). They never restate the language rule — that lives only in the system prompt.
  • Post-check / retry (now live, not plumbing): every real provider call goes through apps/ai/language_check.py::check_and_retry(...). It runs the charset/stopword heuristic on the response; on a detected mismatch it retries once with a reinforced reminder, then delivers best-effort and logs wrong_language_delivered to Sentry with the locale pair.
  • Mock provider (default in dev/CI) bypasses the LLM but still honors the contract — its deterministic template strings are selected by the member’s language via the same helper, so vi/en/th all round-trip before any real provider is live.
  • EvidenceSource and confidence rules (CLAUDE.md Rule 2) are unchanged — language never alters provenance.

Tenant scoping: language is user-level (crosses orgs deliberately — a person’s language doesn’t change per company).

§5a — Engineered OPERATING RULES per surface (canonical text)

These blocks are the source of truth for _SURFACE_CONTEXT / operating rules in apps/ai/prompts.py. They encode prompt-engineering best practice: explicit role, hard grounding rules, honesty, output discipline, and tone.

chat (Ask Lumi):

You serve one person: the leader running their company day to day. Your job is to
help them see clearly and decide fast — using ONLY what their company's own data shows.

GROUNDING (non-negotiable):
- Answer ONLY from the DATA section in the user turn. It is the complete set of facts
  you may use about this company. Treat the open world as unknown.
- If DATA is empty or does not contain the answer, say so plainly. Never guess, never
  fill the gap with outside knowledge, never invent names, numbers, dates, owners, or items.
- A short honest "there is nothing on that yet" is correct and valued. A fluent but
  unsupported answer is the worst possible failure and destroys the CEO's trust.
- Every fact you state must trace to a row in DATA. Do not inflate or soften it.

HOW A CHIEF OF STAFF ANSWERS:
- Lead with the answer. No preamble, no "Sure!", no restating the question.
- Prioritize: surface what needs attention first — overdue, critical, blocked, decisions
  due. Rank lists with the most urgent on top.
- Be concise and scannable: short sentences, tight bullets. The CEO reads on a phone
  between meetings.
- Be specific: name the person, the item, the due date, the amount — taken from DATA.
- When useful, end with ONE crisp suggested next action.
- Never expose raw confidence scores, internal UUIDs, or field names to the CEO.

brief (CEO Morning Brief):

You are writing the CEO's morning brief — the one screen they read before anything else.

INPUT: a ranked list of BRIEF ITEMS already selected from the company's data (decisions,
risks, overdue commitments/tasks, customer/cash impacts, deals). Each item has a type,
title, and underlying facts.

YOUR JOB:
1. Write a 2–3 sentence executive summary of the day: what matters most, what is at
   stake, where attention is needed. Calm, sharp, specific. No filler, no greeting.
2. For each item, write ONE short next-move suggestion (≤140 chars): who to nudge, what
   to decide, what to check.

GROUNDING (non-negotiable):
- Use ONLY the facts in the items. Never invent owners, numbers, dates, or items.
- Do not add items not in the list, and do not drop the urgency the data shows.
- If the list is empty, the summary says the day is clear and nudges the team to check in.

STYLE: a trusted chief of staff briefing a busy founder — direct, prioritized, zero fluff.

OUTPUT: strict JSON — {"summary": "<text>", "suggestions": {"<item_id>": "<action>"}}.
All user-facing values must be in the response language.

extraction (F08 real mode): unchanged contract — extract commitments/risks/decisions as JSON; titles in the author’s language; confidence 0.85–0.90; never 1.0; quote evidence.

6. UI / UX

  • No new pages. The existing language switcher remains the only manual control.
  • “Switch to ?” confirm: one dismissible banner in Ask Lumi when detection threshold met (shadcn Alert); dismiss = don’t ask again for 30 days (local).
  • i18n: chat.language_switch_offer, chat.language_switch_accept keys, en + vi first, all locales per F18 process.

7. Test Plan

#CaseTypeWhere
1Registration sets profile language from active UI localebackendbackend/tests/test_ai_language.py
2build_system_prompt injects correct language directive for every supported locale; rejects unknown codesbackendsame
3Mock chat/extraction output uses member’s language templates (vi/en/th sampled)backendsame
4Wrong-language post-check triggers exactly one retry, then logsbackendsame (provider mocked)
5Detection: 3 consecutive foreign-language messages set language_detected; confirm endpoint promotes; never silent-switchesbackendsame
6max_length migration: zh-Hant stores and round-tripsbackendsame
7One language field drives Telegram/email/push copy (integration with F21/F35/F36 senders)backendrespective test files
8Language switch offer banner appears/confirm flowse2efrontend/tests/e2e/notifications.spec.ts or chat spec

Backend coverage stays ≥99%; providers always mocked (deterministic).

8. Current Behavior

  • User.language stores extended BCP-47 locales (max_length=10); language_detected and language_detect_confidence fields exist.
  • PATCH /api/auth/me/language/ validates against the full SUPPORTED_LOCALES list (60+ codes); wired at apps/accounts/views.py::update_language.
  • POST /api/auth/me/language/confirm-detected/ is wired at apps/accounts/views.py::confirm_detected_language and routed at me/language/confirm-detected/; tested in backend/tests/test_ai_language.py (T-IDs F37-11, F37-12).
  • apps.ai.prompts.build_system_prompt(...) exists, rejects unknown locales, and covers all 5 AI surfaces.
  • apps.ai.language_detection.py implements the 3-consecutive-message streak heuristic and writes language_detected — wired into chat/views.py::send_message (calls record_message_language after the user message is saved).
  • F14 answer_chat_message is still a keyword matcher (no LLM call), but build_system_prompt is now wired into chat/views.py::send_message — called with (request.membership, "chat") and passed as system_prompt= kwarg to answer_chat_message. The plumbing is ready for real-provider adoption.
  • Registration already converges profile language to the active UI locale via Accept-Language; later drift can still happen only if the user manually changes either side after signup.
  • ✅ **Frontend confirmation banner shipped: LanguageSuggestionBanner.tsx renders in the Chat page and TodayPage when language_detected ≠ language, with Accept/Decline buttons. 8 unit tests (renamed from LangBanner → fixes broken import path). Dismiss sets 30-day localStorage window.
  • build_system_prompt wired into apps/pulses/views.py at both call sites: submit_pulse and PulseViewSet.process. Each passes build_system_prompt(request.membership, "extraction") as system_prompt= kwarg to extract_pulse_entry. extract_pulse_entry accepts the kwarg (system_prompt=None) and stores it on the pulse object for real-provider use.
  • 30 backend tests pass in backend/tests/test_ai_language.py; backend suite is green at 99%+ coverage.

9. Error States

  • Unknown language code: 400 with settings.error_invalid_language.
  • LLM ignores directive twice: deliver the response anyway (an answer in the wrong language beats no answer), log wrong_language_delivered to Sentry with locale pair — this metric decides if a provider is unfit.
  • Detection flapping (multilingual users): threshold is 3 consecutive messages and resets on any profile-language message; confirm-only promotion makes flapping harmless.
  • Locale without LLM support quality: none excluded a priori; the wrong-language metric per locale tells us where to add few-shot examples.

10. Diagrams & Docs Touched

  • backend/DIAGRAMS.md: AI pipeline diagram gains the prompt-contract stage.
  • F08 §real-mode and F14: both must reference build_system_prompt when real-mode lands — update in implementing PR.
  • GLOSSARY: “AI Language Contract”, language_detected.
  • F18 spec: note that profile language and UI locale converge at registration.

11. Implementation Audit (2026-06-12)

#ClaimStatusEvidence
1User.language max_length=10 + language_detected + language_detect_confidence fields✅ matchaccounts/models.py; migration for max_length=10; 30 backend tests pass
2SUPPORTED_LOCALES shared list (60+ codes); PATCH /api/auth/me/language/ validates against it✅ matchapps/accounts/views.py::update_language; F37-? tests
3POST /api/auth/me/language/confirm-detected/ wired and tested✅ matchapps/accounts/views.py::confirm_detected_language; F37-11, F37-12
4apps.ai.prompts.build_system_prompt(member, surface) helper; rejects unknown locales✅ matchapps/ai/prompts.py; covers all 5 AI surfaces
5apps.ai.language_detection.py streak heuristic (3 consecutive foreign-language messages)✅ matchlanguage_detection.py; called from chat/views.py::send_message after user message saved
6build_system_prompt wired into F14 chat caller (send_message → system_prompt= kwarg)✅ matchchat/views.py::send_message; F37-15
7build_system_prompt wired into F08 extraction callers (submit_pulse + PulseViewSet.process)✅ matchpulses/views.py; F37-16
8Frontend “Switch to ?” banner in Chat + Today; 8 unit tests; 2 E2E tests✅ matchLanguageSuggestionBanner.tsx; LanguageSuggestionBanner.test.tsx (8 it() blocks); language-banner.spec.ts
9Wrong-language post-check triggers exactly one retry, then logs⚠️ narrowedcheck_and_retry is wired at 3 live callsites in apps/ai/services.py (~line 536 extraction, ~1735 synthesis, ~1982 chat); unit tests test_post_check_wrong_language_one_retry and test_post_check_wrong_language_twice_delivers_anyway cover the retry path via mocked provider. What remains untested is the live-provider integration scenario (real LLM returning the wrong language)
10Registration sets profile language from active UI locale (not a separate question)✅ matchregister_view captures Accept-Language via _locale_from_accept_language; backend/tests/test_ai_language.py proves header capture, no-header fallback, and unsupported-locale fallback

Summary: 9/10 match; 1 narrowed gap (wrong-language post-check unit path is covered via mocked provider; live-provider integration scenario remains untested end-to-end).

Edge Cases Catalog (formerly §9, 4 items): E1 mixed-language input must prefer stored preference over one-message detection swing (3-consecutive-message threshold with reset handles this); E2 org default must not overwrite member’s manual override (no org-level default exists — per-person only, per spec §3); E3 unsupported locale codes from mobile/connectors must normalize or reject (✅ SUPPORTED_LOCALES validation returns 400 on unknown codes); E4 historical AI outputs must not be retroactively translated (✅ contract governs generation language; read path untouched).

12. Open Questions / Decisions

  • Detection mechanism: charset/stopword heuristic first (zero deps, deterministic); a model-based detector only if the heuristic’s confirmed-switch rate is poor. Decision: heuristic for v1.
  • Should extraction titles for shared objects use author language or viewer language? Decided: author’s language, never auto-translated (provenance honesty — translation is interpretation).
  • Few-shot per-locale examples in prompts: add only for locales where the wrong-language metric exceeds threshold; not speculatively.

13. Bug / Change Log

2026-07-02 — Correct unit-test count in §11 row 8, backend test count in §8, and narrow §11 row 9 gap

Type: Documentation truth fix

Summary:

  • §11 row 8: corrected “6 unit tests” to “8 unit tests”. LanguageSuggestionBanner.test.tsx contains 8 it() blocks; the count drifted from the initial spec (6) and was not updated when 2 tests were added in the 2026-06-12 fix pass. §8 already said 8; §11 was the stale copy.
  • §8: corrected “28 backend tests” to “30 backend tests”. backend/tests/test_ai_language.py now has 30 tests (grep -c "def test_" = 30); the count drifted from 28.
  • §11 row 9 (⚠️ gap “behavior only activates with real LLM”): narrowed — check_and_retry is now wired at 3 live callsites in apps/ai/services.py (~line 536 extraction, ~1735 synthesis, ~1982 chat), and test_post_check_wrong_language_one_retry / test_post_check_wrong_language_twice_delivers_anyway cover the retry logic via mocked provider. The remaining untested path is only the live-provider integration scenario (a real LLM actually returning the wrong language end-to-end).
  • Summary updated: gap is now described as narrowed rather than wholly absent.

Tests: No new code — counts and descriptions now match the filesystem.

2026-07-02 — Spec drift fix: check 2 confirmed-shipped ACs in §2

Type: Documentation truth fix

Summary:

  • AC “registration sets profile language from UI locale” checked ([x]): confirmed by §11 row 10 ✅ (register_view captures Accept-Language, tested in test_ai_language.py).
  • AC “Lumi offers one-tap Switch to ? affordance (never switches silently)” checked ([x]): confirmed by §11 row 8 ✅ (LanguageSuggestionBanner.tsx in Chat + Today, 2 E2E tests, explicit confirm/dismiss flow).
  • Remaining [ ] ACs (Thai/vi AI response language, wrong-language retry, per-member multilingual org, one-language-field-drives-all-surfaces) stay unchecked: §11 row 9 is ⚠️ gap (post-check retry requires real LLM), and the others lack a direct ✅ audit row.

2026-07-01 — Correct F37 registration-language audit row

Type: Documentation truth fix

Summary:

  • Closed the stale F37 audit row claiming registration-time language convergence was unconfirmed.
  • register_view already captures Accept-Language into User.language, and backend/tests/test_ai_language.py already proves the header, fallback-to-vi, and unsupported-locale paths.

Tests:

  • No new code path. Evidence already exists in backend/tests/test_ai_language.py (test_register_captures_language_from_header, test_register_defaults_to_vi_without_header, test_register_falls_back_to_vi_for_unsupported_locale).

2026-06-16 — Real-provider activation: engineered prompts, DeepSeek, live post-check

Type: Spec Update (design for implementation)

Summary:

  • §5 prompt contract rewritten into three engineered layers (identity/role · operating rules · response-language directive) with canonical English OPERATING RULES per surface in new §5a — chief-of-staff identity, hard grounding/anti-hallucination rules, honesty, output discipline, and tone for chat and brief.
  • §4 gains the provider layer + model-routing table and adds DeepSeek as a first-class provider (deepseek-chat, OpenAI-compatible) alongside Gemini and OpenRouter; complete() gains temperature/response_json.
  • Post-check/retry (language_check.check_and_retry) promoted from “plumbing only” to the binding path for every real provider call.
  • §3 moves the provider layer + prompt builders in-scope for this revision (implemented under F12 brief synthesis and F14 RAG chat).

Reason:

  • The contract existed but no real caller used it. F12 (brief) and F14 (chat) are moving to real Gemini/DeepSeek synthesis; this revision specifies the exact engineered prompts and provider/routing so both implement against one source of truth and always answer in the user’s language.

Impact:

  • Audit rows 9 (post-check retry) and the wrong-language path become testable with the real provider mocked. No data-model change beyond settings keys (DEEPSEEK_*). No status flip until F12/F14 implementation lands.

Tests: providers (DeepSeek + JSON mode), build_system_prompt operating-rules content, check_and_retry mismatch→retry — all with the provider mocked (deterministic).

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

Type: Documentation Change

Summary:

  • §1 reframed from breadth-first (“Lumi’s UI ships in 55+ languages”, “A Thai member who asks Lumi a question in Thai”) to Vietnamese-first: Vietnamese is the first-class AI language for the first market (interior fit-out / renovation / small M&E contractor in Vietnam). The multi-language contract mechanism (build_system_prompt, detection streak, post-check/retry) is explicitly retained as correct engineering, but breadth (F45/F69) is parked behind Vietnam vertical proof — validation before velocity.
  • The detection / answer-in-user-language behavior is unchanged. §2 acceptance criteria, §4 data model, §5 prompt contract, and §7 tests (vi/en/th sampling) are untouched — the mechanism stays multi-locale by design.

Reason:

  • Aligns F37 with competitive-strategy-v2.md and the MVP_SCOPE.md “Positioning doctrine (strategy v2, 2026-06-14)”: Vietnamese-first vertical vocabulary; country/language breadth is downstream of Vietnam vertical proof.

Impact:

  • Positioning only — no behavior, API, data-model, test, or status change. User.language already defaults to vi (§4, unchanged); the language-agnostic contract continues to serve any locale. No new behavior is claimed as shipped, so no §11 Implementation Audit gap is added.

Tests: None — doc-only positioning edit.

Notes:

  • No behavior change is implied or required by this realignment. If a future “Vietnamese-first defaulting” hardening (e.g., forcing vi as the answer language until a vertical-proof gate clears) were ever desired, it would be recorded as a §11 gap first — it is not claimed here.

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

Type: Documentation Change

Summary:

  • Absorbed §9 Edge Cases (4 items) into §11 note; §10 Error States → §9; §11 Diagrams → §10; §12 Implementation Audit (2-column table) → §11 (10-row 4-column table); §13 Open Questions → §12; §14 Bug/Change Log → §13.
  • §11 audit: 10 rows, 8 ✅, 2 gaps (post-check retry requires real LLM, registration-time convergence unconfirmed).

2026-06-12 — Wire LanguageSuggestionBanner into TodayPage + fix test filename (B-r15)

Type: Bugfix + Feature

Summary:

  • Renamed LangBanner.test.tsx → LanguageSuggestionBanner.test.tsx and fixed the import path + describe name (tests referenced non-existent LangBanner component).
  • Fixed all 8 tests to mock the real component API (confirmDetectedLanguage/ dismissDetectedLanguage instead of updateLanguage/dismissLanguageSuggestion; language_banner_dismissed_until localStorage key instead of lang_banner_dismissed_until).
  • Wired LanguageSuggestionBanner into TodayPage (already wired in ChatPage).
  • Deleted stale LangBanner.test.tsx.

Reason:

  • The broken test file blocked the frontend test suite; the banner was only wired in ChatPage, missing the Today dashboard where detection may be needed.

Impact:

  • F37 frontend status remains ✅. Banner now shows in both Chat and Today views. All 8 tests now reference the real component path and real API.

Tests: 14/14 test files pass, 863/863 tests, 0 tsc errors. 30/30 F37 backend tests pass.

2026-06-12 — Wire build_system_prompt into F08 extraction callers (submit_pulse + process)

Type: Feature

Summary:

  • apps/pulses/views.py::submit_pulse now calls build_system_prompt(request.membership, "extraction") and passes the result as system_prompt= kwarg to extract_pulse_entry.
  • apps/pulses/views.py::PulseViewSet.process (the /process/ endpoint) similarly calls build_system_prompt(request.membership, "extraction") and passes as system_prompt= kwarg.
  • extract_pulse_entry signature updated: def extract_pulse_entry(pulse_entry_id, evidence_id=None, *, system_prompt=None) — keyword-only arg, existing callers unaffected.
  • Added backend/tests/test_ai_language.py::test_submit_pulse_wires_build_system_prompt_to_extraction (F37-16) — mocks both helpers, submits a pulse, asserts build_system_prompt(vi_member, "extraction") is called and its output is forwarded as system_prompt= kwarg.

Reason:

  • F37 spec §11 listed “build_system_prompt adopted in F08 real-mode extraction — pending real LLM integration” as the last remaining backend gap. This closes it.

Impact:

  • Zero runtime change today (keyword matcher ignores system_prompt). The plumbing is live on both extraction call paths — when a real LLM provider lands in extraction, it will receive the language-aware system prompt automatically. F37 backend is now fully wired.

Tests: 1225 backend tests, 99.11% coverage. 824 frontend tests, 0 tsc errors.

2026-06-12 — Wire build_system_prompt into F14 chat send_message

Type: Feature

Summary:

  • chat/views.py::send_message now calls build_system_prompt(request.membership, "chat") and passes the result as system_prompt= kwarg to answer_chat_message. The keyword matcher still serves responses, but the prompt plumbing is live — when a real LLM provider is connected, it will receive the language-aware system prompt automatically.
  • answer_chat_message signature updated: def answer_chat_message(thread_id, user_message, organization_id, *, system_prompt=None). Existing callers (3 positional args) are unaffected.
  • Added backend/tests/test_ai_language.py::test_send_message_uses_build_system_prompt (F37-15) — mocks both helpers, sends a message via the API, asserts build_system_prompt(vi_member, "chat") is called and its output is forwarded as system_prompt= kwarg.

Reason:

  • build_system_prompt existed but had no live caller — the F37 audit flagged this as the remaining backend wiring gap. This closes it for F14 chat.

Impact:

  • Zero runtime change today (keyword matcher ignores the system_prompt). One extra function call per chat message (negligible). Ready for real-provider adoption without further wiring.

Tests: 1191 backend tests, 99.12% coverage.

2026-06-12 — Wire language detection into chat endpoint

Type: Feature

Summary:

  • chat/views.py::send_message now calls record_message_language(request.user, content) after saving the user message. After 3 consecutive non-profile-language messages, User.language_detected is set.
  • Added backend/tests/test_ai_language.py::test_chat_send_message_updates_language_detection — sends 3 English messages as a Vietnamese user and asserts language_detected = "en" at threshold.

Reason:

  • The detection module existed but was never called from a live endpoint — this closes the wiring gap.

Impact:

  • Detection now runs on every Ask Lumi message. Performance: pure Python, no I/O on the happy path (no detection), one UPDATE when detection state changes. Does not slow the response path.

Tests: 1188 backend tests, 99.12% coverage.

2026-06-12 — Second audit pass: two “missing” items were already shipped

Type: Doc Fix

Summary:

  • §8 Current Behavior and §11 Implementation Audit corrected: PATCH /api/auth/me/language/ already validates against the full SUPPORTED_LOCALES list (not just en/vi), and POST /api/auth/me/language/confirm-detected/ is already wired and tested. The 2026-06-12 first-pass audit overstated the remaining gaps because it read the code state before those commits landed.

Reason:

  • These two items were listed as “still missing” but backend/apps/accounts/views.py shows both are present. Running backend/tests/test_ai_language.py confirms (26/26 pass).

Impact:

  • F37 backend gap is smaller than previously recorded. Remaining work: live-caller wiring (pending real LLM provider), detection-in-chat, and frontend confirmation UI.

Tests: None — doc-only correction.

2026-06-12 — Partial-backend audit correction

Type: Doc Fix

Summary:

  • Reclassified F37 from draft/not-built to in-progress, and replaced stale Current Behavior / Implementation Audit text with the actual partial backend foundation now in the repo.

Reason:

  • The spec still described a max_length=5 model and no detection/prompt helper, which is false on the current filesystem.

Impact:

  • The spec now distinguishes clearly between what is already implemented and what remains missing to satisfy the full contract.

Tests:

  • No code changes. Evidence comes from backend/apps/accounts/models.py, backend/apps/ai/prompts.py, and backend/tests/test_ai_language.py.

Notes:

  • This does not mark the feature shipped; it marks the current state honestly as partial.

2026-06-12 — Audit verdict corrected to shipped (D-r17)

Type: Doc Fix

Summary:

  • docs/implementation-audit/2026-06-12-full-repo-audit.md §4 F37 and §7 Final Verdict said partial because “no frontend confirmation banner or E2E test” existed at the time that audit ran. Both have since shipped (commit 31b735d32) — verified frontend/src/components/chat/LanguageSuggestionBanner.tsx, frontend/src/test/LanguageSuggestionBanner.test.tsx, and the describe("Language Detection Banner") block in frontend/tests/e2e/lumi.spec.ts plus frontend/tests/e2e/language-banner.spec.ts all exist and are wired to /api/auth/me/language/confirm-detected/.

Reason:

  • The audit doc and docs/MVP_SCOPE.md (already correctly says “fully shipped”) disagreed. Audit was the stale one.

Impact:

  • Audit doc §4 F37 verdict updated partial → shipped; §7 Final Verdict moves F37 into the shipped group. F36 left as not built (Fleet-A’s work not yet landed on main at audit-update time).

Tests:

  • No code changes — doc-only correction.

2026-06-12 — Frontend confirmation banner + E2E shipped

Type: Feature

Summary:

  • LanguageSuggestionBanner.tsx (shadcn Alert variant=“info” with Languages icon) shows “Switch to ?” when language_detected differs from current language on the user object. Calls confirmDetectedLanguage (POST /api/auth/me/language/confirm-detected/) on accept → toast + i18n switch. Dismiss sets a 30-day localStorage window, clears from store.
  • 6 unit tests in LanguageSuggestionBanner.test.tsx: no-banner on null/same, show on mismatch, confirm calls API, dismiss hides, dismissal window.
  • 2 E2E tests in lumi.spec.ts describe("Language Detection Banner"): full confirm flow (thread + 3 en messages → detection → banner → click → verify language updated) and dismiss persistence.
  • Fixed vitest setup.ts to polyfill localStorage (Node v26 jsdom regression).
  • Chat locale keys (language_suggestion_text, language_suggestion_confirm, language_suggestion_dismiss, language_suggestion_confirmed, language_suggestion_error) exist in both en/chat.json and vi/chat.json.

Reason:

  • F37 audit verdict was partial — backend detection + auth endpoints shipped but frontend confirmation UI missing. This closes that gap.

Impact:

  • F37 frontend column moves from 🔲 to ✅. E2E column same. Backend column was already ✅. build_system_prompt live-caller wiring remains pending (requires real LLM provider).

Tests: 820 frontend unit tests pass (12 test files), 0 TypeScript errors. 1190 backend tests at 99.12% coverage.

2026-06-12 — Initial spec

Type: Feature

Summary:

  • Fixed the AI language contract: profile language as single source of truth (captured from UI locale at registration, passively detected with explicit confirm), English system prompts with a binding response-language directive via one shared build_system_prompt helper, post-check + single retry, mock provider included.
  • Flagged the existing User.language max_length=5 defect (cannot store zh-Hant).

Reason:

  • 55+ UI languages shipped with no contract for what language AI surfaces answer in; every AI feature was about to improvise.

Impact:

  • New registry entry F37 (🔲). Binds F08/F14 implementations and F21/F35/F36 copy generation to one language field.

Tests:

  • Specified in §7; none written yet.

Notes:

  • The provider switch (Gemini/OpenRouter) is a separate change under F08/F14 — this contract is provider-agnostic by design.