316 lines
10 KiB
Python
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}"
|