186 lines
9.4 KiB
Python
186 lines
9.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` / `get_parcel_info`). Без параметров — каждый отдаёт
|
||
фиксированную секцию(и) отчёта (модель не управляет вычислениями, только запрашивает
|
||
данные).
|
||
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")
|
||
|
||
|
||
def get_parcel_info(report: dict[str, Any]) -> dict[str, Any]:
|
||
"""§1 parcel_context — паспорт участка + градрегламент ПЗЗ + ЗОУИТ.
|
||
|
||
Курируемый срез analyze-рана (тер.зона ПЗЗ, ВРИ, лимиты застройки, ЗОУИТ), влитый
|
||
эндпоинтом под ключ "parcel_context" (retrieval.get_parcel_context_for_chat). Секции
|
||
в отчёте нет (analyze-рана не было) → маркер «недоступно», как у остальных tool'ов.
|
||
"""
|
||
return _section(report, "parcel_context")
|
||
|
||
|
||
# ── Реестр имя→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",)),
|
||
"get_parcel_info": (get_parcel_info, ("parcel_context",)),
|
||
}
|
||
|
||
# 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": ("Сценарии развития: разброс по консервативному / базовому / агрессивному."),
|
||
"get_parcel_info": (
|
||
"Паспорт участка и градостроительный регламент: адрес, площадь, категория земель, "
|
||
"ВРИ, территориальная зона ПЗЗ (код и название), лимиты застройки, "
|
||
"ЗОУИТ-обременения."
|
||
),
|
||
}
|
||
|
||
|
||
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]]:
|
||
"""Все 6 секционных 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_parcel_info",
|
||
"get_product_recommendation",
|
||
"get_risks",
|
||
"get_scenarios",
|
||
"is_known_tool",
|
||
"sections_for_tool",
|
||
"tool_specs",
|
||
]
|