"""Explicitly remove attachments belonging to conversations without v2 image observations. The command is dry-run by default. It only mutates data when ``--apply`` is provided, and it never removes CAD task artifacts. """ from __future__ import annotations import argparse import shutil from pathlib import Path import sys BACKEND_ROOT = Path(__file__).resolve().parents[1] if str(BACKEND_ROOT) not in sys.path: sys.path.insert(0, str(BACKEND_ROOT)) from app.services.storage import read_json, write_json from app.settings import get_settings def _has_v2_observation(record: dict) -> bool: for message in record.get("messages") or []: if not isinstance(message, dict): continue for part in message.get("parts") or []: if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": continue data = part.get("data") if isinstance(data, dict) and str(data.get("schemaVersion") or data.get("schema_version") or "") == "cad.image-observation.v2": return True return False def plan_cleanup(root: Path) -> list[tuple[Path, str]]: planned: list[tuple[Path, str]] = [] for conversation_dir in sorted(root.glob("conv_*")): record_path = conversation_dir / "conversation.json" record = read_json(record_path) if not isinstance(record, dict) or _has_v2_observation(record): continue uploads = conversation_dir / "uploads" if uploads.is_dir(): planned.append((uploads, "legacy upload directory")) planning = conversation_dir / "planning" if planning.is_dir(): planned.append((planning, "legacy planning directory")) if record.get("attachments"): planned.append((record_path, "remove legacy attachment metadata and image-analysis parts")) return planned def apply_cleanup(root: Path) -> int: changed = 0 for conversation_dir in sorted(root.glob("conv_*")): record_path = conversation_dir / "conversation.json" record = read_json(record_path) if not isinstance(record, dict) or _has_v2_observation(record): continue for relative in ("uploads", "planning"): target = conversation_dir / relative if target.is_dir(): shutil.rmtree(target) changed += 1 record["attachments"] = [] for message in record.get("messages") or []: if not isinstance(message, dict): continue message["parts"] = [ part for part in message.get("parts") or [] if not (isinstance(part, dict) and part.get("type") == "data-cad-image-analysis") ] write_json(record_path, record) changed += 1 return changed def main() -> int: parser = argparse.ArgumentParser(description="Remove legacy conversation attachments") parser.add_argument("--apply", action="store_true", help="perform deletion; default is dry-run") parser.add_argument("--dry-run", action="store_true", help="list deletion targets without changing files") args = parser.parse_args() root = get_settings().conversation_root.resolve() if root.name != "conversations": raise SystemExit(f"Refusing unexpected conversation root: {root}") planned = plan_cleanup(root) for path, reason in planned: print(f"{'DELETE' if args.apply else 'WOULD DELETE'} {path} ({reason})") if not args.apply: print(f"Dry-run: {len(planned)} targets. Re-run with --apply to delete.") return 0 print(f"Deleted {apply_cleanup(root)} conversation records/directories.") return 0 if __name__ == "__main__": raise SystemExit(main())