Fix ai-service Dockerfile: bake in drug_entities.json, override its path
This commit is contained in:
@@ -1,9 +1,39 @@
|
||||
# ADR 0007: Conversational reasoning RAG — state, bounded loop, and how it is measured
|
||||
|
||||
**Status:** accepted, implementation in progress (2026-08-05)
|
||||
**Status:** superseded by ADR 0008 (2026-08-07). See the note below before
|
||||
reading this as a description of anything currently running.
|
||||
**Supersedes:** nothing. Extends ADR 0005 (segment output contract) and ADR 0006
|
||||
(quarantined block references) rather than replacing them.
|
||||
|
||||
> **2026-08-07 — why this was superseded, not deleted.** An independent
|
||||
> 7-agent audit on 2026-08-06 found `bootstrap.py` never constructs any of
|
||||
> `rag/conversation.py` / `rag/reasoning.py` / `rag/conversational.py` — the
|
||||
> live agent (`rag/agent.py::RagAgent`, wired in since the F-03 rebuild on
|
||||
> 2026-08-06) is a fixed one-shot pipeline (understand → route → retrieve
|
||||
> once → generate → ≤2 same-claim entailment retries), not the PLAN/RETRIEVE/
|
||||
> ASSESS/REFINE/VERIFY loop or the `Focus`/`ConversationState`/TTL state
|
||||
> design below. This was a real, deliberate pivot mid-implementation, not an
|
||||
> abandoned-but-still-intended plan: `rag/agent.py`'s own module docstring
|
||||
> says outright that `ConversationalLoopService` + `conversation.py` were
|
||||
> replaced because "the LLM reads a plain turn history and resolves
|
||||
> ['thuốc đó' / 'còn liều thì sao'] itself" — simpler than maintaining
|
||||
> `Focus`/TTL/turn-budget state by hand, and proven live across many
|
||||
> multi-turn conversations since. Section 6 below ("Refused: an LLM
|
||||
> confidence score as the loop's uncertainty signal") is the clearest
|
||||
> evidence this is a genuine architecture change, not a gap: the live system
|
||||
> now uses exactly that — an LLM sufficiency/clarify judgment — as its
|
||||
> ask-or-answer signal, the opposite of what this ADR chose.
|
||||
>
|
||||
> The three modules this ADR specified (1,314 lines) and their five dedicated
|
||||
> test files (42 tests) were deleted on 2026-08-07 rather than left as dead
|
||||
> code, once confirmed to have zero live importers anywhere
|
||||
> (`bootstrap.py`/`main.py`/`agent.py`/`answer.py`/`routers/rag.py`). This
|
||||
> document is kept, unedited below this notice, as the historical record of
|
||||
> why that design was chosen and what it traded off — see ADR 0008 for what
|
||||
> actually runs today, including what this ADR got right that ADR 0008
|
||||
> still owes (a real request-scoped time/call budget — F-08, still open; a
|
||||
> durable, cross-worker conversation store — currently an in-process dict).
|
||||
|
||||
## Context
|
||||
|
||||
The service answers one question at a time. `POST /v1/rag/query` carries no
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# ADR 0008: LLM query understanding + one-shot grounded RAG (what is actually live)
|
||||
|
||||
**Status:** accepted, live since 2026-08-06 (F-03), extended 2026-08-07
|
||||
**Supersedes:** ADR 0007 (conversational reasoning RAG — the `Focus`/
|
||||
`ConversationState`/TTL state design and the PLAN/RETRIEVE/ASSESS/REFINE/
|
||||
VERIFY bounded loop). ADR 0007's own `rag/conversation.py`/`rag/reasoning.py`/
|
||||
`rag/conversational.py` were deleted 2026-08-07 once confirmed unreachable
|
||||
from `bootstrap.py` — see the notice at the top of ADR 0007 for the full
|
||||
reasoning.
|
||||
**Extends:** ADR 0006 (quarantined block references) — unchanged and still
|
||||
binding: a chunk with `has_quarantined_content` still forces `VERIFY_PDF`
|
||||
and is never generated over.
|
||||
|
||||
## Context
|
||||
|
||||
This ADR exists because `docs/architecture.md` and ADR 0007 described a
|
||||
design that was never fully built, and the modules that partially
|
||||
implemented it were never wired into `bootstrap.py`. A 2026-08-06
|
||||
independent 7-agent audit found this the hard way — it cost real time
|
||||
establishing that `QdrantRetriever.search()` (dense vector search) and the
|
||||
entire reasoning-loop module set were dead code, contradicting what the
|
||||
docs claimed was live. The fix is not "finish building ADR 0007" — the
|
||||
project deliberately moved to a simpler design that already works, proven
|
||||
across many real multi-turn conversations (see `docs/progress-log.md`,
|
||||
2026-08-05 through 2026-08-07 entries). This ADR documents that design so
|
||||
the next reader doesn't have to re-discover it by audit.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. One LLM call understands the turn; no separate state object
|
||||
|
||||
`rag/understanding.py::LlmQueryUnderstander.understand(turn, history)` reads
|
||||
the raw current turn plus a **plain list of past turn strings**
|
||||
(`"Người dùng: …"` / `"Trợ lý: …"`, kept by `RagAgent._history`, a
|
||||
per-conversation-id in-process dict) and returns a `QueryFrame`: turn type,
|
||||
resolved `drug_id`s (validated against a candidate set a deterministic
|
||||
fuzzy/alias pass bounds *before* the model runs — F-04), section attribute,
|
||||
population, weight, age, indication, route, and a `needs_clarify`/
|
||||
`clarify_reason`/`quick_replies` triple.
|
||||
|
||||
There is no `Focus` struct, no TTL, no separate summariser. The model
|
||||
re-reads the same history window (last `HISTORY_TURNS * 2` = 12 lines) every
|
||||
turn and re-derives what's still relevant — cheaper to build and, so far,
|
||||
more robust than hand-maintained state: it naturally handles "còn trẻ em thì
|
||||
sao?" and short replies to its own clarify questions (population/route/etc.
|
||||
— the latter only after a 2026-08-07 fix; see progress-log) without a
|
||||
resolver state machine to keep in sync.
|
||||
|
||||
**Known gap, inherited from ADR 0007 and still open:** this history is an
|
||||
in-process dict — lost on restart, not shared across workers if the service
|
||||
ever scales beyond one. ADR 0007's `PostgresConversationStore` was never
|
||||
built either.
|
||||
|
||||
### 2. Routing is a single dispatch, not a loop
|
||||
|
||||
`RagAgent._route()` reads `frame.turn_type` and dispatches once:
|
||||
`interaction` (2+ drugs) → gather each drug's evidence, combine, decide;
|
||||
`drug_attribute`/`drug_overview`/`dosing_calc`/fallback → one drug, one
|
||||
retrieval call; `smalltalk`/`out_of_scope` → canned reply, no retrieval;
|
||||
`symptom_to_drug` with no drug named → an honest "not built yet" clarify.
|
||||
There is no PLAN/REFINE step and no retrieval-round budget, because there is
|
||||
only ever one retrieval call per turn.
|
||||
|
||||
### 3. Retrieval is deterministic routing, not similarity ranking
|
||||
|
||||
`RetrievalService.retrieve_framed(drug_id, section_key, query)`:
|
||||
- `section_key` given (the dominant case, since `understand()` almost always
|
||||
resolves it) → `find_by_section`, an **exact Qdrant payload filter**
|
||||
(`drug_id` + `section_key`), returning the whole section as a scroll.
|
||||
Score is a hardcoded 1.0 — this is a filter, not a ranked search, and nothing
|
||||
here is "confidence" in the sense ADR 0007's retrieval-confidence gate meant.
|
||||
- No section resolved → `find_by_drug` (whole monograph, book order),
|
||||
trimmed to identity sections for a bare name or reranked (Cohere
|
||||
cross-encoder over the ~29 sections of that one drug, not a corpus search)
|
||||
for a free-form question.
|
||||
- `QdrantRetriever.search()` — real dense vector similarity over the whole
|
||||
corpus — exists and is unit-tested, but `RagAgent` never calls it. It is
|
||||
reachable only through the legacy `RetrievalService.retrieve()` entry
|
||||
point, itself only reachable when `ANSWER_PROVIDER=disabled` (no agent
|
||||
configured at all — retrieval-only mode). `docs/architecture.md`'s
|
||||
"Retrieval-confidence gate: below a similarity threshold, skip the LLM
|
||||
call entirely" describes this legacy-only path, not the live one; that
|
||||
section has been corrected to say so.
|
||||
- Measured, and the reason this design was chosen over similarity ranking
|
||||
for the live path: routing by exact `section_key` moved contraindication
|
||||
hit@1 from 0.05 to 1.00 (`[[project-retrieval-quality-gap]]`, 2026-08-04).
|
||||
A quarantined chunk anywhere in the retrieved set still forces the whole
|
||||
result to `VERIFY_PDF` (`RetrievalService.decide`, a public wrapper added
|
||||
2026-08-07 so `RagAgent._interaction` applies the same policy to a
|
||||
combined multi-drug evidence pool instead of hand-rolling it).
|
||||
|
||||
### 4. Generation is one call, verified twice, with no confidence score
|
||||
|
||||
`GroundedAnswerService.answer_from_result`: sufficiency-check (ask instead of
|
||||
guessing when the evidence spans multiple populations/routes and the turn
|
||||
hasn't disambiguated) → generate → `grounding.verify` (every number and
|
||||
citation traces to the block it cites) → `_verify_entailment` (a second LLM
|
||||
pass confirming each cited claim's *content*, not just its numbers, is
|
||||
actually stated by that block; one same-claim retry on a lone reject, since
|
||||
this call is measurably noisy — 2026-08-06 finding). A generation that fails
|
||||
any check **abstains** — it does not fall back to a raw extractive quote
|
||||
when a generator is configured (`[[feedback_no_extractive_fallback_when_llm_configured]]`).
|
||||
|
||||
No `MAX_LLM_CALLS`/`MAX_WALL_CLOCK_MS` budget object exists. Each call is
|
||||
bounded only by its own provider timeout. **This is ADR 0007's F-08 finding,
|
||||
inherited unchanged and still open** — a real end-to-end request deadline
|
||||
threaded through `RagAgent`'s sequence of up to 5 sequential Bedrock calls
|
||||
(understand → sufficiency → generate → ≤2 entailment) is real remaining
|
||||
work, not solved by this ADR. Measured live 2026-08-07: a single answerable
|
||||
turn costs ~8-9s wall clock, ~75-80% of it the 4 sequential LLM calls
|
||||
(understand ~2.6-3.3s dominates — an 80B model doing a classification task
|
||||
that likely doesn't need one); a clarify chain compounds this linearly since
|
||||
each round is a fresh request repeating the same call sequence from scratch.
|
||||
|
||||
### 5. Context resolved across turns is folded into one self-contained string
|
||||
|
||||
Added 2026-08-07, closing a P0 the 2026-08-06 audit named: `frame.population`/
|
||||
`weight_kg`/`age_text`/`route`/`indication` were extracted by `understand()`
|
||||
but never reached `retrieve_framed`/`answer_from_result`, which took only
|
||||
the bare current-turn text — so a reply like "Uống" three turns into a dose
|
||||
conversation reached the sufficiency/generation LLM calls as literally just
|
||||
"Uống", with no notion that population=adult was already established two
|
||||
turns back. `RagAgent._synthesize_query` now folds every resolved field into
|
||||
one string (`"Uống. Đối tượng: người lớn. Đường dùng: uống."`) before it
|
||||
reaches retrieval's rerank signal and generation's `query` argument. No-op
|
||||
for a fresh single-shot question that already states its own context.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Accepted.** No confidence score, no retrieval-round budget, no PLAN/REFINE
|
||||
step — the tradeoff ADR 0007 explicitly refused ("an LLM confidence score...
|
||||
is not a defensible basis for asking or not asking a clinician a question")
|
||||
is exactly what this design uses instead (an LLM sufficiency/clarify
|
||||
judgment), because in practice it has been reliable enough and dramatically
|
||||
simpler to build, extend (route/quick_replies were one schema field + one
|
||||
prompt rule each, not a new state machine), and debug — every session this
|
||||
month that touched the ADR 0007 modules found new bugs in the state-machine
|
||||
edges (TTL boundaries, Focus inheritance correctness) rather than in the
|
||||
domain logic itself.
|
||||
|
||||
**Refused (again, restated from ADR 0007, still true):** an LLM confidence
|
||||
score as a hard gate for retrieval — `RetrievalService.decide`'s
|
||||
`VERIFY_PDF`/`ABSTAIN` decisions remain deterministic (quarantine flag,
|
||||
missing provenance), never a model's self-reported certainty.
|
||||
|
||||
**Still open, named rather than hidden:**
|
||||
- No request-scoped time/call budget (F-08).
|
||||
- Conversation history is in-process, not durable/shared (inherited from
|
||||
ADR 0007, never built either way).
|
||||
- No production-path adversarial regression suite beyond one live-verified
|
||||
end-to-end case (F-10's remaining scope).
|
||||
- `dosing_calc` (a real mg/kg calculator) and `symptom_to_drug` (reverse
|
||||
indication lookup) remain honest "not ready" clarifies, not answers.
|
||||
+21
-12
@@ -17,12 +17,12 @@ disclaimer.
|
||||
| **auth-service** (NestJS) | Signup/login, password hashing, JWT issuance/refresh | Postgres (users); no dependency on other services |
|
||||
| **user-service** (NestJS) | Profile data, preferences, account settings | Postgres (profiles), called by gateway |
|
||||
| **chat-service** (NestJS) | Chat session lifecycle, message history persistence | Postgres (chat_sessions, chat_messages); calls ai-service per user message, persists both turns |
|
||||
| **ai-service** (Python/FastAPI) | RAG orchestration: embed query → vector search in Qdrant → build grounded prompt → call OpenAI → return answer + citations | Qdrant (vector search), OpenAI API; stateless, does not own chat history |
|
||||
| **ingestion** (Python, offline batch) | One-time/periodic job: parse PDF → monographs → chunks → embeddings → upsert to Qdrant | Qdrant (write), OpenAI embeddings API; runs as CLI/CI/k8s Job, never in the live request path |
|
||||
| **ai-service** (Python/FastAPI) | RAG orchestration: understand query (LLM) → route to deterministic section/drug retrieval in Qdrant → generate + verify (LLM) → return answer + citations | Qdrant (payload-filtered retrieval), AWS Bedrock (Cohere embed-v4 for query embedding where used, Qwen3 via the Converse API for understanding/generation/entailment, Cohere rerank); conversation history is an in-process dict per `RagAgent`, not yet durable — see ADR 0008 |
|
||||
| **ingestion** (Python, offline batch) | One-time/periodic job: parse PDF → monographs → chunks → embeddings → upsert to Qdrant | Qdrant (write), AWS Bedrock (`cohere.embed-v4:0`); runs as CLI/CI/k8s Job, never in the live request path |
|
||||
| **web** (Next.js) | Chat UI, auth UI, citation/disclaimer rendering, session list | Calls api-gateway only |
|
||||
|
||||
**Sync vs async**: the live chat path (web → gateway → chat-service →
|
||||
ai-service → Qdrant + OpenAI → back) is synchronous request/response.
|
||||
ai-service → Qdrant + AWS Bedrock → back) is synchronous request/response.
|
||||
Ingestion is fully decoupled, offline, batch — it populates Qdrant ahead of
|
||||
time and is never triggered by a chat request, since parsing the 37MB PDF and
|
||||
embedding thousands of chunks takes minutes. Internal protocol is REST/JSON
|
||||
@@ -105,10 +105,12 @@ methodology, cross-tool comparison, and validation numbers.
|
||||
header/footer-boilerplate leak into section text (98.4% of monographs
|
||||
affected) must be fixed upstream before this design runs against real
|
||||
data.
|
||||
4. **Embedding + load**: OpenAI `text-embedding-3-small` in batches, upserted
|
||||
into a versioned Qdrant collection (`drug_monographs_v1`) keyed by
|
||||
`chunk_id` for idempotent re-runs; collection aliasing allows re-ingesting
|
||||
with a changed chunking strategy without downtime.
|
||||
4. **Embedding + load**: AWS Bedrock `cohere.embed-v4:0` in batches
|
||||
(cached by `(model_id, input_kind, text_sha256)` so a reload needs no
|
||||
repeat cloud calls), upserted into Qdrant collection `duocthu_v1`
|
||||
(15,100 points, live) keyed by `uuid5(chunk_id)` for idempotent re-runs; a
|
||||
`<collection>__manifest` sidecar records the corpus sha/model/dimensions
|
||||
and `ai-service` refuses to start against a mismatched one (F-05).
|
||||
5. **Batch job, not synchronous**: runs as a CLI command locally, and as a
|
||||
Kubernetes `Job`/`CronJob` in production — never inside the ai-service
|
||||
request path.
|
||||
@@ -119,8 +121,15 @@ methodology, cross-tool comparison, and validation numbers.
|
||||
context, never state a dosage/contraindication/interaction not present in
|
||||
it, always append a disclaimer, and say "not found in the formulary"
|
||||
rather than guess when retrieval is irrelevant.
|
||||
- **Retrieval-confidence gate**: below a similarity threshold, skip the LLM
|
||||
call entirely and return a canned "consult a professional" response.
|
||||
- **Deterministic routing, not a similarity-confidence gate.** The live
|
||||
path resolves drug + section by exact payload filter (`section_key`
|
||||
routing moved contraindication hit@1 from 0.05 to 1.00 — similarity
|
||||
ranking alone was not reliable enough to gate on). A quarantined table/
|
||||
formula in the retrieved evidence, or missing page provenance, forces
|
||||
`VERIFY_PDF`/abstain deterministically — never an LLM-reported confidence
|
||||
score. Dense vector similarity search exists (`QdrantRetriever.search()`)
|
||||
but is reachable only in the legacy no-generator-configured mode, not the
|
||||
live agent path. See ADR 0008.
|
||||
- **Citations from metadata, not LLM prose**: the `citations` list is built
|
||||
directly from retrieved-chunk metadata, independent of what the LLM says,
|
||||
so the frontend can always show verifiable sources.
|
||||
@@ -136,9 +145,9 @@ methodology, cross-tool comparison, and validation numbers.
|
||||
1. **Ingestion pipeline + populated, queryable vector DB.** Done when a CLI
|
||||
run populates Qdrant and a test script retrieves the correct
|
||||
drug/section chunk for a sample query — no API, no LLM call yet.
|
||||
2. **ai-service (FastAPI) wrapping RAG + OpenAI.** Done when a `curl` to
|
||||
`/query` returns a grounded answer with a traceable citation and an
|
||||
always-present disclaimer.
|
||||
2. **ai-service (FastAPI) wrapping RAG + AWS Bedrock.** Done when a `curl` to
|
||||
`/v1/rag/query` returns a grounded answer with a traceable citation and an
|
||||
always-present disclaimer. **Done** — live since 2026-08-05, see ADR 0008.
|
||||
3. **auth/user/chat services + api-gateway.** Done when register → login →
|
||||
chat message flows end-to-end through the gateway only, persisted in
|
||||
Postgres.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user