209 lines
13 KiB
Python
209 lines
13 KiB
Python
"""Краткая диагностика среды 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:]
|