Some checks failed
CI / changes (push) Has been cancelled
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
CI / changes (pull_request) Has been cancelled
- admin_scrape.py /failures limit: ge=0 → ge=1 (returning 0 rows is nonsensical for a list endpoint) - orchestrator.py: check result.finish_reason after ok=True; 'length'/'content_filter' and any other non-stop reason triggers deterministic fallback with reason=finish_reason:<value> (LLMResult docstring mandated this but no consumer enforced it)
214 lines
11 KiB
Python
214 lines
11 KiB
Python
"""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"]
|