Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -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:]