gendesign/backend/app/services/chat/safe_payload.py
bot-backend 7b3d1a8f85
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m8s
CI / backend-tests (pull_request) Successful in 14m34s
feat(chat): add get_parcel_info tool exposing curated analyze градрегламент
Chat could not answer 'какая зона ПЗЗ?' — retrieval loaded only the §22
forecast run (schema 1.0); тер.зона/градрегламент/ЗОУИТ/ЕГРН live in the
analyze-1.0 run and never reached the chat context.

Add get_parcel_context_for_chat: a §19-safe whitelist slice of analyze
(fixed keys AND scalar-only values — dict/list dropped at the boundary;
ЗОУИТ zone names as a dedicated str-only capped list; no geometry/PII/raw
sub-dicts) merged into a copy of report_dict under 'parcel_context';
expose it via the sixth sectional tool get_parcel_info. Bump chat_system
prompt v2->v3 so the model uses it and no longer treats the parcel's own
ПЗЗ zone as out of scope.
2026-07-04 06:08:47 +05: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"]