90 lines
6.2 KiB
Python
90 lines
6.2 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-агрегаты.
|
||
• ``parcel_context`` — КУРИРУЕМЫЙ срез analyze-рана (retrieval.get_parcel_context_for_chat):
|
||
публичные градоданные участка (адрес, площадь, категория, ВРИ, тер.зона ПЗЗ + лимиты
|
||
застройки, ЗОУИТ-сводка + названия зон). Строится по фиксированному whitelist'у из
|
||
скаляров и списка строк — БЕЗ геометрии/координат, без сырых под-словарей, без PII.
|
||
|
||
ЗАПРЕЩЕНО (НИКОГДА не должно сюда дойти):
|
||
• сырой ``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"]
|