"""LLM tool-loop + RU-проза-композиция чата по §22-форсайту (#957, Step 2). Поверх ДЕТЕРМИНИРОВАННОГО Step 1 (intents.render_answer) — ОПЦИОНАЛЬНЫЙ LLM-слой. Архитектурный закон проекта: форсайт-движок детерминирован, LLM ничего не «ломает» — при ЛЮБОМ сбое/выключенности возвращается детерминированный ответ Step 1. ПОТОК (ручной tool-call loop — ``complete`` это pass-through, НЕ агент): 1. ``complete(chat_system, payload(question, {}), tools=<5 секционных спек>, call_index=0)``. 2. Если модель попросила tool'ы → выполняем секционные срезы против УЖЕ-ЗАГРУЖЕННОГО report_dict (tools.execute_tool — НИКАКОЙ БД/recompute/engine-math), копим section_data, пересобираем payload и зовём ``complete(..., call_index+1)``. 3. Повтор пока модель не вернёт финальную прозу (нет tool_calls) ЛИБО пока call_index не упрётся в settings.llm_max_calls_per_request → детерминированный fallback. GROUNDING (§16): числа живут в section_data (= срезы отчёта); LLM лишь оборачивает их в RU-прозу. system-prompt запрещает выдумывать числа (см. prompts.chat_system). grounded_in.sections — РЕАЛЬНО запрошенные моделью секции (честный provenance). FALLBACK: любой ``LLMResult.ok is False`` (disabled/timeout/rate_limited/redaction_refused/ call_cap/no_api_key/provider_error) → Step-1 ``render_answer`` с llm_used=False + fallback_reason. Пустой финальный ответ модели → тоже детерминированный fallback (empty_response): пустую прозу клиенту не отдаём. """ from __future__ import annotations import logging from dataclasses import dataclass, field from typing import Any from sqlalchemy.orm import Session from app.core.config import settings from app.schemas.chat import ChatTurn from app.services.chat import intents, tools from app.services.chat.safe_payload import build_chat_payload from app.services.llm import LLMResult, complete, render from app.services.llm.provider import LLMProvider, ToolCall logger = logging.getLogger(__name__) # Имя системного промпта чата (versioned-шаблон в llm/prompts.py). _CHAT_SYSTEM_PROMPT = "chat_system" @dataclass(frozen=True, slots=True) class ChatResult: """Итог оркестрации одного хода чата (LLM-проза ИЛИ детерминированный fallback). Attributes: answer: финальный RU-текст (LLM-проза при llm_used; иначе шаблонный Step-1). sections: секции отчёта, легшие в основу (provenance grounded_in.sections): реально запрошенные tool'ами при llm_used; render_answer-секции при fallback. llm_used: True только при успешной LLM-композиции. fallback_reason: машиночитаемая причина деградации (None при llm_used). """ answer: str sections: list[str] = field(default_factory=list) llm_used: bool = False fallback_reason: str | None = None def _deterministic(report_dict: dict[str, Any], message: str, reason: str) -> ChatResult: """Детерминированный Step-1 ответ (FALLBACK). Числа ВЕРБАТИМ из отчёта, без LLM.""" intent = intents.route_intent(message, None) answer, sections = intents.render_answer(intent, report_dict) return ChatResult( answer=answer, sections=sections, llm_used=False, fallback_reason=reason, ) def _run_tool_calls( tool_calls: list[ToolCall], report_dict: dict[str, Any], section_data: dict[str, Any], called_sections: list[str], ) -> None: """Выполнить запрошенные секционные tool'ы против report_dict, дополнив аккумуляторы. PURE-срезы (tools.execute_tool): НИКАКОЙ БД/recompute/engine-math. Имя tool'а → под-dict в section_data (для следующего payload); реально затронутые секции отчёта → called_sections (provenance, без дублей). Неизвестное имя tool'а игнорируем безопасно (execute_tool вернёт маркер «недоступно», sections_for_tool вернёт ()). """ for tc in tool_calls: # Аргументы tool'ов наши спеки не определяют (секция фиксирована) — tc.arguments # игнорируем намеренно: модель не управляет вычислениями, только выбирает секцию. section_data[tc.name] = tools.execute_tool(tc.name, report_dict) for sect in tools.sections_for_tool(tc.name): if sect not in called_sections: called_sections.append(sect) def orchestrate_chat( db: Session | None, cad_num: str, message: str, history: list[ChatTurn] | None, report_dict: dict[str, Any], run_id: int, *, provider: LLMProvider | None = None, ) -> ChatResult: """Прогнать один ход чата через LLM tool-loop; при любом сбое — детерм. Step-1 fallback. Args: db: сессия БД (НЕ используется для пере-расчёта — отчёт уже загружен; принимается для единообразия сигнатуры и возможного будущего read-only обогащения. None допустим — оркестратор в БД не ходит). cad_num: кадастровый номер (для логов/контекста). message: вопрос пользователя (RU). history: история диалога (Step 2: пока в payload НЕ форвардится — узкий аллоулист §19; расширение — отдельным решением). report_dict: УЖЕ-ЗАГРУЖЕННЫЙ ``SiteFinderReport.as_dict()`` (8 секций). Источник ВСЕХ чисел — срезы этого dict'а; движковая математика НЕ повторяется. run_id: id рана-источника (для логов; grounded_in.run_id ставит эндпоинт). provider: внедрить LLMProvider (тесты/RU-hosted). None → собирается из settings внутри ``complete`` (в проде). Returns: ``ChatResult`` — LLM-проза (llm_used=True) или детерминированный Step-1 fallback. """ del db, history # см. docstring: отчёт уже загружен; history пока не форвардим. system_prompt = render(_CHAT_SYSTEM_PROMPT) # Аккумуляторы между итерациями loop'а. section_data: dict[str, Any] = {} # имя tool'а → срез секции (идёт в payload) called_sections: list[str] = [] # реально затронутые секции отчёта (provenance) specs = tools.tool_specs() # call_index растёт с каждой итерацией; call-cap дублирует guard внутри complete, # но держим явный предел и здесь, чтобы loop гарантированно завершался. call_index = 0 max_calls = settings.llm_max_calls_per_request while call_index < max_calls: payload = build_chat_payload(message, section_data) result: LLMResult = complete( system_prompt=system_prompt, payload=payload, tools=specs, provider=provider, call_index=call_index, ) if not result.ok: # disabled / timeout / rate_limited / redaction_refused / call_cap / # no_api_key / provider_error → детерминированный Step-1 ответ. logger.info( "chat: LLM unavailable (reason=%s) for cad=%s run=%s → deterministic fallback", result.reason, cad_num, run_id, ) return _deterministic(report_dict, message, result.reason or "llm_unavailable") if result.tool_calls: # Модель запросила секции — выполняем срезы и идём на следующий виток с # накопленным контекстом (числа из отчёта, не из LLM). _run_tool_calls(result.tool_calls, report_dict, section_data, called_sections) call_index += 1 continue # Нет tool_calls → финальная проза. # Проверяем finish_reason: 'length' / 'content_filter' означают обрезанный # или отфильтрованный ответ — неполная проза клиенту не отдаётся (LLMResult # docstring §64: «консьюмер ОБЯЗАН проверять даже при ok=True»). if result.finish_reason not in (None, "stop", "end_turn", "tool_calls"): logger.warning( "chat: non-stop finish_reason=%r for cad=%s run=%s → deterministic fallback", result.finish_reason, cad_num, run_id, ) return _deterministic(report_dict, message, f"finish_reason:{result.finish_reason}") answer = (result.content or "").strip() if not answer: # Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем. logger.info( "chat: empty LLM content for cad=%s run=%s → deterministic fallback", cad_num, run_id, ) return _deterministic(report_dict, message, "empty_response") logger.info( "chat: LLM-composed answer for cad=%s run=%s (sections=%s, calls=%d)", cad_num, run_id, called_sections, call_index, ) return ChatResult( answer=answer, sections=called_sections, llm_used=True, fallback_reason=None, ) # Loop исчерпал бюджет вызовов (модель всё просит tool'ы) → детерминированный fallback. logger.info( "chat: tool-loop hit call cap (%d) for cad=%s run=%s → deterministic fallback", max_calls, cad_num, run_id, ) return _deterministic(report_dict, message, "call_cap") __all__ = ["ChatResult", "orchestrate_chat"]