From 473b884ed856e1446c4ae72ba793eff5b71c1715 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 15:27:09 +0300 Subject: [PATCH 1/3] =?UTF-8?q?chore(devops):=20server=20deploy=20next=20t?= =?UTF-8?q?o=20vobot=20=E2=80=94=20compose=20override,=20nginx=20vhost,=20?= =?UTF-8?q?deploy=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- infra/deploy/deploy.sh | 24 ++++++++++++ infra/deploy/docker-compose.server.yml | 30 +++++++++++++++ infra/deploy/nginx-meditron.conf | 52 ++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100755 infra/deploy/deploy.sh create mode 100644 infra/deploy/docker-compose.server.yml create mode 100644 infra/deploy/nginx-meditron.conf diff --git a/infra/deploy/deploy.sh b/infra/deploy/deploy.sh new file mode 100755 index 0000000..f190cbf --- /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 +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; + } +} -- 2.54.0 From bc044650ac00c037782e799a7349d13d9039b712 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 15:27:09 +0300 Subject: [PATCH 2/3] chore(devops): deploy script tolerates offline fetch Co-Authored-By: Claude Opus 5.5 --- infra/deploy/deploy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/deploy/deploy.sh b/infra/deploy/deploy.sh index f190cbf..adc9b28 100755 --- a/infra/deploy/deploy.sh +++ b/infra/deploy/deploy.sh @@ -6,7 +6,7 @@ set -euo pipefail REF=${1:-origin/dev} HOST=${DEPLOY_HOST:-vobot} DIR=/srv/ptsr -git fetch -q origin +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" -- 2.54.0 From ade93d959bdda2f48a095d34af3071040df756ed Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 15:46:21 +0300 Subject: [PATCH 3/3] feat(bot): GigaChat function-calling provider for the chat agent; never 500 on provider errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter returns 403 «Access denied by security policy» from the RF server, and parsing that string error crashed the request (500 instead of the fallback answer). Error parsing is robust now and any agent exception degrades to the extractive answer. GigaChat tool provider (GigaChat-2-Pro by default) converts the agent's OpenAI-style history to function_call / role=function; auto mode prefers GigaChat when credentials are set. Co-Authored-By: Claude Opus 5.5 --- app/config.py | 1 + app/llm/agent/__init__.py | 15 ++++--- app/llm/agent/providers.py | 89 ++++++++++++++++++++++++++++++++++++-- tests/test_llm_agent.py | 72 ++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 8 deletions(-) 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/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) -- 2.54.0