feat(llm): foundational LLM infra package + §19 redaction (#960) #1168
12 changed files with 1575 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
|
|||
77
backend/app/services/llm/__init__.py
Normal file
77
backend/app/services/llm/__init__.py
Normal 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",
|
||||
]
|
||||
283
backend/app/services/llm/client.py
Normal file
283
backend/app/services/llm/client.py
Normal 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"]
|
||||
76
backend/app/services/llm/prompts.py
Normal file
76
backend/app/services/llm/prompts.py
Normal 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"]
|
||||
224
backend/app/services/llm/provider.py
Normal file
224
backend/app/services/llm/provider.py
Normal 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",
|
||||
]
|
||||
155
backend/app/services/llm/redaction.py
Normal file
155
backend/app/services/llm/redaction.py
Normal 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"]
|
||||
0
backend/tests/services/llm/__init__.py
Normal file
0
backend/tests/services/llm/__init__.py
Normal file
288
backend/tests/services/llm/test_client.py
Normal file
288
backend/tests/services/llm/test_client.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
"""LLM client orchestration tests (#960).
|
||||
|
||||
Покрываем: disabled→fallback (БЕЗ сетевой попытки), no-key→fallback, call-cap,
|
||||
redaction hard-block→fallback, happy-path (тело запроса проскраблено),
|
||||
guardrails (timeout→fallback, 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
|
||||
40
backend/tests/services/llm/test_config.py
Normal file
40
backend/tests/services/llm/test_config.py
Normal 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"
|
||||
47
backend/tests/services/llm/test_prompts.py
Normal file
47
backend/tests/services/llm/test_prompts.py
Normal 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")
|
||||
204
backend/tests/services/llm/test_provider.py
Normal file
204
backend/tests/services/llm/test_provider.py
Normal 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
|
||||
154
backend/tests/services/llm/test_redaction.py
Normal file
154
backend/tests/services/llm/test_redaction.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue