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