gendesign/backend/app/services/chat/orchestrator.py
bot-backend 145dc62e0b
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
fix(admin-scrape): ge=1 on count param + enforce LLM finish_reason (#review-followup)
- 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)
2026-06-17 21:23:13 +03:00

214 lines
11 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.

"""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"]