Files
PTSRaiAgent/app/llm/mock.py
ElebobaandClaude Opus 5.5 b4731fb1d6
CI / ruff · pytest (pull_request) Successful in 22s
chore(core): stage 1 contracts — dialog, API schemas + fixtures, integrations, monitoring, modules
Freeze interfaces between lanes A (bot), B (backend), C (dashboard) so three developers work in
parallel without touching each other's files: dialog Event/Reply + engine registry with echo stub,
integration protocols with in-memory defaults, MonitoringService with noop stub, /api/v1 schemas
with fixture-backed routes, frozen main.py wiring, config/modules.yaml + due_scales(), DAR-5,
tzdata, Andrey trajectory, lanes in WORKFLOW, ADR-010.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:22:25 +03:00

332 lines
12 KiB
Python
Raw Permalink 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.
"""Deterministic offline `LLMTasks` (ADR-004): keyword rules over Russian text.
Works at the task level (not by parsing prompts): it receives the same arguments as `PromptedTasks`
and returns the same pydantic models, so the gateway policy (thresholds, filters, fallbacks) is
identical for both providers. It must cover every free-text reply in docs/product/01_demo_persona.md.
These are rough heuristics for the demo, not a clinical NLP model. The risk rules here are an
*auxiliary* signal only; the primary safety signal is the psychiatrist's lexicon in `app.core.safety`.
"""
import re
from app.core.types import ScrubbedText
from app.llm.schemas import CheckinFacts, ItemScore, RiskVerdict, SideEffect
def _norm(text: str) -> str:
return " ".join(text.lower().replace("ё", "е").split())
def _any(patterns: tuple[str, ...], text: str) -> str | None:
for p in patterns:
m = re.search(p, text)
if m:
return m.group(0)
return None
# --- extract_item ----------------------------------------------------------------------------------
# Answers are mapped to a severity fraction of the item maximum, following the PHQ-9 frequency anchors
# (0 = not at all, 1 = several days, 2 = more than half the days, 3 = nearly every day). The fraction is
# then scaled to the item range, so PCL-5 (0..4) items reuse the same cues: round(fraction * max_score).
_SEVERITY_CUES: tuple[tuple[float, tuple[str, ...]], ...] = (
(
1.0,
(
r"почти кажд\w+ (день|ночь|дня|ночи)",
r"кажд\w+ (день|ночь)",
r"постоянно",
r"вс[её] время",
r"ежедневно",
r"почти не сплю",
r"совсем не (сплю|могу)",
r"очень (плохо|сильно|тяжело)",
r"ужасно",
r"невыносимо",
r"крайне",
),
),
(
2 / 3,
(
r"часто",
r"больше половины",
r"большую часть",
r"плохо",
r"сильно",
r"тяжело",
r"много раз",
r"не могу",
r"нет сил",
r"ничего не хочется",
),
),
(
1 / 3,
(
r"иногда",
r"несколько (дней|раз|ночей)",
r"пару раз",
r"время от времени",
r"бывает",
r"изредка",
r"немного",
r"слегка",
r"не очень",
r"чуть-чуть",
),
),
(
0.0,
(
r"\bнет\b",
r"не было",
r"ни разу",
r"совсем не беспоко",
r"не беспоко",
r"нормально",
r"\bхорошо\b",
r"отлично",
r"в порядке",
),
),
)
_UNSURE = (r"не знаю", r"сложно сказать", r"трудно сказать", r"не помню", r"затрудняюсь")
_NUMERIC_ANSWER = re.compile(r"^(?:на\s*)?(\d)(?:\s*балл\w*)?[.!]?$")
def _extract_item(scale_item_id: str, max_score: int, answer: str) -> ItemScore:
text = _norm(answer)
if _any(_UNSURE, text):
return ItemScore(item=scale_item_id, score=0, confidence=0.2, evidence=text[:60])
numeric = _NUMERIC_ANSWER.match(text)
if numeric and int(numeric.group(1)) <= max_score:
return ItemScore(item=scale_item_id, score=int(numeric.group(1)), confidence=0.9, evidence=text[:60])
# Highest matched severity wins: "сплю плохо, кошмары почти каждую ночь" -> nearly every day.
for fraction, cues in _SEVERITY_CUES:
hit = _any(cues, text)
if hit:
return ItemScore(item=scale_item_id, score=round(fraction * max_score), confidence=0.8, evidence=hit)
# No frequency/intensity cue -> low confidence, the gateway turns it into a clarifying question.
return ItemScore(item=scale_item_id, score=0, confidence=0.3, evidence="")
# --- extract_checkin -------------------------------------------------------------------------------
_SIDE_EFFECT_CUES: tuple[tuple[SideEffect, tuple[str, ...]], ...] = (
("nausea", (r"тошн", r"мутит", r"рвот", r"подташнива")),
# Only explicit inability to sleep counts as the insomnia side effect; "сплю плохо" alone is a sleep
# symptom (scale item / sleep bucket), otherwise veterans would get a side-effect flag every day.
("insomnia", (r"не могу (у|за)снуть", r"(у|за)снуть не могу", r"бессонниц", r"не спится")),
("headache", (r"голова болит", r"болит голова", r"головн\w* бол", r"мигрен")),
("dizziness", (r"кружится голова", r"голова кружится", r"головокруж")),
("sexual", (r"либидо", r"влечени", r"потенци", r"эрекц", r"оргазм")),
("agitation", (r"беспокойств", r"не нахожу себе места", r"взвинчен", r"трясет", r"дерга")),
)
_GENERIC_SIDE_EFFECT = (r"от (таблет\w*|лекарств\w*|препарат\w*) (только )?хуже", r"побочк", r"побочн")
_QUIT = (
r"брош\w*\b(?! (пить|курить))",
r"бросить(?! (пить|курить))",
r"бросаю(?! (пить|курить))",
r"перестан\w* (их |его )?(принимать|пить)",
r"не буду (их |его |больше )*(принимать|пить (таблет|лекарств|препарат))",
r"решил\w* не принимать",
r"хочу отказаться",
r"откажусь от",
)
_MED_TAKEN_NO = (
r"не принял",
r"не принимал",
r"не пил (таблет|лекарств|препарат)",
r"пропустил",
r"забыл\w* (принять|выпить|про таблет)",
r"решил\w* не принимать",
)
_MED_TAKEN_YES = (r"\bпринял\w*", r"выпил\w* (таблет|лекарств|препарат)", r"приняла")
# "Не могу уснуть без пива" is drinking, not abstinence — checked before the "без алкоголя" rule.
_ALCOHOL_DEPENDENT = (r"(не могу|не получается|не усну)\D{0,20}без (алкогол|пив|вин|водк|выпивк)",)
_ALCOHOL_NONE = (
r"не пил\b(?! (таблет|лекарств|препарат))",
r"без алкогол",
r"алкогол\w* (нет|не было)",
r"не выпивал",
r"трезв",
)
_ALCOHOL_MORE = (
r"напил",
r"много (пива|выпил|алкогол)",
r"(три|четыре|пять|несколько|много) (бутыл|банок|бокал|рюм)",
r"бутылк\w* водки",
r"литр",
r"пол-?литр",
r"запой",
)
_ALCOHOL_SOME = (
r"\bпив",
r"\bвин(о|ца)\b",
r"(бокал|выпил)\w* вина",
r"водк",
r"коньяк",
r"виски",
r"бокал",
r"рюмк",
r"выпил\w* (пару|одну|бутылку|банку|немного)",
r"пару (бутыл|банок)",
r"алкогол",
r"выпива",
)
_NIGHTMARES_NO = (r"кошмар\w* (нет|не было|не снил)", r"без кошмар")
_NIGHTMARES_YES = (r"кошмар", r"страшн\w* сн", r"плох\w* сн", r"снится (война|бой|фронт)")
_MED_WORDS = r"(таблет|препарат|лекарств|сертралин|доз|пить их|принимать)"
_MOOD = (
re.compile(r"настроени\w*\D{0,12}?(\d{1,2})"),
re.compile(r"(\d{1,2})\s*(?:/|из)\s*10"),
)
_SLEEP_HOURS = re.compile(r"(?:спал\w*|сплю|сна)\D{0,10}?(\d{1,2})\s*(?:час|ч\b)|(\d{1,2})\s*час\w* сна")
_SLEEP_SHORT = (r"почти не спал", r"\bне спал", r"пару часов", r"часа два", r"глаз не сомкнул")
def _sleep_bucket(hours: int) -> str:
if hours < 4:
return "<4"
if hours < 6:
return "4-6"
if hours <= 8:
return "6-8"
return ">8"
def _extract_checkin(raw: str) -> CheckinFacts:
text = _norm(raw)
mood = None
for pattern in _MOOD:
m = pattern.search(text)
if m and int(m.group(1)) <= 10:
mood = int(m.group(1))
break
side_effects: list[SideEffect] = [code for code, cues in _SIDE_EFFECT_CUES if _any(cues, text)]
if not side_effects and _any(_GENERIC_SIDE_EFFECT, text):
side_effects.append("other")
quit_intent = _any(_QUIT, text) is not None
if _any(_MED_TAKEN_NO, text):
med_taken: bool | None = False
elif _any(_MED_TAKEN_YES, text):
med_taken = True
else:
med_taken = None
# "Не могу уснуть без пива" mentions drinking without a quantity: record the minimal bucket ("1-2")
# so the alcohol-days rule still sees a drinking day; the button answer can refine it.
if _any(_ALCOHOL_DEPENDENT, text):
alcohol: str | None = "1-2"
elif _any(_ALCOHOL_NONE, text):
alcohol = "none"
elif _any(_ALCOHOL_MORE, text):
alcohol = "more"
elif _any(_ALCOHOL_SOME, text):
alcohol = "1-2"
else:
alcohol = None
sleep = None
m = _SLEEP_HOURS.search(text)
if m:
sleep = _sleep_bucket(int(m.group(1) or m.group(2)))
elif _any(_SLEEP_SHORT, text):
sleep = "<4"
if _any(_NIGHTMARES_NO, text):
nightmares: bool | None = False
elif _any(_NIGHTMARES_YES, text):
nightmares = True
else:
nightmares = None
med_question = "?" in text and re.search(_MED_WORDS, text) is not None
signals = (mood, med_taken, alcohol, sleep, nightmares)
found = any(x is not None for x in signals) or bool(side_effects) or quit_intent or med_question
return CheckinFacts(
mood=mood,
med_taken=med_taken,
side_effects=side_effects,
quit_intent=quit_intent,
alcohol=alcohol,
sleep_hours_bucket=sleep,
nightmares=nightmares,
med_question=med_question,
confidence=0.85 if found else 0.3,
)
# --- risk_classify ---------------------------------------------------------------------------------
_RISK_ACUTE = (
r"покончить с собой",
r"убить себя",
r"убью себя",
r"суицид",
r"самоубийств",
r"повеситься",
r"вскрыть вены",
r"не хочу жить",
r"не хочется жить",
r"жить не хочу",
r"наглотаться таблет",
r"выпил\w* все таблетки",
r"прощайте",
r"порезать себя",
)
_RISK_POSSIBLE = (
r"бессмысленн",
r"нет смысла",
r"смысла нет",
r"устал\w* от всего",
r"безнадежн",
r"никому не нужен",
r"никому не нужна",
r"хочу исчезнуть",
r"лучше бы меня не было",
r"заснуть и не проснуться",
r"не проснуться",
r"всем будет лучше без меня",
r"нет сил жить",
r"тупик",
)
def _risk(raw: str) -> RiskVerdict:
text = _norm(raw)
if _any(_RISK_ACUTE, text):
return RiskVerdict(level="acute", confidence=0.9)
if _any(_RISK_POSSIBLE, text):
return RiskVerdict(level="possible", confidence=0.7)
return RiskVerdict(level="none", confidence=0.6)
class MockTasks:
name = "mock"
async def extract_item(
self, scale_item_id: str, item_text: str, max_score: int, answer: ScrubbedText
) -> ItemScore | None:
return _extract_item(scale_item_id, max_score, answer)
async def extract_checkin(self, text: ScrubbedText) -> CheckinFacts | None:
return _extract_checkin(text)
async def risk_classify(self, text: ScrubbedText) -> RiskVerdict | None:
return _risk(text)
async def phrase(self, intent: str, payload: ScrubbedText, base_text: str) -> str | None:
return base_text
async def summarize_for_doctor(self, structured: ScrubbedText, base_text: str) -> str | None:
return base_text