@@ -0,0 +1,208 @@
|
||||
"""Read-only проверки файловых SQLite для подсказок LLM (без произвольного SQL из чата)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from app.services.admin_dashboard_service import sqlite_bind_paths
|
||||
|
||||
|
||||
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
||||
cur = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
||||
(table,),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _open_sqlite_readonly(path: Path) -> sqlite3.Connection:
|
||||
uri = path.as_uri() + "?mode=ro"
|
||||
return sqlite3.connect(uri, uri=True, timeout=12.0)
|
||||
|
||||
|
||||
def _pragma_integrity(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
rows = conn.execute("PRAGMA integrity_check").fetchall()
|
||||
msgs = [str(r[0]) for r in rows if r and r[0] is not None]
|
||||
ok = len(msgs) == 1 and str(msgs[0]).lower() == "ok"
|
||||
return {"ok": ok, "messages": msgs[:25] if not ok else []}
|
||||
|
||||
|
||||
def _pragma_foreign_keys(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
try:
|
||||
rows = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return {"count": 0, "samples": [], "note": "foreign_key_check недоступен"}
|
||||
samples: List[str] = []
|
||||
for r in rows[:8]:
|
||||
samples.append("|".join(str(x) for x in r))
|
||||
return {"count": len(rows), "samples": samples}
|
||||
|
||||
|
||||
def _domain_recipes_bind(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Типичные логические проблемы в основной БД (recipes)."""
|
||||
out: Dict[str, Any] = {}
|
||||
if _table_exists(conn, "loading_report"):
|
||||
try:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM loading_report WHERE "
|
||||
"recipe_id IS NULL OR length(recipe_id) != 36"
|
||||
).fetchone()[0]
|
||||
out["loading_report_bad_recipe_id_count"] = int(n)
|
||||
except sqlite3.Error as e:
|
||||
out["loading_report_check_error"] = str(e)[:200]
|
||||
if _table_exists(conn, "unloading_report"):
|
||||
try:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM unloading_report WHERE "
|
||||
"recipe_id IS NULL OR length(recipe_id) != 36"
|
||||
).fetchone()[0]
|
||||
out["unloading_report_bad_recipe_id_count"] = int(n)
|
||||
except sqlite3.Error as e:
|
||||
out["unloading_report_check_error"] = str(e)[:200]
|
||||
if _table_exists(conn, "sync_queue"):
|
||||
try:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM sync_queue WHERE "
|
||||
"(COALESCE(is_deleted, 0) = 0) AND status = 'failed'"
|
||||
).fetchone()[0]
|
||||
out["sync_queue_failed_count"] = int(n)
|
||||
except sqlite3.Error as e:
|
||||
out["sync_queue_check_error"] = str(e)[:200]
|
||||
return out
|
||||
|
||||
|
||||
def _one_bind_health(bind_name: str, path: Path) -> Dict[str, Any]:
|
||||
entry: Dict[str, Any] = {"bind": bind_name, "path": str(path)}
|
||||
if not path.is_file():
|
||||
entry["error"] = "файл не найден"
|
||||
return entry
|
||||
try:
|
||||
entry["size_bytes"] = path.stat().st_size
|
||||
except OSError as e:
|
||||
entry["error"] = str(e)
|
||||
return entry
|
||||
try:
|
||||
conn = _open_sqlite_readonly(path)
|
||||
except sqlite3.Error as e:
|
||||
entry["error"] = f"не удалось открыть read-only: {e}"
|
||||
return entry
|
||||
try:
|
||||
ver = conn.execute("SELECT sqlite_version()").fetchone()
|
||||
entry["sqlite_version"] = str(ver[0]) if ver else "?"
|
||||
entry["integrity"] = _pragma_integrity(conn)
|
||||
entry["foreign_keys"] = _pragma_foreign_keys(conn)
|
||||
try:
|
||||
ntbl = conn.execute(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchone()[0]
|
||||
entry["user_table_count"] = int(ntbl)
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
if bind_name == "recipes":
|
||||
entry["domain_checks"] = _domain_recipes_bind(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return entry
|
||||
|
||||
|
||||
def build_llm_db_health_payload(app: Flask) -> Dict[str, Any]:
|
||||
"""Структура для API и для текста LLM."""
|
||||
paths = sqlite_bind_paths(app)
|
||||
if not paths:
|
||||
return {
|
||||
"binds": {},
|
||||
"note": "Нет файловых баз SQLite в конфигурации (или только :memory:) — PRAGMA-проверки не запускались.",
|
||||
}
|
||||
binds_out: Dict[str, Any] = {}
|
||||
for name, pth in sorted(paths.items(), key=lambda x: x[0]):
|
||||
binds_out[name] = _one_bind_health(name, pth)
|
||||
return {"binds": binds_out}
|
||||
|
||||
|
||||
def build_llm_db_health_block(app: Flask) -> str:
|
||||
"""Текст на русском для system: что проверено и что похоже на ошибку."""
|
||||
payload = build_llm_db_health_payload(app)
|
||||
if payload.get("note"):
|
||||
return (
|
||||
"Проверка баз данных SQLite\n"
|
||||
+ str(payload["note"])
|
||||
)
|
||||
lines: List[str] = [
|
||||
"Проверка баз данных SQLite (только чтение: PRAGMA integrity_check, foreign_key_check, несколько эвристик).",
|
||||
"Ниже не полный аудит — ориентир для диагностики; исправления делает администратор (SQL/админка/миграции).",
|
||||
"",
|
||||
]
|
||||
for bind_name in sorted(payload.get("binds", {}).keys()):
|
||||
b = payload["binds"][bind_name]
|
||||
title = f"База «{bind_name}»"
|
||||
if b.get("error"):
|
||||
lines.append(f"- {title}: ошибка — {b['error']}")
|
||||
lines.append("")
|
||||
continue
|
||||
lines.append(f"- {title}: файл {b.get('path', '—')}")
|
||||
if b.get("size_bytes") is not None:
|
||||
sz = int(b["size_bytes"])
|
||||
lines.append(f" размер файла: {sz} байт")
|
||||
if b.get("sqlite_version"):
|
||||
lines.append(f" SQLite: {b['sqlite_version']}")
|
||||
if b.get("user_table_count") is not None:
|
||||
lines.append(f" пользовательских таблиц: {b['user_table_count']}")
|
||||
integ = b.get("integrity") or {}
|
||||
if integ.get("ok") is True:
|
||||
lines.append(" PRAGMA integrity_check: ok")
|
||||
else:
|
||||
lines.append(" PRAGMA integrity_check: ПРОБЛЕМА")
|
||||
for m in integ.get("messages") or ["(нет деталей)"]:
|
||||
lines.append(f" · {m}")
|
||||
fk = b.get("foreign_keys") or {}
|
||||
fc = int(fk.get("count") or 0)
|
||||
if fc == 0:
|
||||
lines.append(" PRAGMA foreign_key_check: нарушений не найдено (0)")
|
||||
else:
|
||||
lines.append(f" PRAGMA foreign_key_check: найдено нарушений: {fc} (проверьте ссылки между таблицами)")
|
||||
for s in fk.get("samples") or []:
|
||||
lines.append(f" · {s}")
|
||||
dom = b.get("domain_checks") or {}
|
||||
if dom:
|
||||
if "loading_report_bad_recipe_id_count" in dom:
|
||||
n = dom["loading_report_bad_recipe_id_count"]
|
||||
if n > 0:
|
||||
lines.append(
|
||||
f" Эвристика: в loading_report строк с recipe_id не UUID (NULL или длина≠36): {n}"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
" Эвристика: loading_report — подозрительных recipe_id (NULL / не UUID) не найдено"
|
||||
)
|
||||
if "unloading_report_bad_recipe_id_count" in dom:
|
||||
n = dom["unloading_report_bad_recipe_id_count"]
|
||||
if n > 0:
|
||||
lines.append(
|
||||
f" Эвристика: в unloading_report строк с recipe_id не UUID: {n}"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
" Эвристика: unloading_report — подозрительных recipe_id не найдено"
|
||||
)
|
||||
if "sync_queue_failed_count" in dom:
|
||||
n = dom["sync_queue_failed_count"]
|
||||
if n > 0:
|
||||
lines.append(
|
||||
f" Эвристика: sync_queue с status=failed (не удалённые): {n}"
|
||||
)
|
||||
else:
|
||||
lines.append(" Эвристика: sync_queue — задач со status=failed не найдено")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Как отвечать: если пользователь спрашивает про ошибки в базе — опирайся на этот блок; "
|
||||
"явно различай повреждение файла (integrity), битые FK (foreign_key_check) и логические эвристики (UUID, очередь sync)."
|
||||
)
|
||||
text = "\n".join(lines).strip()
|
||||
max_len = 5500
|
||||
if len(text) > max_len:
|
||||
return text[: max_len - 1] + "…"
|
||||
return text
|
||||
Reference in New Issue
Block a user