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