gendesign/backend/app/services/chat/orchestrator.py
Light1YT fceaaf9a2c
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
feat(chat): LLM tool-loop + §19 redaction wiring for #957 (Step 2+3)
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
2026-06-08 17:45:01 +05:00

202 lines
10 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 → финальная проза.
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"]