squid-main-space / services /project_history.py
Zin299's picture
Deploy v3.7.3 project history sidebar fix
be9670c
Raw
History Blame
5.57 kB
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)