Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3e103954f | ||
|
|
bde6ec6855 | ||
|
|
ade93d959b | ||
|
|
bc044650ac | ||
|
|
473b884ed8 |
@@ -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
|
||||||
|
|||||||
+34
-1
@@ -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
|
||||||
@@ -35,6 +37,7 @@ class Settings(BaseSettings):
|
|||||||
openrouter_api_key: str = ""
|
openrouter_api_key: str = ""
|
||||||
openrouter_model: str = "google/gemini-2.5-flash-lite"
|
openrouter_model: str = "google/gemini-2.5-flash-lite"
|
||||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
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_timeout_s: float = 25.0
|
||||||
chat_agent_max_steps: int = 5
|
chat_agent_max_steps: int = 5
|
||||||
|
|
||||||
@@ -42,6 +45,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"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.config import Settings, get_settings
|
|||||||
from app.core.types import ScrubbedText
|
from app.core.types import ScrubbedText
|
||||||
from app.llm.agent.prompts import SYSTEM_PROMPT
|
from app.llm.agent.prompts import SYSTEM_PROMPT
|
||||||
from app.llm.agent.providers import (
|
from app.llm.agent.providers import (
|
||||||
|
GigaChatToolProvider,
|
||||||
MockAgentProvider,
|
MockAgentProvider,
|
||||||
OpenRouterProvider,
|
OpenRouterProvider,
|
||||||
ProviderError,
|
ProviderError,
|
||||||
@@ -49,7 +50,7 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
|
|||||||
s = settings or get_settings()
|
s = settings or get_settings()
|
||||||
mode = s.chat_agent
|
mode = s.chat_agent
|
||||||
if mode == "auto":
|
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 mode == "openrouter":
|
||||||
if not s.openrouter_api_key:
|
if not s.openrouter_api_key:
|
||||||
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
|
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":
|
if mode == "mock":
|
||||||
return MockAgentProvider()
|
return MockAgentProvider()
|
||||||
if mode == "gigachat":
|
if mode == "gigachat":
|
||||||
# TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call).
|
if not s.gigachat_credentials:
|
||||||
log.warning("chat agent: gigachat provider not implemented yet — disabled")
|
log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled")
|
||||||
return None
|
return None
|
||||||
|
return GigaChatToolProvider(s)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
|
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)."""
|
"""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:
|
try:
|
||||||
return await provider.chat(wire, TOOL_SPECS)
|
return await provider.chat(wire, TOOL_SPECS)
|
||||||
except ProviderError as exc:
|
except ProviderError as exc:
|
||||||
@@ -139,6 +141,9 @@ async def answer_chat(
|
|||||||
except (ProviderError, TimeoutError) as exc:
|
except (ProviderError, TimeoutError) as exc:
|
||||||
log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
|
log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
|
||||||
return None
|
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:
|
def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
|
||||||
|
|||||||
@@ -68,9 +68,10 @@ class OpenRouterProvider:
|
|||||||
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.
|
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
|
||||||
try:
|
try:
|
||||||
detail = str(resp.json().get("error", {}).get("message", ""))[:200]
|
err = resp.json().get("error", "")
|
||||||
except ValueError:
|
detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200]
|
||||||
detail = ""
|
except (ValueError, AttributeError):
|
||||||
|
detail = resp.text[:200]
|
||||||
log.error("openrouter http %s: %s", resp.status_code, 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:
|
||||||
@@ -90,6 +91,88 @@ class OpenRouterProvider:
|
|||||||
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
|
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
|
# ------------------------------------------------------------------------------------------------ offline mock
|
||||||
|
|
||||||
_DRUG_WORDS = re.compile(
|
_DRUG_WORDS = re.compile(
|
||||||
|
|||||||
+18
-6
@@ -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)
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -197,3 +197,75 @@ async def test_empty_final_answer_is_nudged(sertraline):
|
|||||||
assert ans is not None and "Алкоголь" in ans.text
|
assert ans is not None and "Алкоголь" in ans.text
|
||||||
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
|
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
|
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