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

209 lines
13 KiB
Python
Raw Permalink 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.
"""Краткая диагностика среды WESP для локального LLM (без паролей и токенов)."""
from __future__ import annotations
import os
import platform
import sys
from typing import Any, Dict, List
from urllib.parse import urlparse, urlunparse
from config import (
read_sync_client_state,
resolve_effective_sync_role,
resolve_sync_server_url,
)
from app.services.admin_llm_db_health import build_llm_db_health_block
from app.services.admin_llm_inventory_snapshot import build_llm_inventory_snapshot_block
def sanitize_sqlalchemy_uri(uri: str) -> str:
"""Маскирует пароль в URI СУБД; SQLite оставляет как есть."""
u = (uri or "").strip()
if not u:
return "—"
if u.lower().startswith("sqlite:"):
return u
try:
p = urlparse(u)
if p.password:
host = p.hostname or ""
port = f":{p.port}" if p.port else ""
user = (p.username or "") + ":***"
netloc = f"{user}@{host}{port}"
return urlunparse((p.scheme, netloc, p.path or "", "", "", ""))
except Exception:
return "[uri скрыт]"
return u
def _sync_snapshot(app: Any) -> Dict[str, Any]:
state = read_sync_client_state()
role, _ = resolve_effective_sync_role(state=state, config_defaults=app.config)
server_url, _ = resolve_sync_server_url(state=state, config_defaults=app.config)
return {
"role": role,
"server_url": (server_url or "").strip(),
"client_name": str(state.get("client_name") or "").strip() or None,
"autostart": bool(app.config.get("SYNC_CLIENT_AUTOSTART", True)),
}
def build_llm_diagnostics_payload(app: Any) -> Dict[str, Any]:
"""Структура для API; пригодно для расширения (диагностика без вызова LLM)."""
cfg = app.config
sync = _sync_snapshot(app)
binds = cfg.get("SQLALCHEMY_BINDS") or {}
reports_uri = ""
if isinstance(binds, dict):
reports_uri = sanitize_sqlalchemy_uri(str(binds.get("reports") or ""))
wesp_cfg = os.getenv("WESP_CONFIG", "").strip() or None
return {
"product": "WESP",
"wesp_config": wesp_cfg,
"debug": bool(cfg.get("DEBUG")),
"testing": bool(cfg.get("TESTING")),
"python": sys.version.split()[0],
"os": f"{platform.system()} {platform.release()}",
"base_dir": str(cfg.get("BASE_DIR") or ""),
"display_timezone": str(cfg.get("WESP_DISPLAY_TIMEZONE") or ""),
"database_main": sanitize_sqlalchemy_uri(str(cfg.get("SQLALCHEMY_DATABASE_URI") or "")),
"database_reports": reports_uri or "—",
"sync_role": sync["role"],
"sync_server_url": sync["server_url"] or None,
"sync_client_name": sync["client_name"],
"sync_client_autostart": sync["autostart"],
"sync_client_version": str(cfg.get("SYNC_CLIENT_VERSION") or ""),
"llm_enabled": bool(cfg.get("WESP_ADMIN_LLM_ENABLED")),
"llm_base_url": str(cfg.get("WESP_LLM_BASE_URL") or ""),
"llm_model": str(cfg.get("WESP_LLM_MODEL") or ""),
"assistant_dir": str(cfg.get("WESP_ASSISTANT_DIR") or "").strip() or None,
}
def build_llm_diagnostics_block(app: Any) -> str:
"""Полный нумерованный снимок полей — тот же текст, что видит модель и API text_for_llm."""
p = build_llm_diagnostics_payload(app)
wesp_cfg = p["wesp_config"] or "по умолчанию (обычно development)"
lines: List[str] = [
"Нумерованный снимок (при запросе «всё» / «полная диагностика» перечисляй каждый пункт, не пропускай):",
f"1) Продукт: WESP (веб-интерфейс, отчёты, склад, синхронизация узлов).",
f"2) Python: {p['python']}; ОС: {p['os']}.",
f"3) WESP_CONFIG: {wesp_cfg}; DEBUG={p['debug']}; TESTING={p['testing']}.",
f"4) BASE_DIR (корень проекта): {p['base_dir'] or '—'}.",
f"5) Часовой пояс отображения (WESP_DISPLAY_TIMEZONE): {p['display_timezone'] or '—'}.",
f"6) Основная БД — URI (без пароля для не-SQLite): {p['database_main']}.",
f"7) БД отчётов — bind «reports»: {p['database_reports']}.",
f"8) Синхронизация — роль узла: «{p['sync_role']}».",
f"9) Синхронизация — URL центрального узла (pull/push): {p['sync_server_url'] or '—'}.",
f"10) Синхронизация — autostart фонового sync в процессе WESP: {p['sync_client_autostart']}.",
f"11) Синхронизация — имя клиента (из state): {p['sync_client_name'] or '—'}.",
f"12) Синхронизация — версия протокола клиента (SYNC_CLIENT_VERSION): {p['sync_client_version'] or '—'}.",
f"13) Локальный LLM в админке включён (WESP_ADMIN_LLM_ENABLED): {p['llm_enabled']}.",
f"14) Локальный LLM — базовый URL (WESP_LLM_BASE_URL): {p['llm_base_url'] or '—'}.",
f"15) Локальный LLM — имя модели в API (WESP_LLM_MODEL): {p['llm_model'] or '—'}.",
f"16) Каталог ассистента — GGUF/кэш (WESP_ASSISTANT_DIR): {p['assistant_dir'] or '—'}.",
]
text = "\n".join(lines)
max_len = 6000
if len(text) > max_len:
return text[: max_len - 1] + "…"
return text
# Модели вроде Qwen часто сокращают или отказывают без явной инструкции на «полный дамп».
_ADMIN_LLM_DIAGNOSTICS_FOLLOWUP_RU = (
"\n---\n"
"Как отвечать:\n"
"• Обычный вопрос о среде — кратко своими словами по фактам из нумерованного снимка выше.\n"
"• Если просят «полностью», «всю информацию», «всё что знаешь», «полный дамп», «для диагностики» — "
"перечисли **все** строки с номерами 1)–16) из снимка выше (можно копировать формулировки), "
"не объединяй в один абзац, не опускай пути, URI, флаги DEBUG/TESTING, autostart, роль синка, LLM.\n"
"• Это не секреты: данные уже переданы тебе. Не отвечай отказом «обратитесь к разработчику», если факт есть в снимке.\n"
"• Если чего-то нет в снимке — так и скажи; не выдумывай значения.\n"
"• Блок «Проверка баз SQLite» (если есть ниже) — read-only проверки файлов БД; при вопросах об ошибках в базе, "
"целостности, FK и странных recipe_id/sync_queue опирайся на него и явно различай тип проблемы.\n"
"• Блок «Полный снимок сущностей» — строки из **всех** ORM-таблиц WESP (основная БД и отчёты); на вопросы про оборудование, пользователей, "
"синхронизацию, отчёты и т.д. перечисляй **только** факты из этого блока; если таблица пустая или строка не в снимке — так и скажи; "
"не используй шаблоны-заглушки.\n"
)
# Чат/сводка: без полного ORM-снимка (десятки тысяч токенов), иначе llama-server с -c 8192 отвечает 400.
_ADMIN_LLM_DIAGNOSTICS_FOLLOWUP_COMPACT_RU = (
"\n---\n"
"Перед ответом: весь текст только русский (кириллица); иероглифы и шаблоны вроде «AI助手» запрещены.\n"
"Режим экономии контекста: полный дамп таблиц БД в этот запрос не включён — он доступен через кнопку «Снимок диагностики» в админке.\n"
"Отвечай кратко по фактам из коротких блоков выше; не выдумывай строки из БД. Локальная модель слабая — не больше нескольких предложений.\n"
)
# Обычный чат: без данных БД в промпте — модель иначе «галлюцинирует» проверки и таблицы.
_ADMIN_LLM_CHAT_MINIMAL_RU = (
"\n---\n"
"Лёгкий режим чата: в запрос НЕ включены строки из БД, PRAGMA SQLite, нумерованный снимок среды (пункты 1–16) и дамп таблиц.\n"
"ЗАПРЕЩЕНО придумывать: «проверка SQLite успешна», «таблицы загружены/датачены», фиктивные пункты 1–16, ответы «в таблице всё хорошо» про рецепты, раздатчики, склад — если пользователь спрашивает про данные системы, а ты их не видишь в тексте переписки.\n"
"Как отвечать: если вопрос про БД, рецепты, оборудование, отчёты — 1–3 предложения по-русски, спокойно: в обычном чате факты из базы сюда не подставляются; "
"чтобы увидеть реальное состояние, в админке откройте «Снимок диагностики» (JSON/таблицы) или «Сводка за период» (отчёты/склад), либо нужный экран WESP. "
"ЗАПРЕЩЕНО утверждать что база «пустая», если ты просто не видишь строк; ЗАПРЕЩЕНО выдумывать JSON ответов GET /api/… — чат HTTP не вызывает.\n"
"Не извиняйся длинно и не повторяй одно и то же; без иероглифов.\n"
)
def _clip_llm_text(text: str, max_len: int) -> str:
t = (text or "").strip()
if len(t) <= max_len:
return t
return t[: max_len - 1] + "…"
def merge_llm_diagnostics_into_system(
messages: List[Dict[str, Any]],
app: Any,
*,
mode: str = "full",
) -> List[Dict[str, Any]]:
"""Добавляет блоки диагностики к первому system-сообщению (после ensure_russian_llm_messages).
mode:
- full — снимок среды, SQLite, полный ORM.
- compact — укороченные среда + PRAGMA, без ORM-дампа (чат с контекстом БД и сводка LLM).
- minimal — только короткая строка; без чтения БД (многоходовый чат, экономия ресурсов).
"""
if not messages or messages[0].get("role") != "system":
return messages
if mode == "minimal":
first = dict(messages[0])
first["content"] = str(first.get("content") or "") + _ADMIN_LLM_CHAT_MINIMAL_RU
return [first] + messages[1:]
compact_family = mode == "compact"
try:
block = build_llm_diagnostics_block(app)
db_block = build_llm_db_health_block(app)
if compact_family:
block = _clip_llm_text(block, 2200)
db_block = _clip_llm_text(db_block, 2200)
inv_block = ""
followup = _ADMIN_LLM_DIAGNOSTICS_FOLLOWUP_COMPACT_RU
else:
inv_block = build_llm_inventory_snapshot_block(app)
followup = _ADMIN_LLM_DIAGNOSTICS_FOLLOWUP_RU
extra = (
"\n\n---\nСистемная диагностика (среда WESP; ориентир для ответов, не секреты):\n"
+ block
+ "\n\n---\n"
+ db_block
+ (("\n\n---\n" + inv_block) if inv_block else "")
+ followup
)
except Exception as exc: # noqa: BLE001 — чат не должен падать 500 из-за снимка БД
extra = (
"\n\n---\nСистемная диагностика: не удалось собрать полный снимок ("
+ str(exc)[:400]
+ "). Ответь по общим правилам; не выдумывай данные из БД.\n"
)
first = dict(messages[0])
first["content"] = str(first.get("content") or "") + extra
return [first] + messages[1:]