@@ -0,0 +1,208 @@
|
||||
"""Краткая диагностика среды 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:]
|
||||
Reference in New Issue
Block a user