From 4af7ba5a40996820547485e2807e3d53cf4df10a Mon Sep 17 00:00:00 2001 From: Light1YT Date: Mon, 8 Jun 2026 15:44:16 +0500 Subject: [PATCH] =?UTF-8?q?feat(llm):=20foundational=20LLM=20infra=20packa?= =?UTF-8?q?ge=20with=20=C2=A719=20redaction=20+=20deterministic=20fallback?= =?UTF-8?q?=20(#960)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/core/config.py | 27 ++ backend/app/services/llm/__init__.py | 77 +++++ backend/app/services/llm/client.py | 283 ++++++++++++++++++ backend/app/services/llm/prompts.py | 76 +++++ backend/app/services/llm/provider.py | 224 +++++++++++++++ backend/app/services/llm/redaction.py | 155 ++++++++++ backend/tests/services/llm/__init__.py | 0 backend/tests/services/llm/test_client.py | 288 +++++++++++++++++++ backend/tests/services/llm/test_config.py | 40 +++ backend/tests/services/llm/test_prompts.py | 47 +++ backend/tests/services/llm/test_provider.py | 204 +++++++++++++ backend/tests/services/llm/test_redaction.py | 154 ++++++++++ 12 files changed, 1575 insertions(+) create mode 100644 backend/app/services/llm/__init__.py create mode 100644 backend/app/services/llm/client.py create mode 100644 backend/app/services/llm/prompts.py create mode 100644 backend/app/services/llm/provider.py create mode 100644 backend/app/services/llm/redaction.py create mode 100644 backend/tests/services/llm/__init__.py create mode 100644 backend/tests/services/llm/test_client.py create mode 100644 backend/tests/services/llm/test_config.py create mode 100644 backend/tests/services/llm/test_prompts.py create mode 100644 backend/tests/services/llm/test_provider.py create mode 100644 backend/tests/services/llm/test_redaction.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 723eb725..60f12a38 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/services/llm/__init__.py b/backend/app/services/llm/__init__.py new file mode 100644 index 00000000..96b0014a --- /dev/null +++ b/backend/app/services/llm/__init__.py @@ -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", +] diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py new file mode 100644 index 00000000..d7abdb3a --- /dev/null +++ b/backend/app/services/llm/client.py @@ -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"] diff --git a/backend/app/services/llm/prompts.py b/backend/app/services/llm/prompts.py new file mode 100644 index 00000000..eab74477 --- /dev/null +++ b/backend/app/services/llm/prompts.py @@ -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"] diff --git a/backend/app/services/llm/provider.py b/backend/app/services/llm/provider.py new file mode 100644 index 00000000..24be52f0 --- /dev/null +++ b/backend/app/services/llm/provider.py @@ -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", +] diff --git a/backend/app/services/llm/redaction.py b/backend/app/services/llm/redaction.py new file mode 100644 index 00000000..6d3c7ccd --- /dev/null +++ b/backend/app/services/llm/redaction.py @@ -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:]``. Это НЕ основная гарантия — основная это (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"(? str: + """Вырезать RU PII из свободного текста → ``[REDACTED:]``. + + Вторичная защита (см. модульный 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"] diff --git a/backend/tests/services/llm/__init__.py b/backend/tests/services/llm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/services/llm/test_client.py b/backend/tests/services/llm/test_client.py new file mode 100644 index 00000000..3043fbf2 --- /dev/null +++ b/backend/tests/services/llm/test_client.py @@ -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 diff --git a/backend/tests/services/llm/test_config.py b/backend/tests/services/llm/test_config.py new file mode 100644 index 00000000..38cbe954 --- /dev/null +++ b/backend/tests/services/llm/test_config.py @@ -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" diff --git a/backend/tests/services/llm/test_prompts.py b/backend/tests/services/llm/test_prompts.py new file mode 100644 index 00000000..45570a0a --- /dev/null +++ b/backend/tests/services/llm/test_prompts.py @@ -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") diff --git a/backend/tests/services/llm/test_provider.py b/backend/tests/services/llm/test_provider.py new file mode 100644 index 00000000..eccf3434 --- /dev/null +++ b/backend/tests/services/llm/test_provider.py @@ -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 diff --git a/backend/tests/services/llm/test_redaction.py b/backend/tests/services/llm/test_redaction.py new file mode 100644 index 00000000..683b7f51 --- /dev/null +++ b/backend/tests/services/llm/test_redaction.py @@ -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