"""Полный снимок сущностей из БД 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"" 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}"