Compare commits

...
Author SHA1 Message Date
Pavel Eliseev f75a358d21 feat(contract): add opt-in MAX configuration and lifecycle
CI / ruff · pytest (pull_request) Successful in 2m6s
Refs #81
2026-10-04 15:51:35 +03:00
Arxip222 b94291e541 Merge pull request 'feat(web): приложение пациента — двухэтапные опросы, результат скрининга, дневник по результату' (#79) from feat/rag-clinical-guidelines into dev
CI / ruff · pytest (push) Successful in 2m2s
2026-10-04 15:25:16 +03:00
Eleboba ebddcae61a Merge pull request 'fix(bot): чат-агент — доработки по живому прогону' (#78) from fix/chat-agent-live into dev
CI / ruff · pytest (push) Successful in 1m59s
2026-10-04 15:22:42 +03:00
ElebobaandClaude Opus 5.5 83ad5d8b33 fix(bot): chat agent — ГРЛС excerpts in drug check, questions with alcohol answered, retry, clearer logs
CI / ruff · pytest (pull_request) Successful in 2m13s
Live run through the API (Andrey, real OpenRouter): onset questions now answered from the instruction
excerpt, «можно пиво?» reaches the agent although the check-in records alcohol, quit intent keeps the single
rule reaction, findings are mentioned only for intake/combination questions, one retry on a provider error,
provider error message logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:20:18 +03:00
9 changed files with 152 additions and 10 deletions
+10
View File
@@ -37,3 +37,13 @@ GITEA_URL=https://gitea.24studbank.ru
GITEA_REPO=MedITron/PTSRaiAgent GITEA_REPO=MedITron/PTSRaiAgent
GITEA_USER= GITEA_USER=
GITEA_TOKEN= GITEA_TOKEN=
# MAX notifications: keep disabled until the backend/channel PRs and a real bot are installed.
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=
MAX_SITE_URL=
# Optional path to a trusted PEM CA bundle; never disable TLS verification.
MAX_CA_BUNDLE=
MAX_LINK_TTL_SECONDS=600
+33 -1
View File
@@ -1,10 +1,12 @@
"""Typed application settings. The only place that reads environment variables.""" """Typed application settings. The only place that reads environment variables."""
import re
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT_DIR = Path(__file__).resolve().parent.parent ROOT_DIR = Path(__file__).resolve().parent.parent
@@ -42,6 +44,36 @@ class Settings(BaseSettings):
tg_bot_token: str = "" tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None doctor_notify_chat_id: int | None = None
# MAX is opt-in. Its adapter is delivered in the related backend/channel changes.
max_enabled: bool = False
max_bot_token: SecretStr = SecretStr("")
max_bot_username: str = ""
max_webhook_secret: SecretStr = SecretStr("")
max_webhook_url: str = ""
max_site_url: str = ""
max_ca_bundle: Path | None = None
max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800)
@model_validator(mode="after")
def validate_max(self) -> "Settings":
if not self.max_enabled:
return self
if not self.max_bot_token.get_secret_value().strip():
raise ValueError("MAX_BOT_TOKEN is required when MAX_ENABLED=true")
if not re.fullmatch(r"[A-Za-z0-9_]{1,128}", self.max_bot_username):
raise ValueError("MAX_BOT_USERNAME must be the bot nickname without @ or a URL")
if not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", self.max_webhook_secret.get_secret_value()):
raise ValueError("MAX_WEBHOOK_SECRET must contain 32..256 URL-safe characters")
for name, value in (("MAX_SITE_URL", self.max_site_url), ("MAX_WEBHOOK_URL", self.max_webhook_url)):
url = urlsplit(value)
if url.scheme != "https" or not url.hostname or url.username or url.password or url.port not in (None, 443):
raise ValueError(f"{name} must use HTTPS on port 443 without credentials")
if url.query or (name == "MAX_WEBHOOK_URL" and url.fragment):
raise ValueError(f"{name} must not contain a query string (or a fragment for webhook)")
if self.max_ca_bundle is not None and not self.max_ca_bundle.is_file():
raise ValueError("MAX_CA_BUNDLE must point to an existing trusted CA bundle")
return self
# Virtual clock for the demo (ADR-005). # Virtual clock for the demo (ADR-005).
demo_clock: bool = True demo_clock: bool = True
tz: str = "Europe/Moscow" tz: str = "Europe/Moscow"
+3 -1
View File
@@ -448,7 +448,9 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
await save_checkin(ctx, **fields) await save_checkin(ctx, **fields)
if text is None and facts.med_question and not facts.quit_intent: if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx)) text = await answer_question(ctx, await current_prescription(ctx))
if text is None and not fields and looks_like_question(ctx.text): # A question is answered even if it also mentioned alcohol/side effects (those are recorded above).
# Quit intent gets the rule reaction (delayed-onset explanation + doctor notified) — no second answer.
if text is None and not facts.quit_intent and looks_like_question(ctx.text):
text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64) text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64)
if text is None: if text is None:
text = await ctx.gateway.phrase("support") text = await ctx.gateway.phrase("support")
+12 -2
View File
@@ -64,6 +64,16 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
return None return None
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
"""One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError)."""
wire = _wire(messages) if provider.name != "mock" else messages
try:
return await provider.chat(wire, TOOL_SPECS)
except ProviderError as exc:
log.warning("chat agent provider=%s retry after %s", provider.name, exc)
return await provider.chat(wire, TOOL_SPECS)
async def _patient_brief(ctx: ToolContext) -> str: async def _patient_brief(ctx: ToolContext) -> str:
"""One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers).""" """One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers)."""
rec = await ctx.record() rec = await ctx.record()
@@ -99,7 +109,7 @@ async def answer_chat(
try: try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2): async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps): for _ in range(s.chat_agent_max_steps):
turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS) turn = await _chat_with_retry(provider, messages)
if not turn.tool_calls: if not turn.tool_calls:
if not (turn.content or "").strip() and not nudged: if not (turn.content or "").strip() and not nudged:
# Some models (Gemini Flash) occasionally end with an empty message after tool results. # Some models (Gemini Flash) occasionally end with an empty message after tool results.
@@ -127,7 +137,7 @@ async def answer_chat(
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used)) log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
return None return None
except (ProviderError, TimeoutError) as exc: except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout") log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
return None return None
+4
View File
@@ -9,11 +9,15 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими
средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через
check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия). check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия).
Вопросы «когда подействует», «что будет, если бросить», «как принимать», побочные эффекты — смотри поле
instruction в check_drug_for_patient (инструкция ГРЛС) и дополняй search_guidelines.
Для общих вопросов о лечении вызывай search_guidelines. Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в 2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай. рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми 3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми
словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно». словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно».
Находки (findings) упоминай, когда вопрос о приёме, сочетании или безопасности препарата; на вопросы о
сроках действия, отмене, побочных эффектах отвечай по существу, без посторонних находок.
Про состав и непереносимости (intolerance_check) говори, только если о них спросили. Про состав и непереносимости (intolerance_check) говори, только если о них спросили.
Про алкоголь бери текст из general_interactions. Про алкоголь бери текст из general_interactions.
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату», 4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
+6
View File
@@ -66,6 +66,12 @@ class OpenRouterProvider:
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc raise ProviderError(type(exc).__name__) from exc
if resp.status_code != 200: if resp.status_code != 200:
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
try:
detail = str(resp.json().get("error", {}).get("message", ""))[:200]
except ValueError:
detail = ""
log.error("openrouter http %s: %s", resp.status_code, detail)
raise ProviderError(f"http {resp.status_code}") raise ProviderError(f"http {resp.status_code}")
try: try:
msg = resp.json()["choices"][0]["message"] msg = resp.json()["choices"][0]["message"]
+19
View File
@@ -10,6 +10,7 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from app.integrations.base import integrations
from app.integrations.emias_record import PatientRecord, emias_records from app.integrations.emias_record import PatientRecord, emias_records
from app.llm.agent.drug_safety import check_patient, find_drug, load_db from app.llm.agent.drug_safety import check_patient, find_drug, load_db
from app.llm.rag import retrieve from app.llm.rag import retrieve
@@ -183,10 +184,12 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
if hit if hit
else "в примере состава этого вещества нет; состав зависит от производителя." else "в примере состава этого вещества нет; состав зависит от производителя."
) )
instruction = await _instruction(ctx, entry.inn)
return { return {
"found": True, "found": True,
"inn": entry.inn, "inn": entry.inn,
"record": True, "record": True,
"instruction": instruction, # ГРЛС excerpts: onset, side effects, discontinuation, administration
"findings": [f.model_dump() for f in findings], "findings": [f.model_dump() for f in findings],
"intolerance_check": intolerance_check, "intolerance_check": intolerance_check,
"composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None, "composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None,
@@ -196,5 +199,21 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
} }
INSTRUCTION_SECTIONS = ("onset", "side_effects", "discontinuation", "administration", "alcohol")
async def _instruction(ctx: ToolContext, inn: str) -> dict[str, str]:
"""ГРЛС excerpts for the drug (no doses in mg — see data/meds/grls)."""
try:
sections = await integrations.drug_info.sections(inn)
except Exception as exc: # noqa: BLE001
log.error("drug_info unavailable: %s", type(exc).__name__)
return {}
out = {s.section: s.text for s in sections if s.section in INSTRUCTION_SECTIONS}
if out:
ctx.cite(sections[0].source.split(",")[0] + ": инструкция к препарату")
return out
def _json(data: Any) -> str: def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False) return json.dumps(data, ensure_ascii=False)
+18 -6
View File
@@ -30,13 +30,25 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# In compose Postgres is required and healthy before the app starts; this branch is for running the # In compose Postgres is required and healthy before the app starts; this branch is for running the
# API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable. # API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable.
log.error("db init failed, continuing without db: %s", type(exc).__name__) log.error("db init failed, continuing without db: %s", type(exc).__name__)
if settings.max_enabled:
raise # MAX requires persistent bindings and a delivery queue.
# Lanes fill these in (#13, #11); main.py itself stays frozen (#34). # Lanes fill these in (#13, #11); main.py itself stays frozen (#34).
await scheduler.start() max_channel = None
await telegram.start() try:
yield await telegram.start()
await telegram.stop() if settings.max_enabled:
await scheduler.stop() from app.channels import max_bot
await dispose()
max_channel = max_bot
await max_channel.start()
await scheduler.start()
yield
finally:
await scheduler.stop()
if max_channel is not None:
await max_channel.stop()
await telegram.stop()
await dispose()
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan) app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
+47
View File
@@ -0,0 +1,47 @@
"""MAX stays off without credentials; enabled deployments fail closed on incomplete settings."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def configured(**overrides):
fields = {
"max_enabled": True,
"max_bot_token": "test-only-token",
"max_bot_username": "test_bot",
"max_webhook_secret": "test-only-secret-" * 3,
"max_webhook_url": "https://api.example.org/api/v1/webhooks/max",
"max_site_url": "https://patient.example.org/#/today",
}
return Settings(_env_file=None, **(fields | overrides))
def test_max_disabled_by_default():
assert not Settings(_env_file=None).max_enabled
def test_max_secrets_are_redacted():
settings = configured()
assert "test-only-token" not in repr(settings)
assert "test-only-secret" not in repr(settings)
@pytest.mark.parametrize(
"overrides",
[
{"max_bot_token": ""},
{"max_bot_username": "@test_bot"},
{"max_webhook_secret": "short"},
{"max_site_url": "http://patient.example.org"},
{"max_webhook_url": "http://api.example.org"},
{"max_webhook_url": "https://api.example.org:8443/hook"},
{"max_webhook_url": "https://user:password@api.example.org/hook"},
{"max_webhook_url": "https://api.example.org/hook#fragment"},
{"max_site_url": "https://patient.example.org?token=credential"},
],
)
def test_max_rejects_incomplete_or_unsafe_settings(overrides):
with pytest.raises(ValidationError):
configured(**overrides)