gendesign/backend/app/services/chat/tools.py
Light1YT fceaaf9a2c
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m41s
CI / backend-tests (push) Successful in 6m24s
CI / backend-tests (pull_request) Successful in 6m23s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m12s
feat(chat): LLM tool-loop + §19 redaction wiring for #957 (Step 2+3)
Add the LLM prose-composition path for the parcel-forecast chat, layered
over the deterministic Step-1 fallback which stays the safety net.

- chat/tools.py: 5 read-only section tools (exec_summary, product_recommendation,
  forecast, risks, scenarios) — pure slices of the loaded report dict, no DB/
  recompute, graceful on missing sections. market_now (raw analyze blob) and meta
  are deliberately NOT exposed -> highest-PII data cannot reach the LLM.
- chat/safe_payload.py: the §19 gate — single place that builds the outbound
  SafePayload from a section-aggregate allowlist; honors is_confidential hard-block.
- chat/orchestrator.py: manual tool-call loop with call-cap/termination, real
  grounded_in provenance; any LLMResult.ok=False (disabled/timeout/rate_limited/
  redaction_refused/call_cap/provider_error/empty) degrades to the deterministic answer.
- llm/prompts.py: versioned chat_system@v1 — answer only from sections, never
  fabricate numbers, advisory tone, decline out-of-scope.
- api/v1/chat.py: branch on settings.llm_enabled; sync complete bridged via
  run_in_threadpool. Default-off -> deterministic path, no provider built.
- Tests: fake provider only (no network), planted-secret redaction-boundary +
  per-reason fallback + call-cap + numbers-from-report coverage.

Refs #957
2026-06-08 17:45:01 +05:00

170 lines
8.4 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.

