All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m41s
CI / backend-tests (push) Successful in 6m24s
CI / backend-tests (pull_request) Successful in 6m23s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m12s
Add the LLM prose-composition path for the parcel-forecast chat, layered over the deterministic Step-1 fallback which stays the safety net. - chat/tools.py: 5 read-only section tools (exec_summary, product_recommendation, forecast, risks, scenarios) — pure slices of the loaded report dict, no DB/ recompute, graceful on missing sections. market_now (raw analyze blob) and meta are deliberately NOT exposed -> highest-PII data cannot reach the LLM. - chat/safe_payload.py: the §19 gate — single place that builds the outbound SafePayload from a section-aggregate allowlist; honors is_confidential hard-block. - chat/orchestrator.py: manual tool-call loop with call-cap/termination, real grounded_in provenance; any LLMResult.ok=False (disabled/timeout/rate_limited/ redaction_refused/call_cap/provider_error/empty) degrades to the deterministic answer. - llm/prompts.py: versioned chat_system@v1 — answer only from sections, never fabricate numbers, advisory tone, decline out-of-scope. - api/v1/chat.py: branch on settings.llm_enabled; sync complete bridged via run_in_threadpool. Default-off -> deterministic path, no provider built. - Tests: fake provider only (no network), planted-secret redaction-boundary + per-reason fallback + call-cap + numbers-from-report coverage. Refs #957
85 lines
5.6 KiB
Python
85 lines
5.6 KiB
Python
"""§19 redaction boundary для чата (#957, Step 2/3) — ЕДИНСТВЕННАЯ точка сборки
|
||
исходящего ``SafePayload``.
|
||
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
КОНТРАКТ (читать ОБЯЗАТЕЛЬНО перед изменением) — allowlist-first, §19 data-residency
|
||
═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
Провайдер LLM ВНЕШНИЙ (OpenAI) → всё, что попадёт в этот ``SafePayload``, ПОКИНЕТ РФ.
|
||
Поэтому сюда разрешено передавать ТОЛЬКО курируемые агрегаты секций отчёта
|
||
(``SiteFinderReport.as_dict()``), которые вызывающий уже отобрал секционными
|
||
tool'ами (см. tools.py). ЭТО ЕДИНСТВЕННОЕ место, через которое данные чата уходят
|
||
во внешнюю модель — поэтому и аллоулист здесь.
|
||
|
||
РАЗРЕШЕНО в ``section_data`` / ``fields``:
|
||
• срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
|
||
scoring / confidence / scenarios) — это посчитанные advisory-агрегаты.
|
||
|
||
ЗАПРЕЩЕНО (НИКОГДА не должно сюда дойти):
|
||
• сырой ``analyze``-blob / сырые строки БД (rosreestr_deals, parcels, …);
|
||
• свободный insight-текст / лиды / любой контент с PII;
|
||
• любой источник, помеченный confidential.
|
||
|
||
Вторичная защита (belt-and-suspenders): ``complete`` всё равно прогонит ``text`` и
|
||
строковые значения ``fields`` через regex-scrub PII (redaction.py). Но это НЕ замена
|
||
аллоулиста — основная гарантия в том, ЧТО сюда передаёт вызывающий.
|
||
|
||
``is_confidential``: прокидывается в ``SafePayload`` как есть. Для MVP отчёт — это
|
||
не-чувствительные агрегаты → False. Но параметр СУЩЕСТВУЕТ и ЧЕСТНО соблюдается: если
|
||
когда-нибудь источник окажется confidential, ``complete`` для внешнего провайдера
|
||
поднимет ``RedactionRefusedError`` → чат деградирует в детерминированный fallback
|
||
(hard-block, данные наружу НЕ уходят).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from app.services.llm import SafePayload
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def build_chat_payload(
|
||
question: str,
|
||
section_data: dict[str, Any],
|
||
*,
|
||
is_confidential: bool = False,
|
||
) -> SafePayload:
|
||
"""Собрать исходящий ``SafePayload`` чата из вопроса + КУРИРУЕМЫХ срезов секций.
|
||
|
||
ЕДИНСТВЕННАЯ точка сборки исходящей нагрузки чата (§19-гейт). Вызывающий ОБЯЗАН
|
||
передавать в ``section_data`` ТОЛЬКО агрегаты секций отчёта (срезы из tools.py) —
|
||
НИКОГДА сырой ``analyze``-blob, сырые строки БД или insight-текст (см. модульный
|
||
docstring). Эта функция ничего не «достаёт» и не ходит в БД — она лишь упаковывает
|
||
то, что ей дали, в ``SafePayload`` (который ``complete`` затем проскрабит/заблокирует).
|
||
|
||
Args:
|
||
question: вопрос пользователя (RU). Уйдёт в ``text`` и будет проскраблен от PII
|
||
внутри ``complete`` перед отправкой (вторичная защита).
|
||
section_data: накопленные срезы секций отчёта (имя tool'а → под-dict секции).
|
||
Аллоулист: только вывод секционных executors из tools.py.
|
||
is_confidential: флаг конфиденциальности источника. MVP: отчёт = агрегаты → False.
|
||
True ⇒ ``complete`` для внешнего провайдера поднимет RedactionRefusedError
|
||
(hard-block) → вызывающий деградирует в детерминированный fallback.
|
||
|
||
Returns:
|
||
``SafePayload`` с ``text``=вопрос, ``fields``=section_data, флагом confidential.
|
||
"""
|
||
# НЕ логируем содержимое вопроса/секций (PII-гигиена) — только факт и размер.
|
||
logger.debug(
|
||
"chat: building safe payload (sections=%d, confidential=%s)",
|
||
len(section_data),
|
||
is_confidential,
|
||
)
|
||
return SafePayload(
|
||
text=question,
|
||
# dict(...) — копия: payload не должен держать ссылку на изменяемый аккумулятор
|
||
# вызывающего (loop дополняет section_data между итерациями).
|
||
fields=dict(section_data),
|
||
is_confidential=is_confidential,
|
||
)
|
||
|
||
|
||
__all__ = ["build_chat_payload"]
|