Files
site/WESP_REL/app/services/admin_llm_orchestrator.py
T
2026-07-17 12:57:18 +03:00

1010 lines
40 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 с встроенными slim-tools (OpenAI-совместимый tool_calls)."""
from __future__ import annotations
import copy
import json
import re
from typing import Any, Dict, List, Tuple
from flask import Flask
from app.services.admin_llm_tool_router import SLIM_TOOL_DEFINITIONS, dispatch_slim_tool
from app.services.local_llm_client import LocalLlmError, openai_chat_completion_message
TOOLS_APPEND_RU = (
"\n\n---\nИнструменты WESP (только при необходимости; аргументы строго по схеме function):\n"
"- slim_sql: один SELECT или WITH … SELECT на bind recipes или reports. "
"Режим execute в инструменте — только если пользователь в этом запросе чата явно включил выполнение SELECT "
"(в UI или sql_mode=execute); иначе используй mode=preview.\n"
"- slim_extract, slim_summary, slim_xsum, slim_sentiment, slim_emotions: "
"source=server_log | client_log | raw_text; для client_log нужен client_id.\n"
"Правило точности: если пользователь спрашивает «что есть в БД» или про список сущностей "
"(кормораздатчики, рецепты, компоненты и т.п.), сначала сделай slim_sql-проверку. "
"Справочник таблиц — sqlite_master; строки сущностей — SELECT из нужной таблицы (не путай их).\n"
"Схема (важно, не выдумывать поля): feed_dispenser — id, name, farm, operator, type, is_active, is_deleted, …; "
"периоды в feeding_period, рецепты через period_recipes/recipe. "
"feed_mixer — id, name, model, gosnomer, operator, ip_address, is_active (поля farm у смесителя нет). "
"component — кормовые компоненты: name, type, is_active, is_deleted, dry_matter, protein, energy, price, …\n"
"Если таблица/строки не найдены — сообщи это явно, а не «нет информации» без проверки.\n"
"Пиши только по-русски (кроме имён из БД); не добавляй китайский/японский текст и не выдумывай версии SQLite или "
"описания файлов recipes.db/reports.db — опирайся только на результат slim_sql.\n"
"После ответа инструмента кратко объясни пользователю результат по-русски, без выдуманных фактов."
"Опирайся на историю чата, если что-то не понятно лучше спросить пользователя."
)
_REFUSAL_RE = re.compile(
r"(не могу|недостаточно информации|используй[а-я\\s]+инструмент|могу выполнить select)",
re.I,
)
_GENERIC_PROGRESS_RE = re.compile(
r"(вывожу|приму|выполню|показываю|сейчас покажу|выполняю запрос)",
re.I,
)
_WEAK_DB_REPLY_RE = re.compile(
r"(sqlite_master|уточните запрос|рекомендую использовать|могу выполнить select|могу предоставить)",
re.I,
)
# Ответы «из головы» про файлы БД / версии SQLite / каталог assistant вместо результата slim_sql.
_HALLUCINATED_DB_RE = re.compile(
r"(recipes\.db|reports\.db|data[/\\]assistant|sqlite\s*3[.,]?\s*\d|верси[ия]\s*sqlite)",
re.I,
)
_NON_CYRILLIC_LEAK_RE = re.compile(r"[\u4e00-\u9fff\u3040-\u30ff]") # CJK в ответе про WESP
def _last_user_text(messages: List[Dict[str, Any]]) -> str:
for m in reversed(messages):
if str(m.get("role") or "") == "user":
return str(m.get("content") or "").strip()
return ""
def _auto_probe_plan(user_text: str) -> List[Tuple[str, Dict[str, Any]]]:
q = (user_text or "").strip().lower()
raw = user_text or ""
plan: List[Tuple[str, Dict[str, Any]]] = []
ask_logs = any(k in q for k in ("лог", "логи", "ошиб", "журнал"))
# Только явный запрос каталога таблиц — не «в бд» в общих вопросах про строки component/recipe.
ask_table_catalog = any(
k in q for k in ("таблиц", "schema", "схем")
) or "список таблиц" in q or "имена таблиц" in q
# «Что есть в БД / какие … в бд» без слова «таблиц» — всё равно нужен slim_sql, иначе модель выдумывает файлы.
ask_db_glimpse = ("бд" in q or "база" in q or "базе" in q or "базы" in q) and any(
w in q for w in ("что", "есть", "какие", "покажи", "расскажи", "какой", "какова")
)
ask_recipes = any(k in q for k in ("рецепт", "recipe"))
ask_period = any(k in q for k in ("период", "смен", "утро", "вечер", "ноч"))
ask_feeders = any(
k in q for k in ("кормораздат", "раздатчик", "feed_dispenser", "миксер", "кормосмес")
)
ask_components = (
"компонент" in q
or "ингредиент" in q
or bool(re.search(r"\bcomponent\b", q))
or bool(re.search(r"`\s*component\s*`", raw, re.I))
)
disp_hint = _extract_dispenser_hint(user_text)
if ask_logs:
plan.append(("slim_extract", {"source": "server_log"}))
plan.append(("slim_summary", {"source": "server_log"}))
plan.append(("slim_sentiment", {"source": "server_log"}))
if ask_table_catalog or ask_feeders or ask_db_glimpse:
for bind in ("recipes", "reports"):
plan.append(
(
"slim_sql",
{
"bind": bind,
"mode": "execute",
"sql": "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
},
)
)
if ask_feeders:
plan.append(
(
"slim_sql",
{
"bind": "recipes",
"mode": "execute",
"sql": "SELECT id, name, farm, is_active FROM feed_dispenser ORDER BY name LIMIT 100",
},
)
)
plan.append(
(
"slim_sql",
{
"bind": "recipes",
"mode": "execute",
"sql": "SELECT id, name, is_active FROM feed_mixer ORDER BY name LIMIT 100",
},
)
)
if ask_components:
plan.append(
(
"slim_sql",
{
"bind": "recipes",
"mode": "execute",
"sql": (
"SELECT cid, name, type, \"notnull\" AS notnull, dflt_value, pk "
"FROM pragma_table_info('component') ORDER BY cid"
),
},
)
)
plan.append(
(
"slim_sql",
{
"bind": "recipes",
"mode": "execute",
"sql": (
"SELECT name, type, is_active, COALESCE(is_deleted, 0) AS is_deleted, "
"dry_matter, protein, energy, price "
"FROM component ORDER BY name LIMIT 200"
),
},
)
)
if ask_recipes:
plan.append(
(
"slim_sql",
{
"bind": "recipes",
"mode": "execute",
"sql": "SELECT id, name FROM recipe WHERE COALESCE(is_deleted, 0)=0 ORDER BY name LIMIT 200",
},
)
)
if ask_feeders and (ask_recipes or (disp_hint and _dispenser_hint_is_actionable(disp_hint))):
# Точный список рецептов по имени/хозяйству/оператору раздатчика (без путаницы с полем name).
if disp_hint and _dispenser_hint_is_actionable(disp_hint):
esc = _sql_like_fragment(disp_hint).replace("'", "''")
if esc:
plan.append(
(
"slim_sql",
{
"bind": "recipes",
"mode": "execute",
"sql": (
"SELECT DISTINCT r.name AS recipe FROM feed_dispenser d "
"JOIN feeding_period p ON p.dispenser_id = d.id AND COALESCE(p.is_deleted, 0) = 0 "
"JOIN period_recipes pr ON pr.period_id = p.id "
"JOIN recipe r ON r.id = pr.recipe_id AND COALESCE(r.is_deleted, 0) = 0 "
"WHERE COALESCE(d.is_deleted, 0) = 0 AND ("
f"LOWER(d.name) LIKE '%{esc}%' OR LOWER(d.farm) LIKE '%{esc}%' "
f"OR LOWER(COALESCE(d.operator, '')) LIKE '%{esc}%'"
") ORDER BY r.name LIMIT 100"
),
},
)
)
# Автосвязка «кормораздатчик -> период -> рецепты» для интерактивных запросов.
where_parts: List[str] = []
for token in _extract_query_terms(q):
esc = token.replace("'", "''")
where_parts.append(
"(LOWER(d.name) LIKE '%{t}%' OR LOWER(d.farm) LIKE '%{t}%' OR LOWER(p.name) LIKE '%{t}%' OR LOWER(r.name) LIKE '%{t}%')".format(
t=esc
)
)
where_sql = ""
if where_parts:
where_sql = " WHERE " + " AND ".join(where_parts[:4])
plan.append(
(
"slim_sql",
{
"bind": "recipes",
"mode": "execute",
"sql": (
"SELECT d.name AS dispenser, d.farm AS farm, d.operator AS operator, "
"p.name AS period, r.name AS recipe "
"FROM feed_dispenser d "
"LEFT JOIN feeding_period p ON p.dispenser_id = d.id AND COALESCE(p.is_deleted, 0) = 0 "
"LEFT JOIN period_recipes pr ON pr.period_id = p.id "
"LEFT JOIN recipe r ON r.id = pr.recipe_id AND COALESCE(r.is_deleted, 0) = 0"
+ where_sql
+ " ORDER BY d.name, p.name, pr.\"order\" LIMIT 200"
),
},
)
)
return plan
def _sql_like_fragment(s: str) -> str:
"""Фрагмент для LIKE: без wildcards, нижний регистр (SQLite LOWER уже на колонке)."""
t = (s or "").strip().lower()
return t.replace("%", "").replace("_", "").replace("'", "")
def _extract_query_terms(q: str) -> List[str]:
raw = re.findall(r"[a-zA-Zа-яА-Я0-9_\\-]{3,}", q or "")
stop = {
"что",
"есть",
"какие",
"какой",
"покажи",
"покажи",
"база",
"бд",
"таблицы",
"таблица",
"рецепты",
"рецепт",
"для",
"период",
"периоде",
"кормораздатчика",
}
out: List[str] = []
for t in raw:
tl = t.lower()
if tl in stop:
continue
out.append(tl)
return out[:8]
_JUNK_DISP_HINT_RE = re.compile(
r"^(есть|какие|что|в\s*бд\b|в\s*базе|список|перечень|у\s+нас|там)\b",
re.I,
)
def _dispenser_hint_is_actionable(hint: str) -> bool:
"""Отсечь хвосты вроде «есть в бд» после слова «кормораздатчики»."""
h = (hint or "").strip()
if len(h) < 2 or len(h) > 80:
return False
low = h.lower()
if _JUNK_DISP_HINT_RE.match(low):
return False
if any(x in low for x in (" в бд", " в базе")):
return False
return True
def _extract_dispenser_hint(user_text: str) -> str:
"""Имя/фрагмент кормораздатчика из фразы пользователя (кавычки или хвост после ключевых слов)."""
raw = (user_text or "").strip()
if not raw:
return ""
m = re.search(r"[«\"']([^»\"']{2,80})[»\"']", raw)
if m:
q = m.group(1).strip()
return q if _dispenser_hint_is_actionable(q) else ""
low = raw.lower()
# Длинные формы первыми: иначе «кормораздатчик» даёт хвост «и…» от «кормораздатчики».
for marker in (
"кормораздатчиках",
"кормораздатчиками",
"кормораздатчиков",
"кормораздатчики",
"кормораздатчика",
"кормораздатчик",
"раздатчиках",
"раздатчиками",
"раздатчиков",
"раздатчики",
"раздатчика",
"раздатчик",
):
idx = low.find(marker)
if idx >= 0:
tail = raw[idx + len(marker) :].strip(" \t:,-—")
# убрать ведущие «для», «имя»
tail = re.sub(r"^(для|имя|название)\s+", "", tail, flags=re.I).strip()
if len(tail) >= 2 and len(tail) <= 80 and _dispenser_hint_is_actionable(tail):
return tail.strip()
return ""
def _is_db_intent(text: str) -> bool:
q = (text or "").strip().lower()
return any(
k in q
for k in (
"бд",
"база",
"базе",
"таблиц",
"schema",
"recipes",
"reports",
"кормораздат",
"рецепт",
"период",
"компонент",
"ингредиент",
"component",
)
)
def _is_logs_intent(text: str) -> bool:
q = (text or "").strip().lower()
return any(k in q for k in ("лог", "логи", "журнал", "ошиб"))
def _recipe_column_only(rows: List[Dict[str, Any]]) -> List[str]:
"""Имена рецептов из строк, где есть колонка recipe (результат JOIN)."""
out: List[str] = []
seen: set[str] = set()
for r in rows:
if "recipe" not in r:
continue
name = str(r.get("recipe") or "").strip()
if not name:
continue
k = name.lower()
if k in seen:
continue
seen.add(k)
out.append(name)
return out
def _recipe_table_rows(rows: List[Dict[str, Any]]) -> List[str]:
"""Строки вида SELECT id, name FROM recipe — без колонок раздатчика."""
out: List[str] = []
seen: set[str] = set()
for r in rows:
if "dispenser" in r or "period" in r:
continue
if "recipe" in r:
continue
keys = set(r.keys())
if "name" not in keys:
continue
if "farm" in keys and "is_active" in keys:
continue
name = str(r.get("name") or "").strip()
if not name:
continue
k = name.lower()
if k in seen:
continue
seen.add(k)
out.append(name)
return out
def _is_components_question(q: str, raw: str) -> bool:
return (
"компонент" in q
or "ингредиент" in q
or bool(re.search(r"\bcomponent\b", q))
or bool(re.search(r"`\s*component\s*`", raw, re.I))
)
def _wants_component_schema_help(q: str, raw: str) -> bool:
if not _is_components_question(q, raw):
return False
return any(
k in q
for k in (
"поля",
"колонк",
"структур",
"pragma",
"ключ",
"атрибут",
"какие столбцы",
"схем",
)
)
def _wants_sqlite_table_catalog(q: str, raw: str) -> bool:
# Не подменять «структуру таблицы component» глобальным списком имён из sqlite_master.
if _is_components_question(q, raw) and _wants_component_schema_help(q, raw):
return False
return (
any(k in q for k in ("таблиц", "schema", "схем"))
or "список таблиц" in q
or "имена таблиц" in q
)
def _wants_db_glimpse_structured(q: str) -> bool:
"""Вопросы вроде «что есть в бд» — отдать список таблиц из trace, не выдумки модели."""
return ("бд" in q or "база" in q or "базе" in q or "базы" in q) and any(
w in q for w in ("что", "есть", "какие", "покажи", "расскажи", "какой", "какова")
)
def _db_glimpse_table_list_only(q: str) -> bool:
"""Обзор «что в БД», но не вопросы про конкретную сущность (их обрабатывают ветки ниже)."""
return _wants_db_glimpse_structured(q) and not any(
k in q
for k in (
"кормораздат",
"раздатчик",
"рецепт",
"компонент",
"ингредиент",
"период",
)
)
def _is_component_data_row(r: Dict[str, Any]) -> bool:
keys = set(r.keys())
if "dry_matter" in keys and "protein" in keys and "energy" in keys:
return True
if "component" in keys:
return False
return "type" in keys and "is_deleted" in keys and "name" in keys and "price" in keys
def _format_component_list(rows: List[Dict[str, Any]], *, limit: int = 45) -> str:
parts: List[str] = []
seen: set[str] = set()
for r in rows:
if not _is_component_data_row(r):
continue
n = str(r.get("name") or "").strip()
if not n:
continue
k = n.lower()
if k in seen:
continue
seen.add(k)
del_flag = bool(r.get("is_deleted"))
suf = " (удалён)" if del_flag else ""
parts.append(f"{n}{suf}")
if len(parts) >= limit:
break
if not parts:
return ""
return "Компоненты в таблице component (фрагмент): " + ", ".join(parts)
def _format_pragma_component_rows(rows: List[Dict[str, Any]]) -> str:
lines: List[str] = []
for r in rows:
if not isinstance(r, dict) or "name" not in r:
continue
col = str(r.get("name") or "").strip()
typ = str(r.get("type") or "").strip()
nn = r.get("notnull")
pk = r.get("pk")
if not col:
continue
lines.append(f"{col} {typ or '?'} notnull={nn} pk={pk}")
if not lines:
return ""
return "Колонки таблицы component (pragma_table_info): " + "; ".join(lines[:30])
def _recipes_for_dispenser_hint(rows: List[Dict[str, Any]], hint: str) -> List[str]:
if not hint:
return []
h = _sql_like_fragment(hint)
out: List[str] = []
seen: set[str] = set()
for r in rows:
disp = str(r.get("dispenser") or "").lower()
farm = str(r.get("farm") or "").lower()
oper = str(r.get("operator") or "").lower()
rec = str(r.get("recipe") or "").strip()
if not rec:
continue
if h not in disp and h not in farm and h not in oper:
continue
k = rec.lower()
if k in seen:
continue
seen.add(k)
out.append(rec)
return out
def _should_run_auto_probe(user_text: str, reply_text: str, trace: List[Dict[str, Any]]) -> bool:
if trace:
return False
q = (user_text or "").strip().lower()
if not q:
return False
asked_data = any(
k in q
for k in (
"лог",
"таблиц",
"бд",
"база",
"кормораздат",
"раздатчик",
"что есть",
"компонент",
"ингредиент",
)
)
if not asked_data:
return False
txt = reply_text or ""
return bool(_REFUSAL_RE.search(txt) or _GENERIC_PROGRESS_RE.search(txt))
def _trace_result_preview(result: Dict[str, Any]) -> Dict[str, Any]:
data = result.get("data")
if not isinstance(data, dict):
return {}
out: Dict[str, Any] = {}
for k in (
"mode",
"bind",
"row_count",
"summary_ru",
"label",
"confidence",
"line_count",
"error_line_samples",
"http_like_codes",
"ipv4",
):
if k in data:
out[k] = data.get(k)
rows = data.get("rows")
if isinstance(rows, list):
out["rows_sample"] = rows[:20]
if rows and isinstance(rows[0], dict):
out["row_keys"] = sorted(rows[0].keys())
return out
def _fallback_text_from_trace(trace: List[Dict[str, Any]]) -> str:
if not trace:
return "Инструменты не вернули данных."
lines: List[str] = []
for t in trace[-8:]:
tool = str(t.get("tool") or "?")
if not t.get("ok"):
lines.append(f"- {tool}: ошибка: {t.get('error') or 'неизвестно'}")
continue
rp = t.get("result_preview") or {}
if tool == "slim_sql":
bind = rp.get("bind") or "?"
mode = rp.get("mode") or "?"
rows = rp.get("rows_sample")
if isinstance(rows, list) and rows:
lines.append(f"- {tool} ({bind}/{mode}): {rows}")
else:
lines.append(f"- {tool} ({bind}/{mode}): выполнено.")
continue
if tool in ("slim_summary", "slim_xsum"):
lines.append(f"- {tool}: {rp.get('summary_ru') or 'сводка получена'}")
continue
if tool in ("slim_sentiment", "slim_emotions"):
lines.append(f"- {tool}: {rp.get('label')} (confidence={rp.get('confidence')})")
continue
lines.append(f"- {tool}: выполнено.")
return "Результат инструментов:\n" + "\n".join(lines)
def _structured_answer_from_trace(user_text: str, trace: List[Dict[str, Any]]) -> str:
if not trace:
return ""
q = (user_text or "").strip().lower()
disp_hint = _extract_dispenser_hint(user_text)
if _is_logs_intent(q):
deep = any(
k in q
for k in (
"ошиб",
"проблем",
"стабильн",
"критич",
"падает",
"warning",
"паник",
"traceback",
)
)
if deep:
for t in reversed(trace):
if t.get("tool") == "slim_extract" and t.get("ok"):
rp = t.get("result_preview") or {}
errs = rp.get("error_line_samples") or []
lc = rp.get("line_count")
if errs:
return (
f"В просмотренном фрагменте лога (~{lc} строк) есть строки с признаками проблем: "
+ " | ".join(str(x) for x in errs[:12])
)
return (
f"В просмотренном фрагменте (~{lc} строк) явных маркеров ERROR/Traceback не найдено; "
"часто это только успешные HTTP-запросы — это не гарантирует отсутствие сбоев вне окна."
)
if t.get("tool") == "slim_sentiment" and t.get("ok"):
rp = t.get("result_preview") or {}
lbl = str(rp.get("label") or "")
if lbl in ("warn", "critical"):
return (
f"По эвристике разбора лога: «{lbl}» (уверенность {rp.get('confidence')}). "
"Смотрите также error_line_samples в трассировке инструментов."
)
for t in reversed(trace):
if t.get("tool") in ("slim_summary", "slim_xsum") and t.get("ok"):
s = str(((t.get("result_preview") or {}).get("summary_ru") or "")).strip()
if s:
return f"По логам: {s}"
return _fallback_text_from_trace(trace)
if _is_db_intent(q):
join_rows: List[Dict[str, Any]] = []
recipe_table_rows: List[Dict[str, Any]] = []
distinct_recipe_only: List[Dict[str, Any]] = []
table_list_rows: List[Dict[str, Any]] = []
pragma_component_rows: List[Dict[str, Any]] = []
component_data_rows: List[Dict[str, Any]] = []
for t in trace:
if t.get("tool") != "slim_sql" or not t.get("ok"):
continue
rp = t.get("result_preview") or {}
rows = rp.get("rows_sample")
if not isinstance(rows, list):
continue
keys = set(rp.get("row_keys") or [])
for r in rows:
if not isinstance(r, dict):
continue
if "cid" in r and "pk" in r:
pragma_component_rows.append(r)
continue
if _is_component_data_row(r):
component_data_rows.append(r)
continue
if "dispenser" in r or "period" in r:
join_rows.append(r)
elif "recipe" in r and "dispenser" not in r and "period" not in r:
distinct_recipe_only.append(r)
elif keys == {"name"} or (keys <= {"name", "id"} and "farm" not in r):
if "recipe" not in r and list(r.keys()) == ["name"]:
table_list_rows.append(r)
elif "id" in r and "name" in r and "farm" not in r:
recipe_table_rows.append(r)
components_q = _is_components_question(q, user_text)
if components_q:
if _wants_component_schema_help(q, user_text) and pragma_component_rows:
return _format_pragma_component_rows(pragma_component_rows)
comp_line = _format_component_list(component_data_rows)
if comp_line:
return comp_line
if _wants_component_schema_help(q, user_text):
return (
"Колонки таблицы component не получены из trace (выполните slim_sql к pragma_table_info)."
)
return "В таблице component нет строк в текущей выборке или данные не попали в trace."
if _wants_sqlite_table_catalog(q, user_text) or _db_glimpse_table_list_only(q):
names: List[str] = []
for r in table_list_rows:
n = str(r.get("name") or "").strip()
if n and n not in names:
names.append(n)
if names:
return "Таблицы в БД (фрагмент): " + ", ".join(names[:40])
feeder_asks_recipes = ("кормораздат" in q or "раздатчик" in q) and (
"рецепт" in q or "рацион" in q
)
feeder_named = (
bool(disp_hint)
and _dispenser_hint_is_actionable(disp_hint)
and ("кормораздат" in q or "раздатчик" in q)
)
if feeder_asks_recipes or feeder_named:
from_distinct = _recipe_column_only(distinct_recipe_only)
if from_distinct:
return "Рецепты для этого кормораздатчика: " + ", ".join(from_distinct[:40])
spec = _recipes_for_dispenser_hint(join_rows, disp_hint)
if spec:
return "Рецепты для этого кормораздатчика: " + ", ".join(spec[:40])
any_rec = _recipe_column_only(join_rows)
if any_rec and disp_hint and _dispenser_hint_is_actionable(disp_hint):
return "Рецепты по связям (фрагмент): " + ", ".join(any_rec[:40])
if disp_hint and _dispenser_hint_is_actionable(disp_hint):
return (
f"Для «{disp_hint}» не найдено привязанных рецептов в period_recipes "
"(проверьте периоды и назначение рецептов в админке)."
)
if "кормораздат" in q or "раздатчик" in q:
dispensers: List[str] = []
for r in join_rows:
d = str(r.get("dispenser") or "").strip()
if d and d not in dispensers:
dispensers.append(d)
if not dispensers:
for t in trace:
if t.get("tool") != "slim_sql" or not t.get("ok"):
continue
rows = (t.get("result_preview") or {}).get("rows_sample")
if not isinstance(rows, list):
continue
for r in rows:
if isinstance(r, dict) and "farm" in r and "is_active" in r:
d = str(r.get("name") or "").strip()
if d and d not in dispensers:
dispensers.append(d)
if dispensers:
return "Кормораздатчики (фрагмент): " + ", ".join(dispensers[:30])
if "рецепт" in q:
recipes = _recipe_table_rows(recipe_table_rows)
if not recipes:
recipes = _recipe_column_only(join_rows)
if recipes:
return "Рецепты в БД (фрагмент): " + ", ".join(recipes[:40])
return "По текущей выборке рецептов не найдено."
return _fallback_text_from_trace(trace)
def _looks_unreliable_final(user_text: str, out: str) -> bool:
txt = out or ""
if _GENERIC_PROGRESS_RE.search(txt) or _REFUSAL_RE.search(txt):
return True
q = (user_text or "").strip().lower()
if _is_logs_intent(q) and ("база данных" in txt.lower() and "лог" in txt.lower()):
return True
if _is_db_intent(q) and _WEAK_DB_REPLY_RE.search(txt):
return True
if _is_db_intent(q) and _HALLUCINATED_DB_RE.search(txt):
return True
if _is_db_intent(q) and _NON_CYRILLIC_LEAK_RE.search(txt):
return True
if _is_logs_intent(q) and _NON_CYRILLIC_LEAK_RE.search(txt):
return True
return False
def _should_override_llm_with_db_trace(user_text: str, out: str, structured: str) -> bool:
"""Если slim_sql уже дал факты, не оставлять «красивый» но выдуманный текст модели."""
q = (user_text or "").strip().lower()
o = (out or "").strip()
if _HALLUCINATED_DB_RE.search(o) or _NON_CYRILLIC_LEAK_RE.search(o):
return True
if "кормораздат" in q or "раздатчик" in q:
if structured.startswith(("Кормораздатчики", "Рецепты для", "Рецепты по")):
return True
if _wants_sqlite_table_catalog(q, user_text) or _db_glimpse_table_list_only(q):
if structured.startswith("Таблицы в БД"):
return True
if "рецепт" in q and structured.startswith("Рецепты в БД"):
return True
if _is_components_question(q, user_text) and "Компоненты в таблице" in structured:
return True
return False
def _run_forced_plan(
app: Flask,
*,
working: List[Dict[str, Any]],
trace: List[Dict[str, Any]],
forced_plan: List[Tuple[str, Dict[str, Any]]],
rounds: int,
sql_max_rows: int,
log_max_bytes: int,
log_max_lines: int,
) -> None:
tc_list: List[Dict[str, Any]] = []
for i, (name, args_obj) in enumerate(forced_plan):
tid = f"forced_{rounds}_{i}_{name}"
tc_list.append(
{
"id": tid,
"type": "function",
"function": {"name": name, "arguments": json.dumps(args_obj, ensure_ascii=False)},
}
)
working.append(
{
"role": "assistant",
"content": "Автопроверка данных: сервер выполнил безопасные инструменты для ответа по БД/логам.",
"tool_calls": tc_list,
}
)
for i, (name, args_obj) in enumerate(forced_plan):
tid = f"forced_{rounds}_{i}_{name}"
result = dispatch_slim_tool(
app,
name,
json.dumps(args_obj, ensure_ascii=False),
chat_sql_mode="execute",
max_rows=sql_max_rows,
max_log_bytes=log_max_bytes,
max_log_lines=log_max_lines,
)
trace.append(
{
"tool": name,
"ok": result.get("ok"),
"error": result.get("error"),
"truncated": result.get("truncated"),
"warnings": result.get("warnings"),
"result_preview": _trace_result_preview(result),
"forced_probe": True,
}
)
working.append(
{
"role": "tool",
"tool_call_id": tid,
"content": json.dumps(result, ensure_ascii=False, default=str),
}
)
def inject_tools_system_block(messages: List[Dict[str, Any]]) -> None:
"""Добавляет к первому system-сообщению инструкцию по инструментам (in-place)."""
if not messages or messages[0].get("role") != "system":
return
first = dict(messages[0])
first["content"] = str(first.get("content") or "") + TOOLS_APPEND_RU
messages[0] = first
def run_llm_chat_with_tools(
app: Flask,
*,
messages: List[Dict[str, Any]],
base_url: str,
model: str,
api_key: str,
timeout_sec: float,
max_tokens: int,
max_rounds: int,
chat_sql_mode: str,
sql_max_rows: int,
log_max_bytes: int,
log_max_lines: int,
) -> Tuple[str, List[Dict[str, Any]]]:
"""
Возвращает финальный текст ответа и trace по инструментам.
При ошибке HTTP от LLM бросает LocalLlmError.
"""
trace: List[Dict[str, Any]] = []
working: List[Dict[str, Any]] = copy.deepcopy(messages)
inject_tools_system_block(working)
user_text = _last_user_text(working)
db_intent = _is_db_intent(user_text)
forced_probe_done = False
if db_intent or _is_logs_intent(user_text):
forced_plan = _auto_probe_plan(user_text)
if forced_plan:
forced_probe_done = True
_run_forced_plan(
app,
working=working,
trace=trace,
forced_plan=forced_plan,
rounds=0,
sql_max_rows=sql_max_rows,
log_max_bytes=log_max_bytes,
log_max_lines=log_max_lines,
)
rounds = 0
last_text = ""
while rounds < max_rounds:
rounds += 1
try:
msg = openai_chat_completion_message(
base_url=base_url,
model=model,
messages=working,
timeout_sec=timeout_sec,
max_tokens=max_tokens,
api_key=api_key,
tools=SLIM_TOOL_DEFINITIONS,
tool_choice="auto",
)
except LocalLlmError:
if trace:
return (_structured_answer_from_trace(user_text, trace), trace)
raise
content = msg.get("content")
if isinstance(content, str):
last_text = content.strip()
tool_calls = msg.get("tool_calls")
if not tool_calls:
out = last_text
if not out and isinstance(content, str):
out = content.strip()
if (
not forced_probe_done
and _should_run_auto_probe(user_text, out or "", trace)
and rounds < max_rounds
):
forced_plan = _auto_probe_plan(user_text)
if forced_plan:
forced_probe_done = True
_run_forced_plan(
app,
working=working,
trace=trace,
forced_plan=forced_plan,
rounds=rounds,
sql_max_rows=sql_max_rows,
log_max_bytes=log_max_bytes,
log_max_lines=log_max_lines,
)
continue
if forced_probe_done and trace:
if _looks_unreliable_final(user_text, out or ""):
return (_structured_answer_from_trace(user_text, trace), trace)
if _is_db_intent(user_text) and any(
t.get("tool") == "slim_sql" and t.get("ok") for t in trace
):
structured = _structured_answer_from_trace(user_text, trace)
if structured and not structured.startswith("Результат инструментов"):
if _should_override_llm_with_db_trace(user_text, out or "", structured):
return (structured, trace)
return (out, trace)
asst_msg: Dict[str, Any] = {"role": "assistant", "content": content}
asst_msg["tool_calls"] = tool_calls
working.append(asst_msg)
for idx, tc in enumerate(tool_calls):
if not isinstance(tc, dict):
trace.append({"tool": "?", "ok": False, "error": "некорректный элемент tool_calls"})
continue
fn = tc.get("function")
if not isinstance(fn, dict):
trace.append({"tool": "?", "ok": False, "error": "нет function в tool_call"})
continue
name = str(fn.get("name") or "")
arg_str = str(fn.get("arguments") if fn.get("arguments") is not None else "{}")
tid = str(tc.get("id") or "").strip() or f"call_{rounds}_{idx}_{name}"
result = dispatch_slim_tool(
app,
name,
arg_str,
chat_sql_mode=chat_sql_mode,
max_rows=sql_max_rows,
max_log_bytes=log_max_bytes,
max_log_lines=log_max_lines,
)
trace.append(
{
"tool": name,
"ok": result.get("ok"),
"error": result.get("error"),
"truncated": result.get("truncated"),
"warnings": result.get("warnings"),
"result_preview": _trace_result_preview(result),
}
)
payload = json.dumps(result, ensure_ascii=False, default=str)
max_tool_chars = max(4000, min(48_000, log_max_bytes // 4 + 8000))
if len(payload) > max_tool_chars:
payload = payload[:max_tool_chars] + "…"
trace[-1]["payload_truncated"] = True
tool_message: Dict[str, Any] = {
"role": "tool",
"tool_call_id": tid,
"content": payload,
}
working.append(tool_message)
return (
last_text
or "Достигнут лимит шагов инструментов; сузьте запрос или отключите лишние действия.",
trace,
)