"""Read-only «секционные» tool'ы чата (#957, Step 2) — срезы УЖЕ-загруженного отчёта.
LLM в tool-loop'е (см. orchestrator.py) просит секции отчёта через function-calling.
Здесь — две вещи и НИЧЕГО больше:
1. OpenAI tool-спеки (JSON-schema) пяти read-only секционных tool'ов
(`get_exec_summary` / `get_product_recommendation` / `get_forecast` /
`get_risks` / `get_scenarios`). Без параметров — каждый отдаёт фиксированную
секцию(и) отчёта (модель не управляет вычислениями, только запрашивает данные).
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
НИКАКОЙ движковой математики — только срез готового dict'а.
КРИТИЧНО (§16 grounding): числа берутся ВЕРБАТИМ из отчёта. Tool'ы не считают и не
выдумывают — они достают под-dict секции как есть. Модель потом только оборачивает
эти числа в RU-прозу (compose), не изобретая своих.
ROBUST: отчёт может быть тонким/частичным (каждая секция Optional). Executors НИКОГДА
не бросают KeyError — отсутствует секция → `{}` или маленький маркер «недоступно».
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
# Маркер «секции нет в отчёте» (graceful, не KeyError). Модель по нему скажет, что
# данных нет, а не выдумает их (см. system-prompt в prompts.py).
_NOT_AVAILABLE: dict[str, Any] = {"available": False}
def _section(report: dict[str, Any], key: str) -> dict[str, Any]:
"""Достать секцию-dict по ключу или вернуть маркер «недоступно». PURE, без KeyError."""
value = report.get(key)
if isinstance(value, dict) and value:
return value
return dict(_NOT_AVAILABLE)
# ── Executors (PURE срезы готового report_dict; НЕТ БД/recompute/engine-math) ────
def get_exec_summary(report: dict[str, Any]) -> dict[str, Any]:
"""§13.1 exec_summary — вердикт-заголовок + ключевые числа + общая уверенность."""
return _section(report, "exec_summary")
def get_product_recommendation(report: dict[str, Any]) -> dict[str, Any]:
"""§13.4 product_tz — рекомендация продукта (класс / квартирография / USP / §16)."""
return _section(report, "product_tz")
def get_forecast(report: dict[str, Any]) -> dict[str, Any]:
"""§13.3 future_market — прогноз спроса/предложения по горизонтам + future-supply."""
return _section(report, "future_market")
def get_risks(report: dict[str, Any]) -> dict[str, Any]:
"""Риски — §13.6 scoring (спец-индексы §25) + §13.7 confidence (уровень/факторы).
Объединяет ДВЕ секции в один срез: риски в отчёте размазаны по scoring
(special_indices, вкл. каннибализацию) и confidence (уровень + тянущие факторы).
Каждая под-секция graceful: отсутствует → маркер «недоступно», без KeyError.
"""
return {
"scoring": _section(report, "scoring"),
"confidence": _section(report, "confidence"),
}
def get_scenarios(report: dict[str, Any]) -> dict[str, Any]:
"""§13.5 scenarios — сводка conservative/base/aggressive + summary."""
return _section(report, "scenarios")
# ── Реестр имя→executor + имя→секции отчёта (для provenance grounded_in.sections) ─
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
# Имя tool'а → (executor, какие секции отчёта он реально трогает). sections нужны для
# честного grounded_in.sections: что СПРАШИВАЛ LLM = на чём заземлён ответ.
_TOOLS: dict[str, tuple[Callable[[dict[str, Any]], dict[str, Any]], tuple[str, ...]]] = {
"get_exec_summary": (get_exec_summary, ("exec_summary",)),
"get_product_recommendation": (get_product_recommendation, ("product_tz",)),
"get_forecast": (get_forecast, ("future_market",)),
"get_risks": (get_risks, ("scoring", "confidence")),
"get_scenarios": (get_scenarios, ("scenarios",)),
}
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
_TOOL_DESCRIPTIONS: dict[str, str] = {
"get_exec_summary": (
"Краткое резюме отчёта: вердикт «что строить», ключевые числа, общая уверенность."
),
"get_product_recommendation": (
"Рекомендация продукта: класс объекта, квартирография (mix), коммерция, "
"USP и §16-обоснования."
),
"get_forecast": (
"Прогноз будущего рынка: спрос/предложение по горизонтам, дефицит/затоварка, "
"давление будущего предложения."
),
"get_risks": (
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
"и уровень/факторы уверенности отчёта."
),
"get_scenarios": (
"Сценарии развития: разброс по консервативному / базовому / агрессивному."
),
}
def _spec(name: str) -> dict[str, Any]:
"""Собрать одну OpenAI function-tool спеку (без параметров — секция фиксирована)."""
return {
"type": "function",
"function": {
"name": name,
"description": _TOOL_DESCRIPTIONS[name],
# Без аргументов: tool отдаёт фиксированную секцию, модель не управляет
# вычислениями. additionalProperties=False — строгая пустая схема.
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
},
}
def tool_specs() -> list[dict[str, Any]]:
"""Все 5 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
return [_spec(name) for name in _TOOLS]
def is_known_tool(name: str) -> bool:
"""Известен ли tool по имени (защита от галлюцинации имени моделью)."""
return name in _TOOLS
def execute_tool(name: str, report: dict[str, Any]) -> dict[str, Any]:
"""Выполнить секционный executor по имени против УЖЕ-загруженного report_dict.
PURE-срез: НИКАКОЙ БД/recompute/engine-math. Неизвестное имя → маркер «недоступно»
(модель не уронит loop, если выдумает имя tool'а). KeyError невозможен (executors
graceful).
"""
entry = _TOOLS.get(name)
if entry is None:
return dict(_NOT_AVAILABLE)
executor, _ = entry
return executor(report)
def sections_for_tool(name: str) -> tuple[str, ...]:
"""Какие секции отчёта трогает tool (для grounded_in.sections). Неизвестный → ()."""
entry = _TOOLS.get(name)
return entry[1] if entry is not None else ()
__all__ = [
"execute_tool",
"get_exec_summary",
"get_forecast",
"get_product_recommendation",
"get_risks",
"get_scenarios",
"is_known_tool",
"sections_for_tool",
"tool_specs",
]