"""Server-derived subject scope. `routing.py`'s `_scope_gate` abstains outright on `NON_HUMAN` scope. Before this module, that value came straight from the request body — a caller could send `{"subject_scope":"human",...}` regardless of the query text, and the shipped web BFF did exactly that, hard-coded on every request without reading the message at all (Codex's 2026-08-06 review, F-02). A scope decision must not be something the caller gets to assert. `resolve_subject_scope` derives it from the query text and combines that with whatever the caller claimed by taking the more conservative of the two: a caller can *narrow* scope (claim `non_human` and have it stick) but can never *widen* it — a claim of `human` cannot override a server-detected veterinary turn. This is a corpus-coverage check, not a restriction on what a doctor or pharmacist is allowed to ask: the book behind this product covers human drug monographs only, so a query about dosing a dog is out of scope regardless of who is asking. It has nothing to do with, and must never be extended into, gatekeeping what kind of *clinical* question a professional user is allowed to ask (see `[[feedback_no_recommendation_gate]]`/progress log 2026-08-06 for the removed `QueryIntent.RECOMMENDATION` keyword detector — this product is for doctors and pharmacists, not lay users, and a "nên dùng thuốc gì" question from a clinician is exactly what a formulary reference is for, not something to abstain on). Deliberately not an LLM call: this is the gate every request passes through, so it must be cheap, available during a provider outage, and auditable as a fixed rule instead of a model judgment. It is a keyword heuristic, which means it has real blind spots (an unusual phrasing can still slip past) — the same trade-off `rag/agent.py`'s `_looks_non_human` backstop already accepts. `rag/agent.py` should consolidate onto this module once the new orchestrator is wired (F-03), instead of keeping a second, narrower keyword list. """ from __future__ import annotations from .models import SubjectScope from .text import normalize_name # Phrases that name a non-human recipient. Matched on normalized text (casefold, # diacritics stripped) so "chó", "CHÓ", "cho chó" all match one entry. _NON_HUMAN_PHRASES = ( "cho cho", # "cho chó" — normalize_name collapses "chó" -> "cho" "cho meo", "cho ga", "cho vit", "cho lon", "cho heo", "cho bo", "cho ngua", "cho de", "cho cuu", "thu y", "vat nuoi", "gia suc", "gia cam", "dong vat", ) def looks_non_human(query: str) -> bool: normalized = normalize_name(query) return any(phrase in normalized for phrase in _NON_HUMAN_PHRASES) def resolve_subject_scope(query: str, claimed: SubjectScope) -> SubjectScope: """The scope that actually gates retrieval: the more conservative of what the caller claimed and what the query text itself indicates.""" if claimed == SubjectScope.NON_HUMAN or looks_non_human(query): return SubjectScope.NON_HUMAN if claimed == SubjectScope.UNKNOWN: return SubjectScope.UNKNOWN return SubjectScope.HUMAN