Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3e103954f | ||
|
|
bde6ec6855 | ||
|
|
ade93d959b | ||
|
|
bc044650ac | ||
|
|
473b884ed8 |
@@ -37,6 +37,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
|
||||
|
||||
|
||||
@@ -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")
|
||||
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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
Executable
+24
@@ -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 <<SH
|
||||
set -euo pipefail
|
||||
cd $DIR
|
||||
cp .env app.new/.env
|
||||
rm -rf app.old; [ -d app ] && mv app app.old; mv app.new app
|
||||
cd app
|
||||
echo "$SHA" > 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
|
||||
for i in \$(seq 1 40); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 3; done
|
||||
curl -sf 127.0.0.1:8010/health && echo " api ok"
|
||||
SH
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user