1010 lines
40 KiB
Python
1010 lines
40 KiB
Python
"""Многошаговый вызов локального 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,
|
||
)
|