from __future__ import annotations import json import re import time from datetime import datetime from pathlib import Path from typing import Any, Callable class ProjectHistoryStore: """Persistent project-package history, separate from normal chat history.""" def __init__(self, root: Path, validator: Callable[[str], bool], max_items: int = 40): self.root = Path(root) self.validator = validator self.max_items = max(5, int(max_items)) def _require_user(self, user_id: str) -> str: uid = str(user_id or "").strip() if not self.validator(uid): raise ValueError("invalid user_id") return uid def _path(self, user_id: str) -> Path: uid = self._require_user(user_id) return self.root / f"{uid}.project-history.json" @staticmethod def _cut(value: Any, n: int) -> str: return str(value or "")[:n] def sanitize_entry(self, item: Any) -> dict[str, Any] | None: if not isinstance(item, dict): return None project = self._cut(item.get("project"), 12000).strip() if not project: return None ident = self._cut(item.get("id"), 120) if not ident: ident = f"project-{int(time.time()*1000)}" title = self._cut(item.get("title"), 240).strip() if not title: title = re.sub(r"\s+", " ", project)[:42] options = item.get("options") if isinstance(item.get("options"), dict) else {} clean_options = { "include_ocean": bool(options.get("include_ocean", True)), "include_tuna": bool(options.get("include_tuna", True)), "include_squid": bool(options.get("include_squid", True)), "max_package_mb": max(20, min(int(options.get("max_package_mb") or 300), 1200)), "selected_ocean_keys": [ self._cut(x, 160) for x in (options.get("selected_ocean_keys") or [])[:80] if str(x or "").strip() ], "selected_fisheries_names": [ self._cut(x, 240) for x in (options.get("selected_fisheries_names") or [])[:80] if str(x or "").strip() ], } # Plans/results are already JSON from our own APIs. Bound total size by # round-tripping and trimming oversized snapshots. plan = item.get("plan") if isinstance(item.get("plan"), dict) else None estimate = item.get("estimate") if isinstance(item.get("estimate"), dict) else None build = item.get("build") if isinstance(item.get("build"), dict) else None def bounded(value, limit): if value is None: return None try: raw = json.dumps(value, ensure_ascii=False) if len(raw) <= limit: return value except Exception: pass return None now = int(time.time() * 1000) return { "id": ident, "title": title, "project": project, "stage": self._cut(item.get("stage") or "analyzed", 40), "options": clean_options, "plan": bounded(plan, 180000), "estimate": bounded(estimate, 120000), "build": bounded(build, 120000), "created_at": int(item.get("created_at") or now), "updated_at": int(item.get("updated_at") or now), } def read(self, user_id: str) -> list[dict[str, Any]]: path = self._path(user_id) if not path.exists(): return [] try: raw = json.loads(path.read_text(encoding="utf-8")) except Exception: return [] items = raw.get("items", []) if isinstance(raw, dict) else raw out = [] for item in items if isinstance(items, list) else []: clean = self.sanitize_entry(item) if clean: out.append(clean) out.sort(key=lambda x: int(x.get("updated_at") or 0), reverse=True) return out[:self.max_items] def upsert(self, user_id: str, item: Any) -> list[dict[str, Any]]: clean = self.sanitize_entry(item) if clean is None: raise ValueError("project is required") current = self.read(user_id) by_id = {str(x.get("id")): x for x in current} old = by_id.get(clean["id"]) if old: clean["created_at"] = int(old.get("created_at") or clean["created_at"]) clean["updated_at"] = int(time.time() * 1000) merged = [clean] + [x for x in current if str(x.get("id")) != clean["id"]] self._write(user_id, merged[:self.max_items]) return merged[:self.max_items] def delete(self, user_id: str, item_id: str) -> list[dict[str, Any]]: ident = self._cut(item_id, 120) current = [x for x in self.read(user_id) if str(x.get("id")) != ident] self._write(user_id, current) return current def _write(self, user_id: str, items: list[dict[str, Any]]) -> None: path = self._path(user_id) path.parent.mkdir(parents=True, exist_ok=True) payload = { "schema": "squid-project-package-history", "schema_version": 1, "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "items": items[:self.max_items], } tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") tmp.replace(path)