Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -1,4 +1,4 @@
"""Клиент облачной LLM за интерфейсом: провайдер меняется значением в конфиге.
"""Клиент совместимого API для локальных или внешних моделей.
Кэш ответов по хешу контекста лежит в Postgres, а не в Redis: база уже поднята,
лишняя движущаяся часть на стенде не нужна (docs/arch/STACK.md). Кэш работает
@ -9,7 +9,10 @@
import hashlib
import json
import logging
import re
from dataclasses import dataclass
from ipaddress import ip_address
from urllib.parse import urlsplit
import httpx
from sqlalchemy import select
@ -26,6 +29,48 @@ class LlmUnavailable(RuntimeError):
занятие продолжается — молчащий звонящий хуже шаблонной фразы."""
def is_loopback_url(value: str, *, allow_docker_host: bool = False) -> bool:
"""В офлайн-режиме модели разрешены лишь на той же машине.
Не доверяем доменам или hosts-записям: они могут указывать наружу.
"""
try:
url = urlsplit(value)
host = url.hostname or ""
try:
local_host = ip_address(host).is_loopback
except ValueError:
local_host = allow_docker_host and host == "host.docker.internal"
return (url.scheme == "http" and local_host and url.port is not None
and not url.username and not url.password)
except (ValueError, TypeError):
return False
def _spoken_content(raw: str, *, strip_reasoning: bool = False) -> str:
"""Убрать только пустой служебный хвост Qwen3, не рассуждения модели.
llama.cpp с выключенным thinking иногда возвращает в `content` один
закрывающий `</think>` перед самой репликой. Внутренний текст размышлений
мы намеренно не вырезаем: если он есть, ответ небезопасен и идёт fallback.
"""
text = raw.strip()
if strip_reasoning:
closing = list(re.finditer(r"</think>\s*", text, re.IGNORECASE))
if closing:
text = text[closing[-1].end():].strip()
elif text.startswith("<|"):
start = text.find("{")
if start >= 0:
text = text[start:].strip()
text = re.sub(r"^(?:</think>\s*)+", "", text, flags=re.IGNORECASE).strip()
if re.search(r"</?think\b", text, re.IGNORECASE) or "<|" in text:
raise LlmUnavailable("ответ содержит служебные токены модели")
if not text:
raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
return text
@dataclass
class LlmRequest:
messages: list[dict]
@ -34,10 +79,15 @@ class LlmRequest:
# С запасом на рассуждающие модели: Qwen3 тратит на размышление сотни токенов
# и при малом бюджете возвращает пустой ответ с finish_reason="length".
max_tokens: int = 400
response_format: dict | None = None
# Только для внутренних структурированных задач. В репликах звонящего
# рассуждение всегда отвергается, чтобы оно не попало в эфир.
strip_reasoning: bool = False
def cache_key(self) -> str:
payload = json.dumps(
{"m": self.model, "t": self.temperature, "msgs": self.messages},
{"m": self.model, "t": self.temperature, "msgs": self.messages,
"format": self.response_format, "strip_reasoning": self.strip_reasoning},
ensure_ascii=False,
sort_keys=True,
)
@ -50,36 +100,43 @@ class LlmClient:
*,
sessionmaker: async_sessionmaker | None = None,
transport: httpx.AsyncBaseTransport | None = None,
base_url: str | None = None,
# Ответ дольше этого бессмысленен: бюджет хода — 1.5 с, а звонящий
# с заготовками ответит сразу.
timeout: float = 8.0,
) -> None:
settings = get_settings()
self._base_url = settings.llm_base_url.rstrip("/")
self._base_url = (base_url or settings.llm_base_url).rstrip("/")
self._key = settings.llm_api_key
self._local_only = settings.offline or settings.llm_provider == "local"
self._allow_docker_host = settings.allow_docker_host_models
self._sessionmaker = sessionmaker
self._client = httpx.AsyncClient(timeout=timeout, transport=transport)
self._client = httpx.AsyncClient(timeout=timeout, transport=transport, trust_env=False)
@property
def configured(self) -> bool:
if self._local_only:
return is_loopback_url(
self._base_url, allow_docker_host=self._allow_docker_host
)
return bool(self._key and self._base_url)
async def complete(self, request: LlmRequest, *, use_cache: bool = True) -> str:
"""Ответ модели. Кэш по хешу контекста: та же реплика на том же месте
занятия звучит одинаково у каждой группы."""
if not self.configured:
raise LlmUnavailable("не задан ключ или адрес провайдера")
raise LlmUnavailable("локальный адрес модели недопустим или провайдер не настроен")
key = request.cache_key()
if use_cache:
cached = await self._from_cache(key)
if cached is not None:
return cached
return _spoken_content(cached, strip_reasoning=request.strip_reasoning)
try:
response = await self._client.post(
f"{self._base_url}/chat/completions",
headers={"Authorization": f"Bearer {self._key}"},
headers={"Authorization": f"Bearer {self._key}"} if self._key else {},
json={
"model": request.model,
"messages": request.messages,
@ -88,6 +145,8 @@ class LlmClient:
# Рассуждение в ответе не нужно: оно только раздувает трафик.
# Провайдеры, которые про это поле не знают, его игнорируют.
"reasoning": {"exclude": True},
**({"response_format": request.response_format}
if request.response_format is not None else {}),
},
)
except httpx.HTTPError as exc:
@ -97,13 +156,14 @@ class LlmClient:
# Тело ошибки в лог, ключ в заголовке — не логируется.
raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}")
message = response.json()["choices"][0]["message"]
text = (message.get("content") or "").strip()
if not text:
# У рассуждающих моделей при нехватке бюджета весь ответ уходит
# в размышление, а content приходит пустым. Для занятия это отказ:
# звонящий откатится на заготовку, а не промолчит.
raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
try:
message = response.json()["choices"][0]["message"]
content = message.get("content")
if content is not None and not isinstance(content, str):
raise TypeError("content не строка")
text = _spoken_content(content or "", strip_reasoning=request.strip_reasoning)
except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
raise LlmUnavailable("некорректный ответ локальной модели") from exc
if use_cache and text:
await self._to_cache(key, request, text)
return text