"""Answers to patient questions about their drug — ONLY from the ГРЛС instruction excerpt (SPEC §7 п.5). Pipeline (deterministic except for the optional rephrasing step): 1. **Dose guard.** Any question about the dose ("можно увеличить дозу?") is never answered by the bot: the personal dose is the doctor's decision -> `None` (caller says «уточните у врача»). 2. **Topic.** An explicit `topic` from the engine/rules wins (e.g. `quit_intent` -> "onset"); otherwise a keyword classifier over the scrubbed question picks one of the supported section codes. 3. **Section.** The matching `DrugSection` is fetched via `integrations.drug_info` (lane B fills real ГРЛС excerpts). No section for the topic -> `None`. 4. **Sentences.** The section is split into sentences; sentences with dosing regimens are dropped (we never quote "до 200 мг" to a patient); the rest are scored by keyword overlap with the question, and the top ones (kept in original order) form the excerpt. Best score 0 -> the excerpt has no answer -> `None`. 5. **Rephrasing.** The excerpt goes to `gateway.phrase("med_answer", ...)` with the excerpt as the base meaning, so the gateway's forbidden-output filter and template fallback apply. On top of that we reject rephrasings that add content not present in the excerpt (new numbers, mostly new words) and fall back to the excerpt itself. 6. **Framing.** An intro ("По инструкции к препарату:") and a fixed doctor note from `config/texts/med_qa.yaml` are added outside the LLM, so they are always present. Logs never contain the question or the answer text — only codes. """ import logging import re from functools import lru_cache from pathlib import Path import yaml from pydantic import BaseModel from app.config import get_settings from app.core.types import ScrubbedText from app.integrations.base import DrugInfo, DrugSection, integrations from app.llm.gateway import get_gateway, is_forbidden log = logging.getLogger(__name__) PHRASE_INTENT = "med_answer" MAX_SENTENCES = 3 # the answer is meant to be 2–3 short sentences MAX_EXCERPT_CHARS = 350 # Share of content-word stems in an LLM rephrasing that may be absent from the excerpt (and the question). # Simplifying a sentence legitimately introduces some new words; a mostly new text means the model wrote # its own answer instead of rephrasing the instruction, which SPEC §7 п.5 forbids. MAX_NOVEL_STEM_SHARE = 0.5 STEM_LEN = 5 SECTIONS = ("side_effects", "onset", "alcohol", "discontinuation", "administration", "interactions", "overdose") # Engine/rule codes accepted as `topic` (demo days 2, 5, 10) -> section code. TOPIC_ALIASES = { "nausea": "side_effects", "side_effect": "side_effects", "quit_intent": "onset", "alcohol_interaction": "alcohol", } def _rx(*patterns: str) -> re.Pattern[str]: return re.compile("|".join(patterns), re.IGNORECASE) # Question -> topic. Order matters: the first match wins. Specific topics go first; "onset" precedes # "discontinuation" because in the demo "от таблеток только хуже, брошу" is driven by the (delayed) effect. TOPIC_CUES: tuple[tuple[str, re.Pattern[str]], ...] = ( ("side_effects", _rx(r"тошн", r"ташн", r"мутит", r"рвот", r"побочк", r"побочн")), ("alcohol", _rx(r"алкогол", r"\bпив", r"выпи(ть|л|ва)", r"\bвин(о|а|ом)\b", r"водк", r"спиртн")), ("overdose", _rx(r"передоз", r"лишн\w* таблет", r"две таблетки сразу", r"больше,? чем назнач")), ("interactions", _rx(r"совмест", r"вместе с", r"други\w* (лекарств|препарат|таблет)", r"сочета")), ( "onset", _rx(r"подейств", r"когда (начн|будет|станет)", r"не помога", r"не действ", r"эффект", r"(только|стало) хуже"), ), ("discontinuation", _rx(r"брос", r"отмен", r"переста\w*", r"прекрат", r"отказ\w* от")), ("administration", _rx(r"как (правильно )?(принимать|пить)", r"(до|после|во время) еды", r"натощак")), ) # Questions about the dose are always redirected to the doctor, whatever the instruction says. DOSE_QUESTION = _rx(r"доз", r"\d+\s*мг", r"полтаблет", r"половин\w* таблет", r"(увелич|уменьш|сниз)\w* (приём|прием)") # Sentences quoting a dosing regimen are never shown: the patient's dose is in the prescription, set by # the doctor, and an instruction range ("до 200 мг/сут") read out of context reads like advice. DOSING_SENTENCE = _rx(r"\d+\s*мг", r"доз\w*\s+(может|можно|следует|увелич|сниж|повыш|уменьш)", r"мг/сут") # Section-level cues: a sentence that talks about the section's subject gets +1 even without word overlap, # so an explicit topic with a vague question ("брошу") still finds e.g. the onset sentence. SECTION_CUES: dict[str, re.Pattern[str]] = { "side_effects": _rx(r"побочн", r"нежелательн", r"проход"), "onset": _rx(r"эффект", r"недел", r"улучшени"), "alcohol": _rx(r"алкогол", r"этанол", r"спиртн"), "discontinuation": _rx(r"отмен", r"прекращ", r"синдром"), "administration": _rx(r"еды", r"сутки", r"принима"), "interactions": _rx(r"одновременн", r"совместн", r"ингибитор"), "overdose": _rx(r"передозиров"), } # Symptom groups: prefixed verbs ("подташнивает") defeat prefix stemming, so a symptom named in the # question is matched against sentences by group (+2 — the strongest relevance signal). SYMPTOM_GROUPS: tuple[re.Pattern[str], ...] = ( _rx(r"тошн", r"ташн", r"мутит", r"рвот"), _rx(r"\bсон\b", r"\bсна\b", r"бессонн", r"засып", r"уснуть", r"сонлив"), _rx(r"головн\w* бол", r"голова болит", r"болит голова"), _rx(r"головокруж", r"кружится"), _rx(r"понос", r"диаре", r"желуд", r"живот"), _rx(r"либидо", r"влечени", r"потенци", r"эякуляц"), _rx(r"тревог", r"беспокойств", r"возбужден"), _rx(r"пот(ливость|еет|ею)", r"потоотдел"), ) _SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+") _WORD = re.compile(r"[а-яёa-z]+", re.IGNORECASE) _NUMBER = re.compile(r"\d+") class MedAnswer(BaseModel): text: str # patient-facing: intro + (rephrased) excerpt + doctor note section: str # DrugSection.section code source: str # ГРЛС reference from DrugSection.source phrased_by_llm: bool # False when the excerpt is shown verbatim (mock, rejection, failure) class MedQATexts(BaseModel): fallback: str intro: str doctor_note: str @lru_cache def load_med_qa_texts(path: Path | None = None) -> MedQATexts: path = path or get_settings().config_dir / "texts" / "med_qa.yaml" return MedQATexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) def fallback_text() -> str: """«Уточните у врача» — what the caller sends when `med_qa` returns `None`.""" return load_med_qa_texts().fallback def _norm(text: str) -> str: return " ".join(text.lower().replace("ё", "е").split()) def _stems(text: str) -> set[str]: # Crude Russian stemming: first STEM_LEN letters of words long enough to carry meaning (skips "не", "с"). return {w[:STEM_LEN] for w in _WORD.findall(_norm(text)) if len(w) >= 4} def detect_topic(question: str) -> str | None: text = _norm(question) for topic, pattern in TOPIC_CUES: if pattern.search(text): return topic return None def _score(sentence: str, question: str, q_stems: set[str], section: str) -> int: s = _norm(sentence) score = sum(2 for g in SYMPTOM_GROUPS if g.search(question) and g.search(s)) cue = SECTION_CUES.get(section) if cue is not None and cue.search(s): score += 1 return score + len(q_stems & _stems(s)) def select_excerpt(section: DrugSection, question: str) -> str | None: """Most relevant sentences of the section (original order), or `None` if nothing is relevant.""" q = _norm(question) q_stems = _stems(q) sentences = [s.strip() for s in _SENTENCE_SPLIT.split(section.text.strip()) if s.strip()] scored = [ (_score(s, q, q_stems, section.section), i, s) for i, s in enumerate(sentences) # Defence in depth: never surface a sentence the gateway would block if an LLM had written it. if not DOSING_SENTENCE.search(s) and not is_forbidden(s) ] relevant = [x for x in scored if x[0] > 0] if not relevant: return None # Highest score first, ties by position; then restore reading order of the chosen sentences. top = sorted(sorted(relevant, key=lambda x: (-x[0], x[1]))[:MAX_SENTENCES], key=lambda x: x[1]) return _trim(" ".join(s for _, _, s in top)) def _trim(text: str) -> str: if len(text) <= MAX_EXCERPT_CHARS: return text cut = text[:MAX_EXCERPT_CHARS].rsplit(" ", 1)[0].rstrip(",;:") return cut + "…" def adds_content(answer: str, excerpt: str, question: str) -> bool: """True if a rephrasing introduces facts absent from the excerpt (numbers, mostly new words).""" if not set(_NUMBER.findall(answer)) <= set(_NUMBER.findall(excerpt)): return True # new numbers = new doses/terms/frequencies — never allowed stems = _stems(answer) if not stems: return True novel = stems - _stems(excerpt) - _stems(question) return len(novel) / len(stems) > MAX_NOVEL_STEM_SHARE async def _rephrase(excerpt: str, question: str, inn: str, topic: str) -> tuple[str, bool]: text = await get_gateway().phrase(PHRASE_INTENT, {"excerpt": excerpt, "question": question, "drug": inn}) # The gateway returns the base (== excerpt, whitespace-normalised) on failure/filter; mock echoes it. if " ".join(text.split()) == " ".join(excerpt.split()): return excerpt, False if adds_content(text, excerpt, question): log.warning("med_qa rephrasing rejected: adds content topic=%s", topic) return excerpt, False return text, True async def med_qa( inn: str, question: ScrubbedText, *, topic: str | None = None, drug_info: DrugInfo | None = None, ) -> MedAnswer | None: """Answer about drug `inn` strictly from its ГРЛС excerpt; `None` -> caller sends `fallback_text()`.""" if DOSE_QUESTION.search(_norm(question)): log.info("med_qa: dose question redirected to doctor") return None explicit = TOPIC_ALIASES.get(topic, topic) if topic else None resolved = explicit if explicit in SECTIONS else detect_topic(question) if resolved is None: log.info("med_qa: topic not detected") return None try: sections = await (drug_info or integrations.drug_info).sections(inn) except Exception as exc: log.warning("med_qa: drug_info failed error=%s", type(exc).__name__) return None section = next((s for s in sections if s.section == resolved), None) if section is None: log.info("med_qa: no section topic=%s", resolved) return None excerpt = select_excerpt(section, question) if excerpt is None: log.info("med_qa: no relevant sentence topic=%s", resolved) return None body, by_llm = await _rephrase(excerpt, question, inn, resolved) texts = load_med_qa_texts() return MedAnswer( text=f"{texts.intro} {body}\n\n{texts.doctor_note}", section=section.section, source=section.source, phrased_by_llm=by_llm, )