Files
duocthu/coordination/CLAUDE_CLAIM_2026-08-11.md
T

6.1 KiB

Claude ownership claim — 2026-08-11

Read CLAUDE_HANDOFF_2026-08-10.md first. Two items in it have since moved on (checked against the code and against live production on 2026-08-11):

  1. The structured-claims refactor it describes as in progress is finished and shipped (dfdbf52, then 9c3acd0). pytest -q --ignore=tests/test_api.py --ignore=tests/test_live_datastores.py = 219 passed at the start of this session.
  2. Entailment majority vote (2-of-3) is no longer in the code. df55af4 introduced it; 9c3acd0 replaced it with a single pass (_ENTAILMENT_MAX_ATTEMPTS = 1), with the reasoning in _verify_entailment's docstring: repeating an identical temperature-0 prompt is a correlated retry rather than an independent vote.

What this session is changing, and why

All five items come from driving production (https://realvuxbaro.me) by hand plus direct /api/chat probes — measured, not inferred from docs.

  • rag/answer.py — availability failures during the entailment pass are currently recorded and shown as unsupported_claim, so a timing or outage problem reaches the user as a content failure. The failure taxonomy in docs/current-rag-pipeline-audit.md §4 keeps these separate. Reusing the reason codes that already exist and are already mapped in apps/web/app/api/chat/route.ts (request_budget_exhausted, provider_unavailable, malformed_output) — no new reason code, so the frontend mapping needs no change. Fail-closed behaviour is unchanged; only the label changes.
  • rag/answer.py_verify_entailment's for _ in range(_ENTAILMENT_MAX_ATTEMPTS) loop always returns on its first iteration, so raising that constant silently does nothing, and the unreachable return False after it returns a bool where every caller reads .supported. Making the single-pass intent explicit.
  • rag/agent.py — the pediatric dosing gate asks "Bé bao nhiêu tuổi và cân nặng bao nhiêu kg?" even when the user just gave one of the two. Reproduced 5/5 live ("18 ký", "18 cân", explicit "18 kg", "Trẻ 5 tuổi"). The gate itself is NOT being loosened — both fields stay required, which is clinically right here (the correct answer uses both an age band and a mg/kg rule). Only the question text becomes specific to what is actually missing.
  • apps/web/app/_components/ChatPanel.tsx — a hard 25s client abort against a backend whose own budget is 40s (config.py:60). Measured n=8 sequential: 2/8 exceeded 25s, and one of those was a correct answerable/grounded/2-citation reply at 25.1s that the user never saw. Caddy (reverse_proxy web:3000, no timeout) and the BFF (signal: request.signal, no own timeout) do not cap this, so the client constant is the only binding limit.
  • packages/ui/src/ChatBubble.tsx — chips dedupe by chunkId but the label is only drug+section+page, so several distinct chunks render as identical-looking chips. Not collapsing them by label: the click handler maps to a specific citation index, so collapsing would make real evidence blocks unreachable from the prose, and provenance is a hard guardrail.

Second batch — guardrail gaps (same day)

A guardrail review against docs/architecture.md found two things the design specifies that were not in the code. Both were implemented around the files currently carrying uncommitted changes, so nothing in that set was touched.

  • Rate limiting — new apps/web/middleware.ts. /api/chat is public, takes no credentials and spends Bedrock credit per call against a small personal AWS budget; the architecture assigns this to api-gateway, which is not built. 12/min and 120/hour for /api/chat, 120/min for /api/suggest (a local catalog lookup, no model call), keyed on the left-most X-Forwarded-For entry that Caddy sets, returning 429 with Retry-After. Counters are per process and in memory: correct for the single web container in production today, and the point at which that scales past one replica is the point this has to move to Redis or to the gateway. It is a cost/abuse guard, not authentication.
  • Disclaimer — rag/answer.py only, wire-up still pending. GroundedAnswer now carries disclaimer: str = DISCLAIMER as a dataclass default, so no response path can be constructed without it, including abstains and clarifications. Deliberately a module constant and never sent through the generator: a model-written disclaimer can be reworded or dropped, and would then need verifying like any other generated claim.

Wire-up left for whoever next owns routers/rag.py

routers/rag.py has uncommitted changes in this worktree, so the last step is left undone rather than edited around someone else's work. Two small changes complete it:

  1. Add disclaimer: str to RagQueryResponse and pass grounded.disclaimer through when the response is built.
  2. packages/shared-types/src/dto/chat.ts already declares disclaimer?: string, so the BFF only needs to copy it onto the message it returns — no type change required.

Until step 1 lands, the guarantee exists in the domain object but is not yet visible to an API consumer.

Files claimed

apps/ai-service/rag/answer.py, apps/ai-service/rag/agent.py, apps/ai-service/tests/test_grounded_generation.py, apps/ai-service/tests/test_agent.py, apps/web/app/_components/ChatPanel.tsx, packages/ui/src/ChatBubble.tsx, and this file.

Not touching ingestion/, retrieval adapters, rag/service.py, rag/understanding.py, or anything sparse/BM25 related — task #4 (real BM25 via Qdrant native sparse vectors) is still not started.

Test-coverage context for review

These areas start with little automated cover: no tests currently touch missing_pediatric_age_or_weight / missing_population, and the repo has no frontend test setup (no test script in apps/web/package.json, no ChatPanel/ChatBubble tests). A passing pytest run therefore is not on its own sufficient evidence here. Backend tests are being added alongside the changes, and the two frontend changes are verified by driving the real site.