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
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
170 lines
8.4 KiB
Python
170 lines
8.4 KiB
Python
"""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",
|
||
]
|