@@ -0,0 +1,315 @@
|
||||
"""Полный снимок сущностей из БД 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}"
|
||||
Reference in New Issue
Block a user