206 lines
11 KiB
Markdown
206 lines
11 KiB
Markdown
# Disease / Condition → Medication: audit and minimal design
|
||
|
||
Date: 2026-08-11
|
||
Scope: the current worktree and the local `duocthu_v1` Qdrant collection. Code and
|
||
runtime observations take precedence over older ADR/progress-log statements.
|
||
|
||
## Phase 1 — Current production capability
|
||
|
||
### Verified request path
|
||
|
||
```text
|
||
apps/web/app/api/chat/route.ts
|
||
-> POST /v1/rag/query
|
||
-> routers/rag.py::query_rag
|
||
-> rag/agent.py::RagAgent.handle
|
||
-> rag/understanding.py::LlmQueryUnderstander.understand
|
||
-> rag/agent.py::RagAgent._route
|
||
-> rag/service.py::RetrievalService
|
||
-> adapters/qdrant.py::QdrantRetriever
|
||
-> rag/answer.py::GroundedAnswerService.answer_from_result
|
||
-> rag/grounding.py::verify + LLM entailment check
|
||
-> citations built from retrieved metadata
|
||
```
|
||
|
||
The web BFF currently always sends `subject_scope="human"` and
|
||
`intent="fact_lookup"`. The live `RagAgent` deliberately does not trust/use that
|
||
client intent for routing; its `QueryFrame.turn_type` drives the dispatch. The
|
||
legacy `QueryRoutingService` remains the no-agent/retrieval-only fallback.
|
||
|
||
### Query understanding and routing
|
||
|
||
`rag/understanding.py` already has one LLM-driven structured pass. Its current
|
||
closed turn taxonomy is:
|
||
|
||
```text
|
||
drug_attribute, drug_overview, interaction, symptom_to_drug,
|
||
dosing_calc, smalltalk, out_of_scope
|
||
```
|
||
|
||
It validates drug ids against a deterministic catalog-bounded candidate set and
|
||
extracts section, population, weight, age, indication, route, clarification state,
|
||
and a standalone-query rewrite. It does not yet represent a normalized disease,
|
||
the requested disease↔drug relation, comorbidities, allergies, current medicines,
|
||
renal/hepatic state, pregnancy/breastfeeding, labs, or a clinical case boundary.
|
||
|
||
`rag/agent.py::RagAgent._route` already dispatches `symptom_to_drug` to
|
||
`_symptom_to_drug`. However, the repository's most recent measured local run
|
||
recorded 5/5 disease/symptom queries being misrouted into clarification and never
|
||
reaching the reverse lookup. This is a query-understanding/routing failure, not an
|
||
absence of indication data.
|
||
|
||
### Retrieval actually present
|
||
|
||
- Exact metadata retrieval: `find_by_section(drug_id, section_key)` scrolls the
|
||
complete section, sorted by `part_index`.
|
||
- Drug overview: `find_by_drug` scrolls prose for one monograph.
|
||
- Dense: `search` within one drug and `search_indication` across
|
||
`section_key=chi_dinh`.
|
||
- Lexical: `search_lexical` is normalized token-overlap over Qdrant's text index;
|
||
it is BM25-style but not a true sparse-vector/BM25 ranking.
|
||
- Hybrid: `rag/fusion.py::reciprocal_rank_fusion` exists and is unit-tested, but
|
||
it is not wired into the live retrieval service.
|
||
- Reranker: Cohere rerank is configurable and used for overview/similarity. It is
|
||
off by default and the current indication route does not call it.
|
||
- Reverse indication: `find_by_indication` performs contiguous normalized phrase
|
||
matching over prose `chi_dinh` chunks; `search_indication` is the dense fallback.
|
||
Both exclude contraindication, ADR, precaution, and interaction sections.
|
||
|
||
The current reverse lookup deduplicates to one hit per drug inside the adapter,
|
||
but stops at the first matching chunk and caps before any entity-level reranking.
|
||
Qdrant scroll order is not a clinical ranking, so the current top-N is arbitrary
|
||
among exact matches. It also retains only one evidence chunk rather than an
|
||
explicit drug-level aggregate.
|
||
|
||
### Qdrant and chunk schema
|
||
|
||
The local runtime collection was queried directly during this audit:
|
||
|
||
- collection `duocthu_v1`: green, 15,100 points, cosine vectors, 1,024 dimensions;
|
||
- payload indexes: `chunk_id`, `drug_id`, `section_key`, `atc_codes`,
|
||
`chunk_kind`, `has_quarantined_content`, plus multilingual `text` index;
|
||
- a live `chi_dinh` point contains `drug_id`, `drug_name`, `section_key`,
|
||
`section_display_name`, `text`, `source_text`, physical and printed page ranges,
|
||
part index/count, ATC code, attachments, and quarantine flag.
|
||
|
||
`ingestion/ingestion/chunk/models.py::Chunk` and
|
||
`ingestion/ingestion/load/models.py` confirm those fields. `parent_id` and explicit
|
||
`source_refs` are supported by the AI-service retrieval model/adapter, but the
|
||
current ingestion `Chunk` contract does not emit `parent_id`; the live sample also
|
||
has no parent id. Parent hydration is therefore reusable compatibility machinery,
|
||
not an active parent-child hierarchy in the current v4 corpus. Provenance is at
|
||
chunk page-range/attachment-region precision; there is no character-offset span.
|
||
|
||
### Grounding, claims, citations, and generation
|
||
|
||
- `rag/prompt.py::ANSWER_SCHEMA` requires structured claims with citation indices.
|
||
- `rag/grounding.py::verify` rejects invalid citations, uncited claims, and numbers
|
||
absent from the specifically cited evidence.
|
||
- `GroundedAnswerService` additionally runs an LLM entailment/completeness check.
|
||
- API citations are built from retrieved `SourceRef`, never model-authored prose.
|
||
- `list_mode` exists for reverse indication and asks generation to enumerate the
|
||
retrieved drugs without calling any one first-line/preferred.
|
||
|
||
There is no deterministic candidate-set field on generated claims today. A model
|
||
that names an extra drug should be rejected by semantic entailment, but there is
|
||
no direct `generated_drugs - retrieved_drugs` set check. Citation responses expose
|
||
chunk id and page data; drug/section labels are currently reconstructed in the web
|
||
BFF by splitting `chunk_id`, rather than carried explicitly as provenance.
|
||
|
||
### Conversation state
|
||
|
||
Raw conversation lines are persisted by
|
||
`adapters/postgres.py::PostgresConversationStore`. `RagAgent._last_frame` is the
|
||
only normalized state and is in-process only. `_merge_with_prior_frame` only has a
|
||
code-level merge backstop for an open clarification. Ordinary multi-turn patient
|
||
facts are otherwise re-derived by the LLM from raw history and can be lost; no
|
||
explicit new-patient/case boundary exists.
|
||
|
||
### Tests and evaluations
|
||
|
||
Baseline command run before feature changes:
|
||
|
||
```text
|
||
python -m pytest -q --ignore=tests/test_api.py --ignore=tests/test_live_datastores.py
|
||
243 passed in 2.08s
|
||
```
|
||
|
||
Existing tests cover the primitive reverse indication route, its section filter,
|
||
one-hit-per-drug behavior, no-result abstention, list-mode prompting, grounding,
|
||
and raw history isolation. They do not cover disease normalization/ambiguity,
|
||
relation confusion, patient context, second-stage safety retrieval, candidate
|
||
assessment, or unsupported-drug rate. `rag/evaluation.py`/`run_eval.py` are
|
||
drug-first and do not calculate the requested condition-to-drug metrics.
|
||
|
||
## Phase 2 — Gap analysis
|
||
|
||
| Requirement | State | Existing implementation | Minimal proposed change |
|
||
|---|---|---|---|
|
||
| Disease intent | Partial | `symptom_to_drug` frame and agent branch | Rename/accept `condition_to_drug`; retain old value as compatibility alias; add requested relation |
|
||
| Drug→condition / dose / contraindication / interaction distinction | Partial | turn type + `attribute` | Add explicit `drug_to_condition` and relation-safe reverse categories without replacing section taxonomy |
|
||
| Condition extraction/normalization | Missing | free-text `indication` only | Add `ConditionQuery`; deterministic conservative alias normalization plus LLM structured output; preserve original |
|
||
| Ambiguity | Partial | generic `needs_clarify` | Add condition ambiguity fields and deterministic guard for known broad category-only queries |
|
||
| Indication-only reverse retrieval | Exists | both indication methods filter `chi_dinh` | Keep filter; add ranked candidate pool and aggregate at drug level |
|
||
| Drug-level aggregation/rerank | Partial | one first hit per drug | Aggregate all candidate hits by `drug_id`, then rerank/cap entities, never count chunks as votes |
|
||
| Dense/sparse/hybrid | Partial | dense + lexical; RRF not live | Reuse exact lexical-first and dense fallback initially; keep fusion seam, avoid unmeasured full-stack rewrite |
|
||
| Patient context | Missing | population/age/weight only | Add structured `PatientContext`, optional and field-preserving |
|
||
| Comorbidities/current medicines/allergy | Missing | direct interaction supports 2 named drugs | Make first-class context and trigger targeted second-stage retrieval |
|
||
| Renal/hepatic/pregnancy/age | Partial | sections exist for drug-centric queries | Select only relevant safety sections for top candidates, using lexical seed then whole-section hydration |
|
||
| Candidate assessment | Missing | raw evidence pool only | Add evidence-only `MedicationCandidateAssessment` grouped by drug and safety facet/status |
|
||
| Candidate-set hallucination guard | Partial | grounding + entailment | Require candidate `drug_id` on list-mode claims and validate it/cited evidence deterministically |
|
||
| Provenance | Partial | pages + chunk id | Carry drug name, section key/title, and corpus source explicitly through Evidence/Citation/API |
|
||
| Structured conversation state | Partial | raw Postgres history + in-memory last frame | Merge `PatientContext` only on explicit case continuation; reset on new case/topic; keep raw history fallback |
|
||
| Guideline distinction | Missing in prompt | corpus is Part 2 monographs only | Add prompt contract: indication evidence is not first-line/preferred/treatment-of-choice evidence |
|
||
| Metrics/eval | Missing for this feature | generic recall/resolution eval | Add deterministic feature eval cases/metrics including unsupported-drug rate and section/relation correctness |
|
||
|
||
## Phase 3 — Minimal architecture
|
||
|
||
```text
|
||
LlmQueryUnderstander
|
||
-> QueryFrame(condition + relation + optional PatientContext + case action)
|
||
-> deterministic condition normalization / ambiguity backstop
|
||
-> RagAgent condition_to_drug route
|
||
-> RetrievalService.retrieve_by_indication
|
||
-> chi_dinh lexical candidates (dense only as fallback)
|
||
-> group by drug_id
|
||
-> entity-level rerank/cap
|
||
-> indication evidence
|
||
-> if patient context exists:
|
||
top candidates × context
|
||
-> lexical selection among relevant safety facets
|
||
-> hydrate only selected whole sections
|
||
-> MedicationCandidateAssessment per drug
|
||
-> candidate-set validator
|
||
-> existing structured generation + grounding + entailment
|
||
-> explicit drug/section/page/source citations
|
||
```
|
||
|
||
### Files to modify
|
||
|
||
- `apps/ai-service/rag/understanding.py`: frame/schema/prompt/parser and bounded
|
||
conversation-state merge.
|
||
- `apps/ai-service/rag/agent.py`: relation-safe dispatch, ambiguity response,
|
||
optional patient-specific second stage, candidate assessments.
|
||
- `apps/ai-service/rag/service.py`: drug-level indication aggregation/rerank and
|
||
targeted patient-safety retrieval.
|
||
- `apps/ai-service/adapters/qdrant.py`: return a wider, scored indication candidate
|
||
pool without first-match/scroll-order ranking.
|
||
- `apps/ai-service/rag/models.py`, `rag/answer.py`, `rag/prompt.py`: evidence
|
||
provenance and deterministic candidate-set claim validation.
|
||
- `apps/ai-service/routers/rag.py`, `apps/web/app/api/chat/route.ts`, shared types:
|
||
expose explicit provenance without parsing chunk ids.
|
||
- instrumentation and tests/evals for new routes and metrics.
|
||
|
||
### File to create
|
||
|
||
- `apps/ai-service/rag/clinical.py`: small domain-only schemas and conservative
|
||
condition/context normalization. It contains no disease→drug knowledge.
|
||
- focused tests/eval fixture for condition-to-drug and patient safety.
|
||
|
||
### Explicit non-goals
|
||
|
||
No ingestion rewrite, knowledge graph, internet access, guideline subsystem,
|
||
autonomous diagnosis, agent loop, new service, or hard-coded disease→drug map.
|
||
The Part 2 monograph corpus can prove an indication and drug-specific safety text;
|
||
it cannot by itself prove first-line/preferred regimens.
|