feat(llm): foundational LLM infra package with §19 redaction + deterministic fallback (#960)
All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m20s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m13s
CI / changes (push) Successful in 5s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped

Optional external-OpenAI layer over the deterministic forecasting engine. Gated by
llm_enabled (default False) so prod makes no network calls until deliberately enabled.
Allowlist-first SafePayload contract + is_confidential hard-block + RU-PII regex scrub
(mandatory on the external path). Abstract LLMProvider seam (is_external) for a future
RU-hosted provider. Sync httpx core (Celery-friendly); tool/function-calling pass-through;
timeout + bounded 429/5xx retry + per-request call cap, all degrading to fallback.
Raw httpx (no openai SDK -> no pyproject/lock drift). 47 tests, ruff + mypy clean.

Refs #960
This commit is contained in:
Light1YT 2026-06-08 15:44:16 +05:00
parent 4b2e7d9af8
commit 4af7ba5a40
12 changed files with 1575 additions and 0 deletions

View file

@ -141,5 +141,32 @@ class Settings(BaseSettings):
# возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
enable_ird_analyze: bool = True
# ── LLM infrastructure (#960) ────────────────────────────────────────────
# ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
# зависит от LLM — при любом сбое/выключенности возвращается детерминированный
# fallback (см. app/services/llm/client.py).
#
# llm_enabled СТРОГО default False: пока секреты не настроены в проде, клиент
# НЕ делает сетевых вызовов вообще (guard #2 в client.complete). Включать только
# после того как OPENAI_API_KEY заведён в окружение прод-контейнера И принято
# решение по §19 data-residency (провайдер внешний — данные покидают РФ).
llm_enabled: bool = False
# Ключ читается ТОЛЬКО отсюда (env OPENAI_API_KEY). Нигде в коде/тестах нет
# литерала ключа. None = ключ не задан → клиент ведёт себя как при llm_enabled=False.
openai_api_key: str | None = None
llm_model: str = "gpt-4o-mini"
llm_base_url: str = "https://api.openai.com/v1"
# Таймаут одного HTTP-вызова к провайдеру (сек). Воркер не должен висеть.
llm_timeout_s: float = 30.0
llm_max_output_tokens: int = 1024
# Верхняя граница числа LLM-вызовов в рамках одной логической операции
# (граддок-extraction / chat-turn) — защита от случайного цикла у консьюмера.
llm_max_calls_per_request: int = 4
# Бюджетный потолок (USD) — пока только логируется как оценка (tokens→$).
# None = без потолка. Жёсткий enforcement добавит консьюмер при необходимости.
llm_daily_cost_cap_usd: float | None = None
# Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
llm_max_retries: int = 2
settings = Settings()

View file

@ -0,0 +1,77 @@
"""LLM infrastructure (#960) — ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка.
Foundational-пакет. Консьюмеры приедут позже: #956 граддок-extraction (sync Celery)
и #957 chat (async FastAPI). Этот пакет их НЕ реализует — только инфраструктуру.
КОНТРАКТ БЕЗОПАСНОСТИ обязателен к прочтению авторами консьюмеров
1. Детерминированный fallback ВСЕГДА. ``complete`` НИКОГДА не бросает из-за LLM:
при ``LLMResult.ok is False`` вы ОБЯЗАНЫ подставить свой детерминированный
результат. Forecasting-движок остаётся детерминированным; LLM ничего не «ломает».
res = complete(system_prompt=..., payload=safe)
if not res.ok:
return deterministic_result() # обязательно
use(res.content, res.tool_calls)
2. Allowlist-first (§19 data-residency). Провайдер ВНЕШНИЙ (OpenAI) данные
покидают РФ. Поэтому в ``complete`` нельзя передать сырые строки/DB-записи
только ЯВНО собранный ``SafePayload`` из проверенных не-чувствительных полей:
payload = SafePayload(
text=public_regulation_text, # публичный/безопасный текст
fields={"zone_index": "Ц-1", "floors": 16}, # агрегаты/коды
is_confidential=insight.is_confidential, # прокинуть флаг источника!
)
НЕ собирайте ``SafePayload`` из сырой строки insight/лида без проверки полей.
3. Hard-block конфиденциального. Если ``SafePayload.is_confidential is True``
для внешнего провайдера ``complete`` НЕ отправит данные, вернёт fallback
(reason="redaction_refused"). Всегда прокидывайте confidential-флаг источника.
4. Regex-scrub вторичная защита (belt-and-suspenders): из ``text``/``fields``
автоматически вырезаются RU PII (телефон/email/СНИЛС/ИНН/паспорт/ФИО) перед
отправкой. Это НЕ заменяет пункт 2 не полагайтесь на scrub как на гарантию.
5. ``llm_enabled`` default False. В проде (до настройки секретов) сеть НЕ дёргается
``complete`` сразу возвращает fallback. Включение осознанное действие.
"""
from __future__ import annotations
from .client import LLMResult, LLMUnavailableError, complete, complete_or_raise
from .prompts import PromptNotFoundError, render
from .provider import (
LLMProvider,
LLMProviderError,
LLMRateLimitedError,
LLMTimeoutError,
OpenAIProvider,
ToolCall,
)
from .redaction import RedactionRefusedError, SafePayload, scrub_safe_payload, scrub_text
# Сортировка isort-style (RUF022). Группировка по слоям — в docstring выше:
# orchestration (complete/complete_or_raise/LLMResult), redaction (SafePayload/...),
# provider abstraction (LLMProvider/OpenAIProvider/... — шов под RU-hosted), prompts.
__all__ = [
"LLMProvider",
"LLMProviderError",
"LLMRateLimitedError",
"LLMResult",
"LLMTimeoutError",
"LLMUnavailableError",
"OpenAIProvider",
"PromptNotFoundError",
"RedactionRefusedError",
"SafePayload",
"ToolCall",
"complete",
"complete_or_raise",
"render",
"scrub_safe_payload",
"scrub_text",
]

View file

@ -0,0 +1,283 @@
"""LLM orchestration client (#960) — enabled-check → redact → guardrails → fallback.
Архитектурный принцип (жёсткий): forecasting-движок полностью детерминирован; LLM
ОПЦИОНАЛЬНЫЙ слой, у которого ВСЕГДА есть детерминированный fallback. Поэтому
``complete`` НИКОГДА не падает наружу из-за LLM: любая проблема (выключено / таймаут /
сеть / ошибка провайдера / отказ redaction / превышение call-cap) превращается в
``LLMResult(ok=False, fallback_used=True)`` вызывающий подставляет свой
детерминированный результат.
Гарантия «прод не позвонит в OpenAI случайно»: если ``settings.llm_enabled`` is False
ИЛИ ключ не задан ``complete`` возвращает fallback СРАЗУ, не создавая провайдера и не
делая сетевых вызовов (guard #2). Это первая строка функции.
Sync-first: ядро синхронное (``httpx.Client``) совпадает с синхронным
forecasting-движком и Celery-консьюмером (#956). Async-консьюмер (#957 chat) мостит
через ``anyio.to_thread.run_sync`` / FastAPI ``run_in_threadpool`` (НЕ делаем async
Celery task backend.md).
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Any
from app.core.config import settings
from .provider import (
LLMProvider,
LLMProviderError,
LLMRateLimitedError,
OpenAIProvider,
ProviderResponse,
ToolCall,
)
from .redaction import RedactionRefusedError, SafePayload, scrub_safe_payload
logger = logging.getLogger(__name__)
class LLMUnavailableError(Exception):
"""LLM-слой недоступен (выключен / нет ключа / сбой). Для вызывающих, которые
предпочитают исключение вместо проверки ``LLMResult.ok``. ``complete`` по умолчанию
НЕ бросает возвращает fallback-результат; raise делает только ``complete_or_raise``.
"""
@dataclass(frozen=True, slots=True)
class LLMResult:
"""Результат вызова. ``ok=False`` → вызывающий ОБЯЗАН использовать детерм. fallback.
Attributes:
ok: True только при успешном ответе провайдера.
content: Текст ответа модели (None при tool-only ответе или fallback).
tool_calls: Распарсенные tool/function calls (pass-through).
fallback_used: True если результат сигнал к детерминированному fallback.
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
model: модель, ответившая на запрос ("" при fallback).
"""
ok: bool
content: str | None = None
tool_calls: list[ToolCall] = field(default_factory=list)
fallback_used: bool = False
reason: str | None = None
prompt_tokens: int = 0
completion_tokens: int = 0
model: str = ""
@classmethod
def fallback(cls, reason: str) -> LLMResult:
return cls(ok=False, fallback_used=True, reason=reason)
@classmethod
def from_provider(cls, resp: ProviderResponse) -> LLMResult:
return cls(
ok=True,
content=resp.content,
tool_calls=list(resp.tool_calls),
prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens,
model=resp.model,
)
# Грубая оценка стоимости (USD / 1K токенов) для лог-хука tokens→$. Только для
# логирования/наблюдаемости — НЕ enforcement. Значения для gpt-4o-mini (ориентир,
# уточняется консьюмером при смене модели/тарифа).
_COST_PER_1K_PROMPT_USD = 0.00015
_COST_PER_1K_COMPLETION_USD = 0.00060
def _estimate_cost_usd(prompt_tokens: int, completion_tokens: int) -> float:
return (
prompt_tokens / 1000.0 * _COST_PER_1K_PROMPT_USD
+ completion_tokens / 1000.0 * _COST_PER_1K_COMPLETION_USD
)
def _build_default_provider() -> LLMProvider | None:
"""Собрать провайдера из settings. None если ключ не задан (→ fallback у вызывающего).
Сейчас единственный провайдер OpenAI (внешний). Будущий RU-hosted выбирается
здесь же по настройке (напр. settings.llm_provider) без изменений в complete.
"""
if not settings.openai_api_key:
return None
return OpenAIProvider(
api_key=settings.openai_api_key,
model=settings.llm_model,
base_url=settings.llm_base_url,
timeout_s=settings.llm_timeout_s,
)
def _messages_from_payload(system_prompt: str, payload: SafePayload) -> list[dict[str, Any]]:
"""Собрать chat-messages из системного промпта и (уже проскрабленной) нагрузки.
fields сериализуются в компактный «ключ: значение» блок и приклеиваются к user-тексту
модель получает структурированный контекст, а вызывающий не пишет prompt-склейку сам.
"""
user_parts: list[str] = []
if payload.text:
user_parts.append(payload.text)
if payload.fields:
rendered = "\n".join(f"{k}: {v}" for k, v in payload.fields.items())
user_parts.append(rendered)
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "\n\n".join(user_parts)},
]
def complete(
*,
system_prompt: str,
payload: SafePayload,
tools: list[dict[str, Any]] | None = None,
provider: LLMProvider | None = None,
call_index: int = 0,
) -> LLMResult:
"""Главная точка входа. Прогоняет нагрузку через все защиты и возвращает LLMResult.
Контракт безопасности (порядок строгий):
1. enabled-check: ``llm_enabled`` False / нет ключа fallback БЕЗ сети.
2. call-cap: ``call_index >= llm_max_calls_per_request`` fallback (анти-цикл).
3. redaction (mandatory на external-пути): hard-block confidential + regex-scrub.
4. provider call с таймаутом + ретраями (429/5xx, backoff, cap), circuit-breaker-lite.
5. любой сбой fallback (система не падает из-за LLM).
Args:
system_prompt: системная инструкция (без данных данные в ``payload``).
payload: ЯВНО собранная вызывающим ``SafePayload`` (allowlist-first контракт).
tools: опц. OpenAI tool/function-calling спецификации (clean pass-through).
provider: внедрить провайдера (тесты/будущий RU-hosted). None из settings.
call_index: порядковый номер вызова в рамках одной логической операции
(для call-cap). Консьюмер инкрементит сам.
Returns:
``LLMResult`` при ``ok=False`` вызывающий ОБЯЗАН подставить детерм. fallback.
"""
# ── Guard #1/#2: выключено или нет ключа → НИКАКОЙ сети ──────────────────
if not settings.llm_enabled:
logger.debug("llm: disabled (llm_enabled=False) → deterministic fallback")
return LLMResult.fallback("disabled")
prov = provider or _build_default_provider()
if prov is None:
logger.warning("llm: enabled but OPENAI_API_KEY not set → deterministic fallback")
return LLMResult.fallback("no_api_key")
# ── Per-request call cap (анти-цикл) ────────────────────────────────────
if call_index >= settings.llm_max_calls_per_request:
logger.warning(
"llm: call cap reached (call_index=%d >= %d) → fallback",
call_index,
settings.llm_max_calls_per_request,
)
return LLMResult.fallback("call_cap")
# ── Redaction (mandatory на external-пути) ──────────────────────────────
try:
safe = scrub_safe_payload(payload, is_external=prov.is_external)
except RedactionRefusedError:
# confidential нагрузка — НЕ отправляем наружу, деградируем в fallback.
return LLMResult.fallback("redaction_refused")
messages = _messages_from_payload(system_prompt, safe)
# ── Provider call с ретраями (circuit-breaker-lite) ─────────────────────
return _call_with_retries(prov, messages, tools)
def _call_with_retries(
provider: LLMProvider,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
) -> LLMResult:
"""Вызвать провайдера с bounded-ретраями на 429/5xx и backoff. Сбой → fallback."""
max_retries = settings.llm_max_retries
last_reason = "provider_error"
for attempt in range(max_retries + 1):
try:
resp = provider.complete(
messages,
tools=tools,
max_output_tokens=settings.llm_max_output_tokens,
)
except LLMRateLimitedError as e:
last_reason = "rate_limited"
if attempt < max_retries:
wait = e.retry_after if e.retry_after is not None else min(2**attempt, 30)
logger.warning(
"llm: HTTP %s (attempt %d/%d), backing off %.1fs",
e.status_code,
attempt + 1,
max_retries + 1,
wait,
)
time.sleep(wait)
continue
# Circuit-breaker-lite: исчерпали ретраи — не «молотим», деградируем.
logger.warning("llm: rate-limited after %d attempts → fallback", max_retries + 1)
return LLMResult.fallback("rate_limited")
except LLMProviderError as e:
# Таймаут (LLMTimeoutError) и прочие провайдер-ошибки: НЕ ретраим (таймаут уже
# «съел» бюджет времени; сетевые/4xx обычно не лечатся повтором тут).
reason = "timeout" if e.__class__.__name__ == "LLMTimeoutError" else "provider_error"
logger.warning("llm: provider error (%s) → fallback: %s", reason, e)
return LLMResult.fallback(reason)
# Успех — лог-хук оценки стоимости (tokens→$), без содержимого ответа.
cost = _estimate_cost_usd(resp.prompt_tokens, resp.completion_tokens)
logger.info(
"llm: ok model=%s prompt_tokens=%d completion_tokens=%d est_cost_usd=%.5f",
resp.model,
resp.prompt_tokens,
resp.completion_tokens,
cost,
)
if settings.llm_daily_cost_cap_usd is not None:
# Сейчас только наблюдаемость: логируем приближение к потолку. Жёсткий
# enforcement (агрегация по дню) добавит консьюмер при необходимости.
logger.debug(
"llm: per-call est_cost_usd=%.5f (daily cap configured=%.2f)",
cost,
settings.llm_daily_cost_cap_usd,
)
return LLMResult.from_provider(resp)
# Недостижимо (цикл всегда return'ит), но mypy/читатель спокойнее с явным fallback.
return LLMResult.fallback(last_reason)
def complete_or_raise(
*,
system_prompt: str,
payload: SafePayload,
tools: list[dict[str, Any]] | None = None,
provider: LLMProvider | None = None,
call_index: int = 0,
) -> LLMResult:
"""Как ``complete``, но при недоступности LLM бросает ``LLMUnavailableError``.
Для вызывающих, которым удобнее try/except, чем проверка ``LLMResult.ok``. Семантика
fallback та же просто упакована в исключение (вызывающий ловит и подставляет детерм.).
"""
result = complete(
system_prompt=system_prompt,
payload=payload,
tools=tools,
provider=provider,
call_index=call_index,
)
if not result.ok:
raise LLMUnavailableError(result.reason or "llm_unavailable")
return result
__all__ = ["LLMResult", "LLMUnavailableError", "complete", "complete_or_raise"]

View file

@ -0,0 +1,76 @@
"""Minimal versioned prompt-template scaffolding (#960).
Полная библиотека промптов приедет вместе с консьюмерами (#956 граддок-extraction,
#957 chat). Здесь — только каркас: версионированные шаблоны + ``render`` с явной
подстановкой переменных. Шаблоны намеренно НЕ содержат данных данные вставляются
вызывающим в ``SafePayload`` (см. redaction), а не зашиваются в prompt-литералы.
Версионирование: ключ шаблона включает версию (``name@vN``), чтобы изменение
формулировки было трассируемым (важно для воспроизводимости LLM-вывода).
"""
from __future__ import annotations
from dataclasses import dataclass
class PromptNotFoundError(KeyError):
"""Запрошен неизвестный template_name."""
@dataclass(frozen=True, slots=True)
class PromptTemplate:
"""Версионированный шаблон. ``required_vars`` валидируется в render."""
name: str
version: int
template: str
required_vars: tuple[str, ...] = ()
@property
def key(self) -> str:
return f"{self.name}@v{self.version}"
# Системный шаблон-заглушка. Без данных — только инструкция-роль. Консьюмеры
# добавят свои предметные шаблоны (extraction-schema / chat-system) здесь же.
_SYSTEM_BASE = PromptTemplate(
name="system_base",
version=1,
template=(
"Ты ассистент в системе анализа недвижимости. Отвечай кратко и по делу. "
"Не выдумывай факты — если данных недостаточно, скажи об этом."
),
)
_TEMPLATES: dict[str, PromptTemplate] = {
_SYSTEM_BASE.name: _SYSTEM_BASE,
}
def get_template(name: str) -> PromptTemplate:
"""Вернуть шаблон по имени (последняя версия). PromptNotFoundError если нет."""
tpl = _TEMPLATES.get(name)
if tpl is None:
raise PromptNotFoundError(name)
return tpl
def render(template_name: str, **vars: object) -> str:
"""Отрендерить шаблон по имени с подстановкой переменных.
Использует ``str.format`` (не f-string/concat) placeholder'ы вида ``{var}``.
Проверяет, что все ``required_vars`` переданы.
Raises:
PromptNotFoundError: неизвестное имя.
KeyError: отсутствует обязательная переменная или placeholder без значения.
"""
tpl = get_template(template_name)
missing = [v for v in tpl.required_vars if v not in vars]
if missing:
raise KeyError(f"prompt '{tpl.key}' missing required vars: {', '.join(missing)}")
return tpl.template.format(**vars)
__all__ = ["PromptNotFoundError", "PromptTemplate", "get_template", "render"]

View file

@ -0,0 +1,224 @@
"""LLM provider abstraction (#960) — внешний OpenAI + шов под RU-hosted.
``LLMProvider`` абстрактная база, чтобы будущий RU-hosted провайдер
(GigaChat/YandexGPT данные остаются в РФ) можно было добавить, НЕ трогая
консьюмеров и клиент. Ключевой шов свойство ``is_external``:
- ``OpenAIProvider.is_external == True`` клиент ОБЯЗАН прогнать нагрузку через
redaction перед вызовом (см. client.complete).
- будущий ``GigaChatProvider.is_external == False`` redaction можно ослабить.
HTTP raw ``httpx`` (без openai-SDK: новая зависимость = pyproject + uv.lock и
abort деплоя на lock-drift; httpx уже backend-mandated клиент). Поддержан
tool/function-calling pass-through: ``tools`` в запросе, ``tool_calls`` в ответе.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class LLMProviderError(Exception):
"""Базовая ошибка провайдера (сеть/HTTP/парсинг). Ловится клиентом → fallback."""
class LLMTimeoutError(LLMProviderError):
"""Провайдер не ответил в пределах таймаута."""
class LLMRateLimitedError(LLMProviderError):
"""429/5xx от провайдера (после исчерпания ретраев на уровне провайдера)."""
def __init__(self, message: str, *, status_code: int, retry_after: float | None = None) -> None:
super().__init__(message)
self.status_code = status_code
self.retry_after = retry_after
@dataclass(frozen=True, slots=True)
class ToolCall:
"""Распарсенный tool/function call из ответа модели (pass-through, без exec)."""
id: str
name: str
arguments: str # сырой JSON-строкой как отдаёт OpenAI; парсит вызывающий
@dataclass(frozen=True, slots=True)
class ProviderResponse:
"""Сырой ответ провайдера (до обёртки в LLMResult клиентом)."""
content: str | None
tool_calls: list[ToolCall] = field(default_factory=list)
finish_reason: str | None = None
prompt_tokens: int = 0
completion_tokens: int = 0
model: str = ""
class LLMProvider(ABC):
"""Абстрактный провайдер. Реализации: OpenAIProvider (external) + future RU-hosted."""
@property
@abstractmethod
def is_external(self) -> bool:
"""True если провайдер выгружает данные за пределы РФ (=> redaction mandatory)."""
@property
@abstractmethod
def model(self) -> str:
"""Идентификатор модели по умолчанию (для логов/оценки стоимости)."""
@abstractmethod
def complete(
self,
messages: list[dict[str, Any]],
*,
tools: list[dict[str, Any]] | None = None,
max_output_tokens: int,
) -> ProviderResponse:
"""Один синхронный вызов chat-completions. Без ретраев/таймаута-логики выше —
это делает client.complete (guardrails). Поднимает ``LLMProviderError`` на сбое.
"""
# ── OpenAI (external) ─────────────────────────────────────────────────────────
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
choices = data.get("choices") or []
if not choices:
raise LLMProviderError("OpenAI response has no choices")
choice = choices[0] or {}
message = choice.get("message") or {}
raw_tool_calls = message.get("tool_calls") or []
tool_calls: list[ToolCall] = []
for tc in raw_tool_calls:
fn = (tc or {}).get("function") or {}
tool_calls.append(
ToolCall(
id=str(tc.get("id", "")),
name=str(fn.get("name", "")),
arguments=str(fn.get("arguments", "")),
)
)
usage = data.get("usage") or {}
return ProviderResponse(
content=message.get("content"),
tool_calls=tool_calls,
finish_reason=choice.get("finish_reason"),
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
model=str(data.get("model") or fallback_model),
)
class OpenAIProvider(LLMProvider):
"""OpenAI Chat Completions через raw httpx. ВНЕШНИЙ → redaction обязателен.
Ключ передаётся в конструктор вызывающим (client читает его из settings); сам
провайдер ключ нигде не логирует. Stateless относительно данных; один instance
на вызов достаточно дёшев (httpx.Client создаётся на запрос простота поверх
пула; консьюмеры низкочастотные).
"""
def __init__(
self,
*,
api_key: str,
model: str,
base_url: str = "https://api.openai.com/v1",
timeout_s: float = 30.0,
) -> None:
self._api_key = api_key
self._model = model
self._base_url = base_url.rstrip("/")
self._timeout_s = timeout_s
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return self._model
def _post(self, body: dict[str, Any]) -> dict[str, Any]:
"""POST {base_url}/chat/completions. Вынесен отдельно для monkeypatch в тестах.
Ключ кладётся в заголовок Authorization и НИКОГДА не логируется. Поднимает
типизированные LLM*-ошибки без ретраев (их делает client.complete).
"""
url = f"{self._base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
try:
with httpx.Client(timeout=self._timeout_s) as client:
resp = client.post(url, headers=headers, json=body)
except httpx.TimeoutException as e:
raise LLMTimeoutError(f"OpenAI request timed out after {self._timeout_s}s") from e
except httpx.HTTPError as e:
raise LLMProviderError(f"OpenAI network error: {e}") from e
if resp.status_code == 429 or resp.status_code >= 500:
retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
raise LLMRateLimitedError(
f"OpenAI HTTP {resp.status_code}",
status_code=resp.status_code,
retry_after=retry_after,
)
if resp.status_code != 200:
# 4xx (кроме 429): тело может содержать описание ошибки, но НЕ наши данные
# эха целиком — берём короткий префикс. Ключ в теле ответа не возвращается.
raise LLMProviderError(f"OpenAI HTTP {resp.status_code}: {resp.text[:200]}")
try:
data: dict[str, Any] = resp.json()
except ValueError as e:
raise LLMProviderError(f"OpenAI returned non-JSON body: {e}") from e
return data
def complete(
self,
messages: list[dict[str, Any]],
*,
tools: list[dict[str, Any]] | None = None,
max_output_tokens: int,
) -> ProviderResponse:
body: dict[str, Any] = {
"model": self._model,
"messages": messages,
"max_tokens": max_output_tokens,
}
if tools:
# Clean pass-through tool-calling: прокидываем tools как есть, без agent-loop.
body["tools"] = tools
body["tool_choice"] = "auto"
data = self._post(body)
return _parse_openai_response(data, fallback_model=self._model)
def _parse_retry_after(value: str | None) -> float | None:
"""Распарсить заголовок Retry-After (только числовая форма в секундах)."""
if value and value.isdigit():
return float(value)
return None
__all__ = [
"LLMProvider",
"LLMProviderError",
"LLMRateLimitedError",
"LLMTimeoutError",
"OpenAIProvider",
"ProviderResponse",
"ToolCall",
]

View file

@ -0,0 +1,155 @@
"""§19 data-residency redaction layer (#960) — THE critical safety boundary.
Провайдер LLM ВНЕШНИЙ (OpenAI) данные покидают РФ. Чувствительные данные не
должны попасть наружу НИКОГДА. Защита эшелонирована (defense-in-depth):
1. Allowlist / safe-payload (ПЕРВИЧНОЕ): публичный API заставляет вызывающего
явно собрать ``SafePayload`` из не-чувствительных полей/агрегатов/публичного
текста а НЕ передавать сырые строки БД. См. ``SafePayload`` ниже и контракт
в ``app/services/llm/__init__.py``.
2. Hard-block (ENFORCED): ``SafePayload(is_confidential=True)`` ``RedactionRefusedError``.
Контент, помеченный конфиденциальным (напр. insight.is_confidential), НИКОГДА
не уходит во внешнего провайдера клиент поднимает типизированную ошибку, а не
«тихо» отправляет.
3. Regex-scrub (ВТОРИЧНОЕ, belt-and-suspenders): из свободного текста вырезаются
RU PII-паттерны (телефон / email / СНИЛС / ИНН / паспорт / ФИО-подобное) и
заменяются на ``[REDACTED:<kind>]``. Это НЕ основная гарантия основная это (1).
``scrub_safe_payload`` единственная точка, через которую ``client.complete``
прогоняет данные перед внешним вызовом (mandatory на external-пути; будущий
RU-hosted провайдер сможет это ослабить см. шов ``LLMProvider.is_external``).
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
class RedactionRefusedError(Exception):
"""Полезная нагрузка помечена конфиденциальной → отправка наружу запрещена.
Поднимается из ``scrub_safe_payload`` для external-провайдера, когда
``SafePayload.is_confidential`` is True. Клиент ловит это и превращает в
детерминированный fallback (система не падает из-за того что данные нельзя слать).
"""
@dataclass(frozen=True, slots=True)
class SafePayload:
"""Явно собранная вызывающим НЕ-чувствительная нагрузка для внешней LLM.
Контракт (allowlist-first): консьюмеры (#956 граддок-extraction, #957 chat)
ОБЯЗАНЫ конструировать это вручную из проверенных полей НЕ передавать сырые
DB-строки/insight'ы. Структура — единственное, что физически доходит до клиента.
Attributes:
text: Свободный текст для модели (публичный градрегламент, агрегаты, вопрос
пользователя). Прогоняется через regex-scrub перед отправкой.
fields: Структурированные не-чувствительные пары ключзначение (числа/коды/
короткие строки). Значения-строки тоже скрабятся.
is_confidential: Если True вся нагрузка под жёстким блоком: ``scrub_safe_payload``
для external-провайдера поднимет ``RedactionRefusedError``. Прокидывать сюда
ЛЮБОЙ confidential-флаг источника (напр. insight.is_confidential).
"""
text: str = ""
fields: dict[str, Any] = field(default_factory=dict)
is_confidential: bool = False
# ── RU PII regex (вторичная защита) ──────────────────────────────────────────
# Порядок применения важен: более специфичные/длинные паттерны раньше, чтобы они не
# «съедались» более общими (СНИЛС/ИНН/паспорт раньше «голых» групп цифр).
# СНИЛС: NNN-NNN-NNN NN (последняя пара — через пробел/дефис).
_SNILS_RE = re.compile(r"\b\d{3}-\d{3}-\d{3}[\s-]\d{2}\b")
# Паспорт РФ: NNNN NNNNNN (серия 4 + номер 6, разделитель пробел/дефис).
_PASSPORT_RE = re.compile(r"\b\d{4}[\s-]\d{6}\b")
# Телефон РФ: +7/8, опц. скобки/пробелы/дефисы, 10 значащих цифр.
_PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\d{2}\b")
# Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
# ФИО-подобное: три слова с заглавной кириллической буквы подряд (Фамилия Имя Отчество).
# Грубая эвристика — может задеть «Министерство Строительства России» и подобное; это
# приемлемо для вторичной защиты (лучше лишний REDACTED, чем утечка ФИО).
_FULLNAME_RE = re.compile(r"\b[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+\b")
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(_SNILS_RE, "snils"),
(_PASSPORT_RE, "passport"),
(_PHONE_RE, "phone"),
(_EMAIL_RE, "email"),
(_INN_RE, "inn"),
(_FULLNAME_RE, "name"),
)
def scrub_text(value: str) -> str:
"""Вырезать RU PII из свободного текста → ``[REDACTED:<kind>]``.
Вторичная защита (см. модульный docstring). НИКОГДА не логирует ни исходный, ни
результирующий текст только факт срабатывания и kind (без значения).
"""
if not value:
return value
redacted = value
for pattern, kind in _PII_PATTERNS:
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
if n:
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
return redacted
def _scrub_value(value: Any) -> Any:
"""Рекурсивно проскрабить строковые значения в структуре fields."""
if isinstance(value, str):
return scrub_text(value)
if isinstance(value, dict):
return {k: _scrub_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
scrubbed = [_scrub_value(v) for v in value]
return type(value)(scrubbed)
return value
def scrub_safe_payload(payload: SafePayload, *, is_external: bool) -> SafePayload:
"""Mandatory-шаг перед внешним вызовом: hard-block + regex-scrub.
Args:
payload: явно собранная вызывающим нагрузка.
is_external: True для провайдера, выгружающего данные за пределы РФ
(OpenAI). Для будущего RU-hosted провайдера можно передать False, чтобы
ослабить scrub это и есть задокументированный шов (data остаётся в РФ).
Returns:
Новый ``SafePayload`` с проскрабленными ``text``/``fields``.
Raises:
RedactionRefusedError: если ``is_external`` и ``payload.is_confidential``.
"""
if is_external and payload.is_confidential:
# НЕ логируем содержимое — только факт отказа.
logger.warning("redaction: refusing confidential payload for external LLM provider")
raise RedactionRefusedError(
"payload marked is_confidential=True cannot be sent to an external LLM provider"
)
if not is_external:
# RU-hosted провайдер: данные не покидают РФ — scrub не обязателен.
return payload
return SafePayload(
text=scrub_text(payload.text),
fields=_scrub_value(payload.fields),
is_confidential=payload.is_confidential,
)
__all__ = ["RedactionRefusedError", "SafePayload", "scrub_safe_payload", "scrub_text"]

View file

View file

@ -0,0 +1,288 @@
"""LLM client orchestration tests (#960).
Покрываем: disabledfallback (БЕЗ сетевой попытки), no-keyfallback, call-cap,
redaction hard-blockfallback, happy-path (тело запроса проскраблено),
guardrails (timeoutfallback, 429-retry-then-fallback), complete_or_raise.
Сеть НЕ дёргается: либо внедряем fake-провайдера, либо ставим tripwire на httpx.Client.
"""
from __future__ import annotations
from typing import Any
import pytest
from app.core.config import settings
from app.services.llm import client as llm_client
from app.services.llm.client import LLMResult, LLMUnavailableError, complete, complete_or_raise
from app.services.llm.provider import (
LLMProvider,
LLMRateLimitedError,
LLMTimeoutError,
ProviderResponse,
ToolCall,
)
from app.services.llm.redaction import SafePayload
# ── Fake провайдеры (внедряются через provider=...) ────────────────────────────
class _FakeOpenAILike(LLMProvider):
"""Внешний (is_external=True) fake: записывает messages, отдаёт фикс. ответ."""
def __init__(self, response: ProviderResponse | None = None) -> None:
self.captured_messages: list[dict[str, Any]] | None = None
self.captured_tools: list[dict[str, Any]] | None = None
self.calls = 0
self._response = response or ProviderResponse(
content="ответ модели", prompt_tokens=10, completion_tokens=5, model="gpt-4o-mini"
)
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(
self,
messages: list[dict[str, Any]],
*,
tools: list[dict[str, Any]] | None = None,
max_output_tokens: int,
) -> ProviderResponse:
self.calls += 1
self.captured_messages = messages
self.captured_tools = tools
return self._response
class _RaisingProvider(LLMProvider):
"""Внешний fake, всегда бросающий заданное исключение (для guardrail-тестов)."""
def __init__(self, exc: Exception) -> None:
self._exc = exc
self.calls = 0
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(self, messages: Any, *, tools: Any = None, max_output_tokens: int) -> Any:
self.calls += 1
raise self._exc
@pytest.fixture
def _enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""Включить LLM с fake-ключом (без реальных секретов)."""
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
# ── Guard #1/#2: disabled / no key ─────────────────────────────────────────────
def test_disabled_returns_fallback_without_network(monkeypatch: pytest.MonkeyPatch) -> None:
"""llm_enabled=False → fallback, и НИ ОДНОЙ попытки создать httpx.Client."""
monkeypatch.setattr(settings, "llm_enabled", False)
def _tripwire(*a: Any, **k: Any) -> None:
raise AssertionError("network must NOT be touched when llm_enabled=False")
# Любая попытка сетевого вызова через httpx завалит тест.
monkeypatch.setattr("app.services.llm.provider.httpx.Client", _tripwire)
res = complete(system_prompt="sys", payload=SafePayload(text="hi"))
assert res.ok is False
assert res.fallback_used is True
assert res.reason == "disabled"
def test_enabled_but_no_key_returns_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""llm_enabled=True но ключ None → fallback (no_api_key), без сети."""
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr(settings, "openai_api_key", None)
def _tripwire(*a: Any, **k: Any) -> None:
raise AssertionError("no network without a key")
monkeypatch.setattr("app.services.llm.provider.httpx.Client", _tripwire)
res = complete(system_prompt="sys", payload=SafePayload(text="hi"))
assert res.reason == "no_api_key"
assert res.fallback_used is True
# ── Call cap ───────────────────────────────────────────────────────────────────
def test_call_cap_returns_fallback(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""call_index >= llm_max_calls_per_request → fallback, провайдер не вызывается."""
monkeypatch.setattr(settings, "llm_max_calls_per_request", 2)
prov = _FakeOpenAILike()
res = complete(
system_prompt="sys", payload=SafePayload(text="hi"), provider=prov, call_index=2
)
assert res.reason == "call_cap"
assert prov.calls == 0
# ── Redaction integration ──────────────────────────────────────────────────────
def test_confidential_payload_returns_fallback(_enabled: None) -> None:
"""is_confidential=True (external) → fallback redaction_refused, провайдер не зван."""
prov = _FakeOpenAILike()
res = complete(
system_prompt="sys",
payload=SafePayload(text="секрет", is_confidential=True),
provider=prov,
)
assert res.reason == "redaction_refused"
assert prov.calls == 0
def test_request_body_is_redacted(_enabled: None) -> None:
"""ГЛАВНОЕ: тело запроса к провайдеру НЕ содержит сырого PII — оно проскраблено."""
prov = _FakeOpenAILike()
payload = SafePayload(
text="Свяжитесь +7 912 345 67 89 или mail@example.ru",
fields={"owner": "Иванов Иван Иванович", "zone": "Ц-1"},
)
res = complete(system_prompt="Ты ассистент", payload=payload, provider=prov)
assert res.ok is True
assert prov.captured_messages is not None
blob = str(prov.captured_messages)
# Сырого PII в отправленных messages быть не должно.
assert "+7 912 345 67 89" not in blob
assert "mail@example.ru" not in blob
assert "Иванов Иван Иванович" not in blob
# Зато плейсхолдеры и безопасные поля — на месте.
assert "[REDACTED:phone]" in blob
assert "[REDACTED:email]" in blob
assert "[REDACTED:name]" in blob
assert "Ц-1" in blob
# ── Happy path ─────────────────────────────────────────────────────────────────
def test_happy_path_returns_ok_result(_enabled: None) -> None:
"""Успешный ответ → ok=True, content и токены проброшены."""
prov = _FakeOpenAILike(
ProviderResponse(
content="готово", prompt_tokens=12, completion_tokens=4, model="gpt-4o-mini"
)
)
res = complete(system_prompt="sys", payload=SafePayload(text="вопрос"), provider=prov)
assert res.ok is True
assert res.content == "готово"
assert res.prompt_tokens == 12
assert res.completion_tokens == 4
assert res.model == "gpt-4o-mini"
def test_tool_calls_passed_through(_enabled: None) -> None:
"""tool_calls из ответа провайдера прокидываются в LLMResult."""
prov = _FakeOpenAILike(
ProviderResponse(
content=None,
tool_calls=[ToolCall(id="c1", name="extract", arguments='{"x":1}')],
model="gpt-4o-mini",
)
)
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.ok is True
assert len(res.tool_calls) == 1
assert res.tool_calls[0].name == "extract"
def test_tools_forwarded_to_provider(_enabled: None) -> None:
"""tools прокидываются в provider.complete."""
prov = _FakeOpenAILike()
tools = [{"type": "function", "function": {"name": "f"}}]
complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov, tools=tools)
assert prov.captured_tools == tools
# ── Guardrails: timeout / rate-limit / retries ─────────────────────────────────
def test_timeout_degrades_to_fallback(_enabled: None) -> None:
"""LLMTimeoutError → fallback(timeout), без ретраев (1 вызов)."""
prov = _RaisingProvider(LLMTimeoutError("slow"))
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.reason == "timeout"
assert res.fallback_used is True
assert prov.calls == 1
def test_rate_limited_retries_then_fallback(
_enabled: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""429 на всех попытках → ретраи (cap) затем fallback(rate_limited). sleep замокан."""
monkeypatch.setattr(settings, "llm_max_retries", 2)
# Не спим в тесте.
monkeypatch.setattr(llm_client.time, "sleep", lambda s: None)
prov = _RaisingProvider(LLMRateLimitedError("429", status_code=429, retry_after=None))
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.reason == "rate_limited"
assert res.fallback_used is True
# 1 первичный + 2 ретрая = 3 вызова.
assert prov.calls == 3
def test_rate_limited_then_success(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""429 один раз, затем успех → ok (ретрай сработал)."""
monkeypatch.setattr(settings, "llm_max_retries", 2)
monkeypatch.setattr(llm_client.time, "sleep", lambda s: None)
class _FlakyProvider(LLMProvider):
def __init__(self) -> None:
self.calls = 0
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(self, messages: Any, *, tools: Any = None, max_output_tokens: int) -> Any:
self.calls += 1
if self.calls == 1:
raise LLMRateLimitedError("429", status_code=429, retry_after=None)
return ProviderResponse(content="ок после ретрая", model="gpt-4o-mini")
prov = _FlakyProvider()
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.ok is True
assert res.content == "ок после ретрая"
assert prov.calls == 2
# ── complete_or_raise ──────────────────────────────────────────────────────────
def test_complete_or_raise_raises_on_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""complete_or_raise бросает LLMUnavailableError когда LLM недоступен (disabled)."""
monkeypatch.setattr(settings, "llm_enabled", False)
with pytest.raises(LLMUnavailableError):
complete_or_raise(system_prompt="sys", payload=SafePayload(text="hi"))
def test_complete_or_raise_returns_on_success(_enabled: None) -> None:
prov = _FakeOpenAILike(ProviderResponse(content="ok", model="gpt-4o-mini"))
res = complete_or_raise(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert isinstance(res, LLMResult)
assert res.ok is True

View file

@ -0,0 +1,40 @@
"""Config defaults для LLM-инфраструктуры (#960).
Критично: llm_enabled СТРОГО False по умолчанию, ключ None. Это гарантирует, что
прод не позвонит в OpenAI до осознанного включения.
"""
from __future__ import annotations
from app.core.config import Settings
def test_llm_disabled_by_default() -> None:
"""llm_enabled default False — прод не делает сетевых вызовов случайно."""
s = Settings(_env_file=None) # type: ignore[call-arg] # игнор .env при проверке дефолтов
assert s.llm_enabled is False
def test_openai_api_key_none_by_default() -> None:
"""Ключ не зашит — None по умолчанию (читается только из env OPENAI_API_KEY)."""
s = Settings(_env_file=None) # type: ignore[call-arg]
assert s.openai_api_key is None
def test_llm_safe_defaults() -> None:
"""Прочие дефолты безопасны: внешний base_url, конечные таймаут/лимиты, cap=None."""
s = Settings(_env_file=None) # type: ignore[call-arg]
assert s.llm_model == "gpt-4o-mini"
assert s.llm_base_url == "https://api.openai.com/v1"
assert s.llm_timeout_s > 0
assert s.llm_max_output_tokens > 0
assert s.llm_max_calls_per_request >= 1
assert s.llm_daily_cost_cap_usd is None
assert s.llm_max_retries >= 0
def test_openai_api_key_reads_from_env(monkeypatch) -> None:
"""OPENAI_API_KEY из окружения мапится в settings.openai_api_key (fake-ключ)."""
monkeypatch.setenv("OPENAI_API_KEY", "test-fake-key-not-real")
s = Settings(_env_file=None) # type: ignore[call-arg]
assert s.openai_api_key == "test-fake-key-not-real"

View file

@ -0,0 +1,47 @@
"""Prompt-scaffolding tests (#960) — render, версионирование, отсутствие шаблона."""
from __future__ import annotations
import pytest
from app.services.llm.prompts import (
PromptNotFoundError,
PromptTemplate,
get_template,
render,
)
def test_get_known_template() -> None:
tpl = get_template("system_base")
assert tpl.name == "system_base"
assert tpl.version == 1
assert tpl.key == "system_base@v1"
def test_get_unknown_template_raises() -> None:
with pytest.raises(PromptNotFoundError):
get_template("does_not_exist")
def test_render_known_template() -> None:
out = render("system_base")
assert "ассистент" in out
def test_render_with_required_vars() -> None:
"""render подставляет переменные через str.format и валидирует required_vars."""
tpl = PromptTemplate(
name="probe",
version=1,
template="zone={zone}",
required_vars=("zone",),
)
# Прямой рендер шаблона (не из реестра) — проверяем format-механику.
assert tpl.template.format(zone="Ц-1") == "zone=Ц-1"
def test_render_missing_required_var_raises() -> None:
"""Если шаблон требует переменную, а её нет → KeyError (для зарегистрированного)."""
with pytest.raises(PromptNotFoundError):
render("missing_template", zone="Ц-1")

View file

@ -0,0 +1,204 @@
"""OpenAIProvider tests (#960) — request/response shape, tool-calling, error mapping.
Сеть НЕ дёргается: для shape-тестов мокаем OpenAIProvider._post; для error-mapping
httpx.MockTransport (проверяем разбор статусов/таймаута на реальном httpx-слое). Ключ fake.
"""
from __future__ import annotations
from typing import Any
import httpx
import pytest
from app.services.llm.provider import (
LLMProviderError,
LLMRateLimitedError,
LLMTimeoutError,
OpenAIProvider,
)
_FAKE_KEY = "test-fake-key-not-real"
# Реальный конструктор сохраняем ДО патча: иначе фабрика, дёргая httpx.Client,
# попадёт в саму себя (provider делает `import httpx`, поэтому патчим module-attr).
_REAL_HTTPX_CLIENT = httpx.Client
def _install_mock_transport(
monkeypatch: pytest.MonkeyPatch,
handler: Any,
) -> None:
"""Подменить httpx.Client в provider на клиент с MockTransport (без сети)."""
transport = httpx.MockTransport(handler)
def factory(**kw: Any) -> httpx.Client:
kw.pop("transport", None)
return _REAL_HTTPX_CLIENT(transport=transport, **kw)
monkeypatch.setattr("app.services.llm.provider.httpx.Client", factory)
def _provider() -> OpenAIProvider:
return OpenAIProvider(api_key=_FAKE_KEY, model="gpt-4o-mini")
def _chat_response(content: str = "привет") -> dict[str, Any]:
return {
"model": "gpt-4o-mini",
"choices": [
{"message": {"role": "assistant", "content": content}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
}
def _tool_call_response() -> dict[str, Any]:
return {
"model": "gpt-4o-mini",
"choices": [
{
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "extract_floors",
"arguments": '{"floors": 16}',
},
}
],
},
"finish_reason": "tool_calls",
}
],
"usage": {"prompt_tokens": 20, "completion_tokens": 8},
}
def test_complete_builds_request_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""complete формирует корректное тело: model, messages, max_tokens."""
captured: dict[str, Any] = {}
def fake_post(self: OpenAIProvider, body: dict[str, Any]) -> dict[str, Any]:
captured["body"] = body
return _chat_response()
monkeypatch.setattr(OpenAIProvider, "_post", fake_post)
resp = _provider().complete(
[{"role": "user", "content": "привет"}], max_output_tokens=256
)
assert captured["body"]["model"] == "gpt-4o-mini"
assert captured["body"]["max_tokens"] == 256
assert captured["body"]["messages"][0]["content"] == "привет"
# Без tools — поля tools/tool_choice не добавляются.
assert "tools" not in captured["body"]
assert resp.content == "привет"
assert resp.prompt_tokens == 10
assert resp.completion_tokens == 5
def test_complete_passes_tools_through(monkeypatch: pytest.MonkeyPatch) -> None:
"""tools прокидываются в тело как есть + tool_choice=auto (clean pass-through)."""
captured: dict[str, Any] = {}
def fake_post(self: OpenAIProvider, body: dict[str, Any]) -> dict[str, Any]:
captured["body"] = body
return _tool_call_response()
monkeypatch.setattr(OpenAIProvider, "_post", fake_post)
tools = [
{
"type": "function",
"function": {
"name": "extract_floors",
"parameters": {"type": "object", "properties": {"floors": {"type": "integer"}}},
},
}
]
resp = _provider().complete(
[{"role": "user", "content": "сколько этажей"}],
tools=tools,
max_output_tokens=256,
)
assert captured["body"]["tools"] == tools
assert captured["body"]["tool_choice"] == "auto"
# Ответ tool_calls распарсен.
assert resp.content is None
assert len(resp.tool_calls) == 1
assert resp.tool_calls[0].name == "extract_floors"
assert resp.tool_calls[0].arguments == '{"floors": 16}'
assert resp.tool_calls[0].id == "call_1"
def test_post_maps_429_to_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None:
"""429 → LLMRateLimitedError со status_code и retry_after из заголовка."""
def fake_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(429, headers={"Retry-After": "7"}, json={"error": "rate"})
_install_mock_transport(monkeypatch, fake_handler)
with pytest.raises(LLMRateLimitedError) as ei:
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
assert ei.value.status_code == 429
assert ei.value.retry_after == 7.0
def test_post_maps_500_to_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None:
"""5xx → LLMRateLimitedError (ретраебельно на уровне клиента)."""
def fake_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, json={"error": "unavailable"})
_install_mock_transport(monkeypatch, fake_handler)
with pytest.raises(LLMRateLimitedError) as ei:
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
assert ei.value.status_code == 503
def test_post_maps_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
"""httpx.TimeoutException → LLMTimeoutError."""
def fake_handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("slow", request=request)
_install_mock_transport(monkeypatch, fake_handler)
with pytest.raises(LLMTimeoutError):
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
def test_post_maps_4xx_to_provider_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""400 → LLMProviderError (не ретраебельно)."""
def fake_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(400, json={"error": "bad request"})
_install_mock_transport(monkeypatch, fake_handler)
with pytest.raises(LLMProviderError):
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
def test_post_sends_bearer_key_in_header(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ключ уходит в Authorization: Bearer — и НЕ попадает в тело запроса."""
seen: dict[str, Any] = {}
def fake_handler(request: httpx.Request) -> httpx.Response:
seen["auth"] = request.headers.get("Authorization")
seen["body"] = request.content.decode()
return httpx.Response(200, json=_chat_response())
_install_mock_transport(monkeypatch, fake_handler)
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
assert seen["auth"] == f"Bearer {_FAKE_KEY}"
# Ключ не должен оказаться в JSON-теле.
assert _FAKE_KEY not in seen["body"]
def test_is_external_true() -> None:
"""OpenAIProvider — внешний (redaction mandatory)."""
assert _provider().is_external is True

View file

@ -0,0 +1,154 @@
"""§19 redaction tests (#960) — самый важный слой безопасности.
Покрываем: каждый тип RU PII, hard-block is_confidential, allowlist-поведение
(SafePayload как единственный вход), и шов RU-hosted провайдера (is_external=False).
"""
from __future__ import annotations
import pytest
from app.services.llm.redaction import (
RedactionRefusedError,
SafePayload,
scrub_safe_payload,
scrub_text,
)
# ── Per-PII-type scrub ────────────────────────────────────────────────────────
def test_scrub_phone_ru() -> None:
out = scrub_text("Звоните +7 (912) 345-67-89 срочно")
assert "[REDACTED:phone]" in out
assert "912" not in out
def test_scrub_phone_8_prefix() -> None:
out = scrub_text("тел 8 912 345 67 89")
assert "[REDACTED:phone]" in out
assert "345" not in out
def test_scrub_email() -> None:
out = scrub_text("пишите ivan.petrov@example.ru пожалуйста")
assert "[REDACTED:email]" in out
assert "example.ru" not in out
def test_scrub_snils() -> None:
out = scrub_text("СНИЛС 112-233-445 95 в деле")
assert "[REDACTED:snils]" in out
assert "112-233-445" not in out
def test_scrub_inn_12() -> None:
out = scrub_text("ИНН физлица 500100732259 проверен")
assert "[REDACTED:inn]" in out
assert "500100732259" not in out
def test_scrub_inn_10() -> None:
out = scrub_text("ИНН юрлица 7707083893 в ЕГРЮЛ")
assert "[REDACTED:inn]" in out
assert "7707083893" not in out
def test_scrub_passport() -> None:
out = scrub_text("паспорт 6512 123456 выдан")
assert "[REDACTED:passport]" in out
assert "123456" not in out
def test_scrub_fullname() -> None:
out = scrub_text("ответственный Иванов Иван Иванович подписал")
assert "[REDACTED:name]" in out
assert "Иванович" not in out
def test_scrub_multiple_pii_in_one_text() -> None:
out = scrub_text("Иванов Иван Иванович, +7 912 345 67 89, mail@x.ru")
assert "[REDACTED:name]" in out
assert "[REDACTED:phone]" in out
assert "[REDACTED:email]" in out
def test_scrub_preserves_safe_text() -> None:
"""Безопасный текст (без PII) не трогается."""
safe = "Зона Ц-1, максимальная этажность 16, процент застройки 60."
assert scrub_text(safe) == safe
def test_scrub_empty_string_noop() -> None:
assert scrub_text("") == ""
# ── Hard-block: is_confidential ───────────────────────────────────────────────
def test_confidential_payload_refused_for_external() -> None:
"""is_confidential=True + external провайдер → RedactionRefusedError (НЕ отправляем)."""
payload = SafePayload(text="секретная аналитика", is_confidential=True)
with pytest.raises(RedactionRefusedError):
scrub_safe_payload(payload, is_external=True)
def test_non_confidential_payload_passes_external() -> None:
"""is_confidential=False → проходит (и скрабится)."""
payload = SafePayload(text="публичный текст про зону Ц-1", is_confidential=False)
out = scrub_safe_payload(payload, is_external=True)
assert out.text == "публичный текст про зону Ц-1"
assert out.is_confidential is False
# ── Scrub применяется к text И fields на external-пути ─────────────────────────
def test_scrub_safe_payload_scrubs_text_and_fields() -> None:
payload = SafePayload(
text="контакт +7 912 345 67 89",
fields={"note": "почта a@b.ru", "floors": 16, "code": "Ц-1"},
)
out = scrub_safe_payload(payload, is_external=True)
assert "[REDACTED:phone]" in out.text
assert "[REDACTED:email]" in out.fields["note"]
# Не-строковые и безопасные значения сохранены.
assert out.fields["floors"] == 16
assert out.fields["code"] == "Ц-1"
def test_scrub_nested_fields() -> None:
"""Рекурсивный scrub вложенных list/dict в fields."""
payload = SafePayload(fields={"items": [{"phone": "+7 912 345 67 89"}, "a@b.ru"]})
out = scrub_safe_payload(payload, is_external=True)
assert "[REDACTED:phone]" in out.fields["items"][0]["phone"]
assert "[REDACTED:email]" in out.fields["items"][1]
# ── RU-hosted seam: is_external=False ─────────────────────────────────────────
def test_ru_hosted_provider_skips_scrub_and_allows_confidential() -> None:
"""is_external=False (будущий RU-hosted): scrub не обязателен, confidential проходит.
Данные не покидают РФ §19 не нарушается. Это задокументированный шов.
"""
payload = SafePayload(
text="контакт +7 912 345 67 89",
is_confidential=True,
)
out = scrub_safe_payload(payload, is_external=False)
# Не бросает, текст НЕ скраблен (RU-провайдер может получить как есть).
assert out is payload
assert "+7 912 345 67 89" in out.text
# ── Allowlist contract: SafePayload — единственный вход ────────────────────────
def test_safe_payload_defaults_are_empty_and_non_confidential() -> None:
"""По умолчанию SafePayload пуст и не-конфиденциален — безопасный нейтральный старт."""
p = SafePayload()
assert p.text == ""
assert p.fields == {}
assert p.is_confidential is False