Files
ai-agent/scripts/backfill-artefacts.py
Gabriel Vidal 7c66eaf38f feat(ai-agent): Artefacts card — per-conversation generated-media gallery
Conversation details drawer gains an Artefacts card: a horizontal strip of
image/video thumbnails collected from the conversation's projects'/services'
.ai/artefacts/<date>/<sessionId>/ folders, opening the full-screen media
viewer seeded with the whole set.

- backend/artefacts.py: list a conversation's artefacts across its tagged
  projects/services + traversal-safe single-file resolution; wired into
  /api/conversation (artefacts field) and a new GET /api/artefact serve route.
- ImageWidget maps Read paths under <project|service>/.ai/artefacts/ to the
  new route (inline preview keeps working with the new convention);
  ScreenshotWidget recognizes both the new artefact output path and the
  legacy docs/screenshots one.
- scripts/backfill-artefacts.py: one-off migration that mines archived
  transcripts for docs/screenshots/ + data/ media mentions and copies the
  surviving files into the right project's artefact folder (400 files across
  120 past conversations imported).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 23:59:19 +02:00

166 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""One-off backfill: copy past conversations' generated artifacts into the
per-project artefact folders the viewer now serves.
Scans every archived transcript (services/ai-agent/data/transcripts/**), finds
mentions of generated media — docs/screenshots/… captures and images/videos
under the repo's data/ tree — and copies each file that still exists into
<projectFolder>/.ai/artefacts/<conv-start-date>/<sessionId>/<file>
where <projectFolder> is the canonical dir of the conversation's first
project/service tag (manual conv-meta tags first, then slugs auto-mined from
the transcript the same way the backend does). Conversations with no
resolvable project folder, and mentioned files that no longer exist on disk,
are skipped. Idempotent: existing targets are never overwritten.
Run on the host from the homelab main checkout:
python3 services/ai-agent/scripts/backfill-artefacts.py [--dry-run]
"""
from __future__ import annotations
import json
import re
import shutil
import sys
from collections import Counter
from pathlib import Path
HOME = Path.home()
HOMELAB = HOME / "homelab"
PROJECTS = HOME / "projects"
TRANSCRIPTS = HOMELAB / "services/ai-agent/data/transcripts"
META = HOMELAB / "services/ai-agent/data/conversations-meta.json"
MEDIA_EXT = r"(?:jpe?g|png|webp|gif|bmp|avif|mp4|webm|mov|m4v|ogv)"
# Generated-artifact locations only: screenshot-skill captures and the repo's
# data/ tree (the old CLAUDE.md artifact convention). Plain source-asset reads
# (public/images/…) are intentionally not treated as artefacts.
SHOT_RE = re.compile(rf"docs/screenshots/([A-Za-z0-9._/-]+\.{MEDIA_EXT})", re.I)
DATA_RE = re.compile(rf"(?:^|[/\s\"'`])data/([A-Za-z0-9._/-]+\.{MEDIA_EXT})", re.I)
# Same slug mining as backend/conversations.py (_PROJECT_RE/_SERVICE_RE).
PROJECT_RE = re.compile(r"(?:^|[/\s])projects/([A-Za-z0-9._][\w.-]*)")
SERVICE_RE = re.compile(r"(?:^|[/\s])services/([A-Za-z0-9._][\w.-]*)")
TS_RE = re.compile(r'"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2})')
def project_dir(slug: str) -> Path | None:
for base in (PROJECTS, HOMELAB / "services"):
d = base / Path(slug).name
if d.is_dir() and not slug.startswith("."):
return d
return None
def dest_dir(meta: dict, text: str) -> Path | None:
"""The conversation's canonical project folder: manual tags win, then the
most-mentioned auto-mined slug that maps to an existing directory."""
for slug in list(meta.get("projects") or []) + list(meta.get("services") or []):
d = project_dir(slug)
if d:
return d
counts: Counter[str] = Counter()
for rx in (PROJECT_RE, SERVICE_RE):
counts.update(m.group(1) for m in rx.finditer(text))
for slug, _n in counts.most_common():
d = project_dir(slug)
if d:
return d
return None
def transcripts() -> list[tuple[str, Path]]:
"""(sessionId, transcript path) pairs: main transcripts plus subagent
sidechains (attributed to their parent conversation id)."""
out = []
for p in sorted(TRANSCRIPTS.glob("*/*.jsonl")):
out.append((p.stem, p))
for p in sorted(TRANSCRIPTS.glob("*/*/subagents/agent-*.jsonl")):
out.append((p.parent.parent.name, p))
return out
def main() -> int:
dry = "--dry-run" in sys.argv
try:
meta_all = json.loads(META.read_text())
except Exception:
meta_all = {}
copied = skipped_missing = existing = 0
convs_with = 0
per_dest: Counter[str] = Counter()
no_dest: list[str] = []
dates: dict[str, str] = {} # parent sid -> conv start date
for sid, path in transcripts():
try:
text = path.read_text(errors="replace")
except OSError:
continue
rels = [(HOMELAB / "docs/screenshots" / m.group(1), m.group(1))
for m in SHOT_RE.finditer(text)]
rels += [(HOMELAB / "data" / m.group(1), m.group(1))
for m in DATA_RE.finditer(text)]
if not rels:
continue
dest = dest_dir(meta_all.get(sid) or {}, text)
if dest is None:
no_dest.append(sid[:8])
continue
if sid not in dates:
m = TS_RE.search(text)
dates[sid] = m.group(1) if m else "unknown-date"
date = dates[sid]
seen: set[Path] = set()
conv_hit = False
for src, _rel in rels:
if src in seen:
continue
seen.add(src)
if not src.is_file():
skipped_missing += 1
continue
target = dest / ".ai" / "artefacts" / date / sid / src.name
if target.exists():
existing += 1
continue
conv_hit = True
copied += 1
per_dest[str(dest)] += 1
if not dry:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, target)
# Keep standalone project repos clean (homelab's .gitignore
# already covers services/*/.ai/artefacts/).
gitdir = dest / ".git"
if gitdir.is_dir():
excl = gitdir / "info" / "exclude"
try:
cur = excl.read_text() if excl.is_file() else ""
if ".ai/" not in cur.splitlines():
excl.parent.mkdir(parents=True, exist_ok=True)
excl.write_text(cur.rstrip("\n") + "\n.ai/\n"
if cur else ".ai/\n")
except OSError:
pass
if conv_hit:
convs_with += 1
print(f"{'DRY RUN — ' if dry else ''}copied {copied} artefacts "
f"across {convs_with} conversations "
f"({existing} already present, {skipped_missing} mentioned files gone)")
for d, n in per_dest.most_common():
print(f" {n:4} {d}")
if no_dest:
print(f" (no project folder resolvable for {len(no_dest)} conversations "
f"with media mentions: {', '.join(no_dest[:12])}"
f"{'' if len(no_dest) > 12 else ''})")
return 0
if __name__ == "__main__":
sys.exit(main())