gendesign/backend/app/services/chat/safe_payload.py
bot-backend 57d156973e
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m44s
Deploy / build-worker (push) Successful in 2m46s
Deploy / deploy (push) Successful in 1m31s
feat(chat): tool get_parcel_info — курируемый градрегламент/ЗОУИТ/ЕГРН в контекст чата (#2366)
2026-07-04 01:24:42 +00:00

90 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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