| 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() |
| ], |
| } |
|
|
| |
| |
| 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) |
|
|