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