diff --git a/app/config.py b/app/config.py index 0f0069c..35d10e4 100644 --- a/app/config.py +++ b/app/config.py @@ -35,6 +35,7 @@ class Settings(BaseSettings): openrouter_api_key: str = "" openrouter_model: str = "google/gemini-2.5-flash-lite" openrouter_base_url: str = "https://openrouter.ai/api/v1" + gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here chat_agent_timeout_s: float = 25.0 chat_agent_max_steps: int = 5 diff --git a/app/llm/agent/__init__.py b/app/llm/agent/__init__.py index dc0386d..7641a7f 100644 --- a/app/llm/agent/__init__.py +++ b/app/llm/agent/__init__.py @@ -22,6 +22,7 @@ from app.config import Settings, get_settings from app.core.types import ScrubbedText from app.llm.agent.prompts import SYSTEM_PROMPT from app.llm.agent.providers import ( + GigaChatToolProvider, MockAgentProvider, OpenRouterProvider, ProviderError, @@ -49,7 +50,7 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None: s = settings or get_settings() mode = s.chat_agent if mode == "auto": - mode = "openrouter" if s.openrouter_api_key else "off" + mode = "gigachat" if s.gigachat_credentials else "openrouter" if s.openrouter_api_key else "off" if mode == "openrouter": if not s.openrouter_api_key: log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled") @@ -58,15 +59,16 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None: if mode == "mock": return MockAgentProvider() if mode == "gigachat": - # TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call). - log.warning("chat agent: gigachat provider not implemented yet — disabled") - return None + if not s.gigachat_credentials: + log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled") + return None + return GigaChatToolProvider(s) 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 + wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name` try: return await provider.chat(wire, TOOL_SPECS) except ProviderError as exc: @@ -139,6 +141,9 @@ async def answer_chat( except (ProviderError, TimeoutError) as exc: log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc) return None + except Exception: # noqa: BLE001 — the agent is optional: any bug must degrade to the extractive answer, not a 500 + log.exception("chat agent provider=%s crashed", provider.name) + return None def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None: diff --git a/app/llm/agent/providers.py b/app/llm/agent/providers.py index 27524ba..5ef6fb2 100644 --- a/app/llm/agent/providers.py +++ b/app/llm/agent/providers.py @@ -68,9 +68,10 @@ class OpenRouterProvider: 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 = "" + err = resp.json().get("error", "") + detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200] + except (ValueError, AttributeError): + detail = resp.text[:200] log.error("openrouter http %s: %s", resp.status_code, detail) raise ProviderError(f"http {resp.status_code}") try: @@ -90,6 +91,88 @@ class OpenRouterProvider: return AssistantTurn(content=msg.get("content"), tool_calls=calls) +class GigaChatToolProvider: + """GigaChat function calling (RF provider, 152-FZ compliant). Converts the OpenAI-style history the agent keeps: + assistant `tool_calls` → `function_call`, `tool` results → role `function` with the function name. + GigaChat returns at most one function call per turn — the agent loop handles that naturally.""" + + name = "gigachat" + + def __init__(self, settings: Settings, client: Any | None = None) -> None: + self._settings = settings + self._client = client + + def _get_client(self) -> Any: + if self._client is None: + from gigachat import GigaChat + + s = self._settings + self._client = GigaChat( + credentials=s.gigachat_credentials, + scope=s.gigachat_scope, + model=s.gigachat_agent_model, + verify_ssl_certs=s.gigachat_verify_ssl, + timeout=s.chat_agent_timeout_s, + ) + return self._client + + @staticmethod + def to_messages(messages: list[dict[str, Any]]) -> list[Any]: + from gigachat.models import FunctionCall, Messages, MessagesRole + + out = [] + for m in messages: + role = m["role"] + if role == "system": + out.append(Messages(role=MessagesRole.SYSTEM, content=m["content"])) + elif role == "user": + out.append(Messages(role=MessagesRole.USER, content=m["content"])) + elif role == "assistant": + calls = m.get("tool_calls") or [] + for c in calls or [None]: + fc = None + if c is not None: + fc = FunctionCall(name=c["function"]["name"], arguments=json.loads(c["function"]["arguments"])) + out.append(Messages(role=MessagesRole.ASSISTANT, content=m.get("content") or "", function_call=fc)) + elif role == "tool": + out.append(Messages(role=MessagesRole.FUNCTION, name=m.get("name"), content=m["content"])) + return out + + @staticmethod + def to_functions(tools: list[dict[str, Any]]) -> list[Any]: + from gigachat.models import Function + + return [Function.model_validate(t["function"]) for t in tools] + + async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: + from gigachat.models import Chat + + chat = Chat( + messages=self.to_messages(messages), + functions=self.to_functions(tools), + function_call="auto", + temperature=0.1, + max_tokens=900, + ) + try: + resp = await self._get_client().achat(chat) + except Exception as exc: # noqa: BLE001 — network/auth/HTTP errors of the SDK + raise ProviderError(type(exc).__name__) from exc + msg = resp.choices[0].message + calls = [] + if msg.function_call is not None: + args = msg.function_call.arguments or {} + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {} + calls.append(ToolCall(id=msg.function_call.name, name=msg.function_call.name, arguments=args)) + usage = getattr(resp, "usage", None) + log.info("gigachat agent ok calls=%d tokens=%s", len(calls), getattr(usage, "total_tokens", None)) + return AssistantTurn(content=msg.content or None, tool_calls=calls) + + # ------------------------------------------------------------------------------------------------ offline mock _DRUG_WORDS = re.compile( diff --git a/infra/deploy/deploy.sh b/infra/deploy/deploy.sh new file mode 100755 index 0000000..adc9b28 --- /dev/null +++ b/infra/deploy/deploy.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Deploy origin/dev to the shared server (ssh alias `vobot`, dir /srv/ptsr). Run from a clean checkout: +# infra/deploy/deploy.sh [git-ref] default: origin/dev +# Keeps /srv/ptsr/.env (secrets live only on the server) and the Postgres volume. +set -euo pipefail +REF=${1:-origin/dev} +HOST=${DEPLOY_HOST:-vobot} +DIR=/srv/ptsr +git fetch -q origin || true +SHA=$(git rev-parse --short "$REF") +echo "deploy $REF ($SHA) -> $HOST:$DIR" +git archive --format=tar "$REF" | ssh "$HOST" "rm -rf $DIR/app.new && mkdir -p $DIR/app.new && tar -x -C $DIR/app.new" +ssh "$HOST" bash -s < REVISION +docker run --rm -v "\$PWD/web:/w" -w /w node:20-alpine sh -c "npm ci --no-audit --no-fund >/dev/null && npm run build >/dev/null" +docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build /dev/null && break; sleep 3; done +curl -sf 127.0.0.1:8010/health && echo " api ok" +SH diff --git a/infra/deploy/docker-compose.server.yml b/infra/deploy/docker-compose.server.yml new file mode 100644 index 0000000..d195ed9 --- /dev/null +++ b/infra/deploy/docker-compose.server.yml @@ -0,0 +1,30 @@ +# Server override (shared host with vobot): loopback-only ports behind host nginx, restart policy, memory caps. +# docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build +services: + postgres: + ports: !reset [] + restart: unless-stopped + mem_limit: 512m + + app: + ports: !override + - "127.0.0.1:8010:8000" + restart: unless-stopped + mem_limit: 768m + + dashboard: + command: + - streamlit + - run + - dashboard/app.py + - --server.port=8501 + - --server.address=0.0.0.0 + - --server.baseUrlPath=doctor + - --browser.gatherUsageStats=false + environment: + API_BASE_URL: http://app:8000 + DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-http://localhost}/doctor + ports: !override + - "127.0.0.1:8511:8501" + restart: unless-stopped + mem_limit: 768m diff --git a/infra/deploy/nginx-meditron.conf b/infra/deploy/nginx-meditron.conf new file mode 100644 index 0000000..ffcfd14 --- /dev/null +++ b/infra/deploy/nginx-meditron.conf @@ -0,0 +1,52 @@ +# meditron.tenvex.ru — PTSRaiAgent prototype (synthetic data only). +# / patient web app (static, web/dist) +# /api/ FastAPI (127.0.0.1:8010) +# /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor) +# TLS: after the DNS A-record exists — certbot --nginx -d meditron.tenvex.ru + +map $http_upgrade $ptsr_connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + listen [::]:80; + server_name meditron.tenvex.ru; + + access_log /var/log/nginx/ptsr-access.log; + client_max_body_size 5m; + + root /srv/ptsr/app/web/dist; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://127.0.0.1:8010; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; # chat agent + scheduler replay on clock advance + } + + location ~ ^/(docs|openapi.json|health)$ { + proxy_pass http://127.0.0.1:8010; + proxy_set_header Host $host; + } + + location /doctor/ { + proxy_pass http://127.0.0.1:8511; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $ptsr_connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + proxy_buffering off; + } +} diff --git a/tests/test_llm_agent.py b/tests/test_llm_agent.py index daabcc7..3752f15 100644 --- a/tests/test_llm_agent.py +++ b/tests/test_llm_agent.py @@ -197,3 +197,75 @@ async def test_empty_final_answer_is_nudged(sertraline): assert ans is not None and "Алкоголь" in ans.text assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1] assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt + + +async def test_region_block_string_error_degrades(): + # Seen live from the RF server: 403 {"success": false, "error": "Access denied by security policy."} + client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda r: httpx.Response(403, json={"success": False, "error": "Access denied"})) + ) + provider = OpenRouterProvider(_settings(), client=client) + assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None + + +async def test_unexpected_provider_bug_degrades(): + class Broken: + name = "broken" + + async def chat(self, messages, tools): + raise AttributeError("boom") + + assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=Broken(), settings=_settings()) is None + + +# ------------------------------------------------------------------------------------------------- gigachat + + +class _FakeGiga: + """Mimics gigachat.GigaChat.achat: first a function_call, then the final text.""" + + def __init__(self): + self.chats = [] + + async def achat(self, chat): + from types import SimpleNamespace as NS + + self.chats.append(chat) + if len(self.chats) == 1: + fc = NS(name="check_drug_for_patient", arguments={"drug": "ибупрофен"}) + msg = NS(content="", function_call=fc) + else: + msg = NS( + content="Ибупрофен вместе с сертралином повышает риск кровотечений. Обсудите с врачом.", + function_call=None, + ) + return NS(choices=[NS(message=msg)], usage=NS(total_tokens=42)) + + +async def test_gigachat_tool_loop(sertraline): + from app.llm.agent.providers import GigaChatToolProvider + + fake = _FakeGiga() + s = Settings(_env_file=None, chat_agent="gigachat", gigachat_credentials="x", chat_agent_timeout_s=5.0) + ans = await agent.answer_chat( + ANDREY, scrub("Можно ибупрофен?"), provider=GigaChatToolProvider(s, client=fake), settings=s + ) + assert ans is not None and "кровотеч" in ans.text and ans.provider == "gigachat" + second = fake.chats[1] + roles = [str(m.role.value if hasattr(m.role, "value") else m.role) for m in second.messages] + assert roles == ["system", "user", "assistant", "function"] + assert second.messages[2].function_call.name == "check_drug_for_patient" + assert second.messages[3].name == "check_drug_for_patient" + assert {f.name for f in second.functions} == { + "search_guidelines", + "get_patient_record", + "get_drug_info", + "check_drug_for_patient", + } + + +def test_build_provider_prefers_gigachat(): + from app.llm.agent.providers import GigaChatToolProvider + + s = Settings(_env_file=None, chat_agent="auto", gigachat_credentials="x", openrouter_api_key="y") + assert isinstance(agent.build_provider(s), GigaChatToolProvider)