"""§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"]