Files
duocthu/docs/08-query-understanding.md
T

187 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 08 — Query understanding
Implementation: `apps/ai-service/rag/understanding.py` (1,030 lines),
`rag/routing.py::CatalogDrugResolver`, `rag/clinical.py`, `rag/policy.py`.
Tests: `tests/test_understanding.py`, `tests/test_policy.py`,
`tests/test_clinical_condition_flow.py`.
One LLM call per turn produces a `QueryFrame`. Nothing here answers a medical
question — the frame is intent only.
## Why an LLM replaced the heuristics
The previous front end resolved drugs with `difflib.SequenceMatcher` and routed
sections with a Vietnamese phrase table. The module docstring lists the measured
failures: `aspirinol` false-matched to aspirin, the correctly-spelled English
INN `amoxicillin` tied, and the common word `uống` was read as a drug.
## The safety property: candidates are bounded *before* the model runs
```mermaid
flowchart LR
T[turn + history lines]
R["CatalogDrugResolver.resolve(line)<br/>exact alias span match"]
S["CatalogDrugResolver.suggest(line, k=5, min_score=0.55)<br/>fuzzy, only when no exact match"]
C["candidate drug_id set"]
P["prompt shows ONLY these drug_ids"]
L[LLM]
V["_resolve_id(): output must be in the shown set<br/>(underscore/space form tolerated)"]
F[QueryFrame.drugs]
U[QueryFrame.unknown_drugs]
T --> R --> C
T --> S --> C
C --> P --> L --> V
V -->|in set| F
V -->|not in set| U
```
A catalog **whitelist** alone would not be enough, and the code says why
(finding F-04): validating that an output id is *some* real `drug_id` does not
prove it is the one the user's text named — a model could satisfy that whitelist
while mapping an invented name onto any of the other 683 real drugs. Bounding the
candidate set first removes that degree of freedom: `amoxicillin``amoxicilin`
still works (fuzzy puts it in the set), but `aspirinol` cannot become aspirin
because nothing about `aspirinol` fuzzy-matches aspirin.
The same change also bounded token cost — the full 684-drug catalog was
previously sent on every turn.
### Resolver performance
`CatalogDrugResolver.resolve` and `.suggest` are both `@lru_cache(maxsize=4096)`.
The comment records the measurement: over the real ~10,164-alias catalog,
`resolve()` costs ~0.650.7 s and `suggest()` ~0.940.97 s, and
`_candidate_ids` calls both **per history line, every turn**. An ordinary
multi-turn conversation was enough to exhaust the request budget before the
first Bedrock call, surfacing as a false "service outage".
Exact matching also enumerates the query's contiguous token spans against an
immutable alias index (`_alias_to_drug_ids`) instead of compiling ~10k regexes,
making the common path O(q²) in the short query rather than O(catalog).
## `QueryFrame`
| Field | Type | Meaning |
|---|---|---|
| `turn_type` | one of 10 | The router's primary branch |
| `drugs` | tuple[str] | Canonical `drug_id`s, catalog-bounded |
| `unknown_drugs` | tuple[str] | Named but not in the catalog |
| `attribute` | section key \| None | Validated against `SECTION_KEYS` |
| `population` | enum \| None | `tre_em`, `nguoi_lon`, `suy_than`, … |
| `weight_kg` | float \| None | Accepted only in `(0, 500]` |
| `age_text` | str \| None | As stated |
| `indication` | str \| None | |
| `condition` | `ConditionQuery` \| None | Normalized condition + subtype + ambiguity |
| `condition_relation` | `indication`\|`adverse_effect`\|`contraindication`\|`unknown` | |
| `patient_context` | `PatientContext` | Comorbidities, allergies, ADRs, current meds, renal, hepatic, pregnancy, labs |
| `context_action` | `none`\|`continue`\|`new` | Case continuity |
| `route` | enum \| None | `uong`, `tiem_tinh_mach`, `dat_truc_trang`, … |
| `section_overview` | bool | Survey the whole section vs. decide for one patient |
| `standalone_query` | str \| None | Turn rewritten self-contained |
| `depends_on_previous_turn` | bool | |
| `needs_clarify`, `clarify_reason`, `quick_replies` | | Ask-back |
| `system_error` | str \| None | Set only on a genuine technical failure |
| `raw` | dict | The model's raw JSON, excluded from equality |
`system_error` exists because a provider outage and a genuine clarifying
question previously produced the identical downstream
`reason="needs_more_info"`, making a real outage indistinguishable from normal
traffic in the API response and in metrics.
## The 10 turn types
`drug_attribute`, `drug_overview`, `interaction`, `symptom_to_drug`,
`condition_to_drug`, `drug_to_condition`, `condition_relation`, `dosing_calc`,
`smalltalk`, `out_of_scope`.
`condition_relation` exists specifically so *"which drug causes X"* and *"which
drug is contraindicated in X"* are never collapsed into an indication lookup.
## Prompt construction
The user message (`understand()`) is assembled from four blocks:
1. **Candidate drug list**`drug_id\tname` for the bounded set, with an
explicit note that this is not the whole formulary.
2. **Section keys with glosses**`SECTION_KEY_HINTS`. Bare slugs were
insufficient: 9/9 live calls for *"X cần thận trọng gì?"* picked
`chong_chi_dinh`, answering from the wrong section. The `than_trong` gloss
now spells out the distinction in capitals.
3. **`THÔNG TIN ĐÃ XÁC ĐỊNH TỪ CÁC LƯỢT TRƯỚC`** — a structured summary of the
prior frame (`_known_facts_block`), so established facts are *data* rather
than something to re-derive from a growing transcript.
4. **History** then the current turn.
`bootstrap.py::_catalog_names` decides which alias to show per drug. It always
shows the `drug_id`'s own name form first: paracetamol has 191 aliases, and the
alphabetically-first three were `0Frezefev, ABAB, Ace kid 80` — none
recognisable — after which the model read an earlier "paracetamol" mention as an
unknown drug and answered "not in the formulary" for a drug that plainly is.
## Deterministic post-conditions
The LLM output passes through four narrow, knowledge-free rewrites. Each covers
an unambiguous surface form where the model's routing would reverse the
requested relation:
| Function | Trigger | Effect |
|---|---|---|
| `_apply_condition_candidate_cue` | a known condition alias + a candidate cue (`dùng thuốc gì`, `lựa chọn thuốc nào`, …) | force `condition_to_drug` + `indication` |
| `_apply_broad_condition_cue` | a broad disease→drug question with no named drug | force `condition_to_drug` |
| `_apply_reverse_relation_cues` | `thuốc nào gây …`, `thuốc nào chống chỉ định …` | force `condition_relation` + the correct relation |
| `_apply_named_drug_cues` | an explicitly named drug + `có tác dụng gì` / `có chống chỉ định` | force `drug_to_condition` / `drug_attribute` |
None of them contains disease or drug knowledge, and none creates a candidate.
## Prior-frame merge
`_merge_with_prior_frame` is the code-level backstop for the model dropping an
already-known field. It fires only when:
- the turn is continuing a case (`context_action == continue` or
`depends_on_previous_turn`), **or** the prior turn was itself a clarify; and
- `context_action != new`; and
- this turn's own `drugs` agree with the prior frame (empty, or the same).
A turn that resolves a *different* drug is a genuine topic change and inherits
nothing — this is the guard against the reproduced "headache question answered
about OMEPRAZOL" bleed.
## Validation and fail-closed behaviour
| Failure | Result |
|---|---|
| `AnswerGenerationUnavailable` | Frame with `turn_type="out_of_scope"`, `needs_clarify=True`, `system_error="understanding_provider_unavailable"`, logged with the real exception |
| Unparseable JSON | `system_error="understanding_malformed_output"` |
| `turn_type` not in `TURN_TYPES` | falls back to `drug_attribute` if drugs were resolved, else `out_of_scope` |
| `attribute` not in `SECTION_KEYS` | → `None` |
| `population`/`route` outside the allowed set | → `None` |
| `weight_kg` outside `(0, 500]` | → `None` |
| A named drug not in the shown candidate set | → `unknown_drugs`, never a fuzzy substitution |
| `quick_replies` | max 4 items, max 40 chars each, de-duplicated |
Before F-10 this call site had **no** error handling at all — a provider outage
propagated into an unhandled 500 rather than the graceful abstain every other
failure mode gets.
## Subject-scope policy — `rag/policy.py`
Deliberately **not** an LLM call: this gate runs on every request, so it must be
cheap, available during a provider outage, and auditable as a fixed rule.
`resolve_subject_scope(query, claimed)` takes the more conservative of the
caller's claim and a keyword scan (`cho cho`, `cho meo`, `thu y`, `gia suc`, …
on diacritic-stripped text). A caller can **narrow** scope but never **widen**
it — the shipped web BFF hard-codes `subject_scope: "human"` on every request
without reading the message, which is exactly the review finding (F-02) this
module answers.
It is a corpus-coverage check, not clinical gatekeeping. The module docstring is
explicit that it must never be extended into restricting what a professional is
allowed to ask; the old `QueryIntent.RECOMMENDATION` keyword detector was
removed for that reason.
`rag/agent.py` still keeps its own narrower `looks_non_human` call as a
deterministic guard before every conversational clarify.