Files
site/WESP_REL/app/services/admin_llm_inventory_snapshot.py
T
2026-07-17 12:57:18 +03:00

316 lines
10 KiB
Python

"""Полный снимок сущностей из БД WESP для LLM (ORM-таблицы обоих bind, без секретов)."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Type
from sqlalchemy import func, inspect as sa_inspect, or_, select
from app import db
_ROW_LIMIT_DEFAULT = 22
_MAX_TOTAL_TEXT_CHARS = 38000
_MAX_JSON_ROWS_PER_TABLE = 25
def _is_sensitive_column(key: str) -> bool:
n = key.lower()
if "password" in n:
return True
if n in ("token",) or n.endswith("_token"):
return True
if "secret" in n:
return True
if n == "device_fingerprint":
return True
return False
def _serialize_cell(key: str, val: Any) -> Any:
if val is None:
return None
if _is_sensitive_column(key):
return "[скрыто]"
if key == "content_hash" and val is not None:
s = str(val)
return (s[:16] + "…") if len(s) > 16 else s
if isinstance(val, (bytes, memoryview)):
return f"<binary {len(val)} B>"
if isinstance(val, datetime):
return val.isoformat()
s = str(val)
if len(s) > 220:
return s[:217] + "…"
return s
def _not_deleted_clause(cls: Type[Any]):
if hasattr(cls, "is_deleted"):
return or_(cls.is_deleted.is_(False), cls.is_deleted.is_(None))
return None
def _all_concrete_models() -> List[Type[Any]]:
"""Все ORM-модели WESP (явный список — надёжнее обхода registry при ранних импортах).
При добавлении новой модели в app/models — импортировать и добавить в список ниже.
"""
from app.models import (
AutoUpdateSettings,
Component,
ComponentLoadingTime,
ComponentStock,
FeedDispenser,
FeedMixer,
FeedingLocation,
FeedingPeriod,
FeedingPoint,
HardwareSetting,
Ingredient,
KioskDevice,
KioskPairToken,
LoadingReport,
LoadingReportComponent,
PeriodRecipe,
Recipe,
SyncClient,
SyncClientDisplayName,
SyncConflict,
SyncDelivery,
SyncEngineState,
SyncMetadata,
SyncQueue,
Trip,
UnloadingGroup,
UnloadingReport,
UnloadingReportGroup,
WebUser,
)
out: List[Type[Any]] = [
AutoUpdateSettings,
Component,
ComponentLoadingTime,
ComponentStock,
FeedDispenser,
FeedMixer,
FeedingLocation,
FeedingPeriod,
FeedingPoint,
HardwareSetting,
Ingredient,
KioskDevice,
KioskPairToken,
LoadingReport,
LoadingReportComponent,
PeriodRecipe,
Recipe,
SyncClient,
SyncClientDisplayName,
SyncConflict,
SyncDelivery,
SyncEngineState,
SyncMetadata,
SyncQueue,
Trip,
UnloadingGroup,
UnloadingReport,
UnloadingReportGroup,
WebUser,
]
out.sort(key=lambda c: (c.__tablename__ or "").lower())
return out
def _bind_label(cls: Type[Any]) -> str:
bk = getattr(cls, "__bind_key__", None)
return "reports" if bk == "reports" else "recipes"
def _count_rows(session, cls: Type[Any]) -> int:
nd = _not_deleted_clause(cls)
stmt = select(func.count()).select_from(cls)
if nd is not None:
stmt = stmt.where(nd)
return int(session.execute(stmt).scalar_one() or 0)
def _fetch_rows(session, cls: Type[Any], limit: int) -> List[Any]:
mapper = sa_inspect(cls).mapper
stmt = select(cls)
nd = _not_deleted_clause(cls)
if nd is not None:
stmt = stmt.where(nd)
pks = list(mapper.primary_key)
if pks:
for pk in pks:
stmt = stmt.order_by(pk)
stmt = stmt.limit(limit)
# joined eager loads (Recipe, …) требуют unique() на Result
return list(session.execute(stmt).unique().scalars().all())
def _object_to_dict(obj: Any) -> Dict[str, Any]:
cls = obj.__class__
mapper = sa_inspect(cls).mapper
out: Dict[str, Any] = {}
for col in mapper.column_attrs:
key = col.key
val = getattr(obj, key, None)
out[key] = _serialize_cell(key, val)
return out
def build_llm_inventory_payload(app: Any) -> Dict[str, Any]:
"""JSON-снимок по bind: recipes / reports; для отладки API."""
out: Dict[str, Any] = {"binds": {"recipes": {}, "reports": {}}, "tables_total": 0}
if app.config.get("TESTING"):
out["note"] = "TESTING — снимок не собирался"
return out
try:
with app.app_context():
session = db.session
models = _all_concrete_models()
out["tables_total"] = len(models)
for cls in models:
bind = _bind_label(cls)
tname = cls.__tablename__
try:
total = _count_rows(session, cls)
rows_raw = _fetch_rows(session, cls, _MAX_JSON_ROWS_PER_TABLE)
rows = [_object_to_dict(r) for r in rows_raw]
out["binds"][bind][tname] = {
"total_non_deleted": total,
"sample_size": len(rows),
"rows": rows,
}
except Exception as ex:
out["binds"][bind][tname] = {
"error": str(ex)[:400],
"total_non_deleted": None,
"rows": [],
}
except Exception as e:
out["error"] = str(e)[:800]
return out
def build_llm_inventory_snapshot_block(app: Any) -> str:
"""Текст для system: все ORM-таблицы, краткие строки; глобальный лимит длины."""
if app.config.get("TESTING"):
return "Снимок сущностей БД: режим TESTING — данные не выгружены."
lines: List[str] = [
"Полный снимок сущностей из баз WESP (ORM-таблицы: основная «recipes» и отчёты «reports»). "
"Используй только эти факты для вопросов про справочники, отчёты, синхронизацию и пользователей; "
"не выдумывай строки. Секреты и токены замаскированы. Показаны неудалённые записи (где есть is_deleted).",
"",
]
buf_len = 0
truncated = False
def add(s: str) -> bool:
nonlocal buf_len, truncated
if truncated:
return False
if buf_len + len(s) + 1 > _MAX_TOTAL_TEXT_CHARS:
lines.append("…(дальше обрезано лимитом размера снимка; откройте API /api/admin/llm/diagnostics для JSON.)")
truncated = True
return False
lines.append(s)
buf_len += len(s) + 1
return True
try:
with app.app_context():
session = db.session
by_bind: Dict[str, List[Type[Any]]] = {"recipes": [], "reports": []}
for cls in _all_concrete_models():
by_bind[_bind_label(cls)].append(cls)
for bind_key, title in (
("recipes", "Основная БД (recipes.db — рецепты, оборудование, синхронизация, пользователи, …)"),
("reports", "БД отчётов (reports.db — loading/unloading и т.д.)"),
):
if not add(f"=== {title} ==="):
break
for cls in by_bind.get(bind_key, []):
tname = cls.__tablename__
try:
total = _count_rows(session, cls)
rows = _fetch_rows(session, cls, _ROW_LIMIT_DEFAULT)
except Exception as ex:
if not add(
f"Таблица «{tname}» — ошибка чтения: {str(ex)[:200]}"
):
break
add("")
continue
if not add(
f"Таблица «{tname}» — записей (не удалённых): {total}; в снимке до {_ROW_LIMIT_DEFAULT} строк."
):
break
if not rows:
add(" (пусто)")
add("")
continue
for i, obj in enumerate(rows, 1):
d = _object_to_dict(obj)
compact = _compact_row_repr(tname, d)
if not add(f" {i}. {compact}"):
break
add("")
if truncated:
break
if truncated:
break
except Exception as e:
return (
"Снимок сущностей БД: ошибка при сборе — "
+ str(e)[:500]
+ ". Не выдумывай данные."
)
return "\n".join(lines).strip()
def _compact_row_repr(table: str, d: Dict[str, Any]) -> str:
"""Короткая строка для чата: имя/id и ключевые поля без простыни."""
parts: List[str] = []
# приоритетные ключи для читаемости
priority = (
"name",
"login",
"title",
"display_name",
"node_id",
"client_name",
"farm",
"gosnomer",
"model",
"status",
"recipe_id",
"component_id",
"dispenser_id",
"mixer_id",
"period_id",
"id",
)
used = set()
for k in priority:
if k in d and d[k] is not None and str(d[k]) != "":
parts.append(f"{k}={d[k]}")
used.add(k)
if len(parts) >= 6:
break
for k, v in sorted(d.items()):
if k in used or v is None or str(v) == "":
continue
if len(parts) >= 10:
parts.append("…")
break
parts.append(f"{k}={v}")
inner = "; ".join(parts) if parts else str(d)[:200]
return f"{table}: {inner}"