72 lines
3.1 KiB
Python
72 lines
3.1 KiB
Python
"""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
|