Add read-only production runtime audit
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# 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.65–0.7 s and `suggest()` ~0.94–0.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.
|
||||
Reference in New Issue
Block a user