Spaces:
Running
Running
Download src/miniweb/annotation/storage.py from sparklabuofu/MiniWeb-demo: direct link, hf CLI and curl.
- Browser
- Download file 9.45 kB
-
https://huggingface.co/spaces/sparklabuofu/MiniWeb-demo/resolve/main/src/miniweb/annotation/storage.py
- Command line
-
hf download hf://spaces/sparklabuofu/MiniWeb-demo/src/miniweb/annotation/storage.py
-
curl -L -o storage.py https://huggingface.co/spaces/sparklabuofu/MiniWeb-demo/resolve/main/src/miniweb/annotation/storage.py
9.45 kB
| """File-based annotation storage. | |
| Layout: | |
| {ANNOTATIONS_DIR}/{annotator}/{task_id}/task.json | |
| {ANNOTATIONS_DIR}/{annotator}/{task_id}/trajectory.json | |
| Each task.json contains: instruction, macros, sites, verifiers, expected_answer, etc. | |
| Each trajectory.json contains the full trajectory array (actions, network, observations). | |
| """ | |
| from helpers.paths import REPO_ROOT | |
| import json | |
| import os | |
| from datetime import datetime | |
| from pathlib import Path | |
| # resolve(): a relative MINIWEB_ANNOTATIONS_DIR (e.g. "data/annotations" in | |
| # .env) would otherwise be resolved against app.root_path by Flask helpers | |
| # like send_from_directory, silently pointing inside the app package. | |
| ANNOTATIONS_DIR = Path( | |
| os.environ.get("MINIWEB_ANNOTATIONS_DIR", | |
| str(REPO_ROOT / "data" / "annotations")) | |
| ).resolve() | |
| def _task_dir(annotator: str, task_id: str) -> Path: | |
| return ANNOTATIONS_DIR / annotator / task_id | |
| def generate_task_id() -> str: | |
| """Generate a unique task ID based on timestamp.""" | |
| return datetime.now().strftime("task_%Y%m%d_%H%M%S") | |
| def save_task(annotator: str, task_id: str, task_data: dict, trajectory: list): | |
| """Save a task and all its logs to the file system. | |
| Saves: | |
| task.json β instruction, macros, sites, answer, outcome | |
| trajectory.json β actions + network entries from recorder.js | |
| server_log.json β HTTP request/response log from /_admin/log | |
| beacon_log.json β UI action beacon log from /_admin/beacon | |
| """ | |
| d = _task_dir(annotator, task_id) | |
| d.mkdir(parents=True, exist_ok=True) | |
| # Real captured frames arrive inline as data URLs β write them out as | |
| # screenshot files before the trajectory is dumped | |
| _extract_inline_screenshots(d, trajectory) | |
| # Extract logs and agent data before saving task metadata | |
| server_log = task_data.pop("server_log", []) | |
| beacon_log = task_data.pop("beacon_log", []) | |
| agent_result = task_data.pop("agent_result", None) | |
| # Save task metadata | |
| task_data["task_id"] = task_id | |
| task_data["annotator"] = annotator | |
| task_data["saved_at"] = datetime.now().isoformat() | |
| task_data["trajectory_actions"] = sum(1 for e in trajectory if e.get("type") == "action") | |
| task_data["trajectory_network"] = sum(1 for e in trajectory if e.get("type") == "network") | |
| with open(d / "task.json", "w") as f: | |
| json.dump(task_data, f, indent=2, default=str) | |
| # Save trajectory (actions + network from recorder.js) | |
| with open(d / "trajectory.json", "w") as f: | |
| json.dump(trajectory, f, indent=2, default=str) | |
| # Save server-side logs β trimmed to THIS recording's time window. The | |
| # session request log accumulates everything the annotator's browser did | |
| # (other tasks, free browsing, playback iframes); only entries inside the | |
| # trajectory's own timestamp span are this recording's evidence. | |
| if server_log: | |
| try: | |
| from miniweb.evaluation.trajectory import filter_log_to_window | |
| server_log = filter_log_to_window(trajectory, server_log) | |
| except Exception: | |
| pass | |
| with open(d / "server_log.json", "w") as f: | |
| json.dump(server_log, f, indent=2, default=str) | |
| if beacon_log: | |
| with open(d / "beacon_log.json", "w") as f: | |
| json.dump(beacon_log, f, indent=2, default=str) | |
| if agent_result: | |
| with open(d / "agent_result.json", "w") as f: | |
| json.dump(agent_result, f, indent=2, default=str) | |
| def _extract_inline_screenshots(task_dir: Path, events: list): | |
| """Write inline captured frames (obs["screenshot_data"] data URLs) to | |
| screenshots/step_NNN files and reference them from the observation. | |
| The step number is the ordinal of the action the observation belongs to, | |
| numbered exactly like backfill_observations._pair_events (each action | |
| pairs with the first observation before the next action) β the backfill's | |
| skip-gate matches on the referenced file, so parity keeps real captures | |
| from being re-rendered over. | |
| """ | |
| import base64 | |
| action_no = -1 | |
| paired = False # current action already has its observation | |
| for e in events: | |
| if e.get("type") == "action": | |
| action_no += 1 | |
| paired = False | |
| continue | |
| if e.get("type") != "observation": | |
| continue | |
| data = e.pop("screenshot_data", None) | |
| if paired or action_no < 0: | |
| continue # unpaired observation β drop any frame | |
| paired = True | |
| if not data: | |
| continue | |
| try: | |
| header, b64 = data.split(",", 1) | |
| ext = "png" if "png" in header else "jpg" | |
| raw = base64.b64decode(b64) | |
| shots = task_dir / "screenshots" | |
| shots.mkdir(exist_ok=True) | |
| name = f"step_{action_no:03d}.{ext}" | |
| (shots / name).write_bytes(raw) | |
| e["screenshot"] = f"screenshots/{name}" | |
| e["screenshot_captured"] = True | |
| e["screenshot_full_page"] = False | |
| except (ValueError, OSError): | |
| pass # bad frame β derivation covers it | |
| def load_task(annotator: str, task_id: str) -> dict | None: | |
| """Load a task with all its data.""" | |
| d = _task_dir(annotator, task_id) | |
| task_file = d / "task.json" | |
| if not task_file.exists(): | |
| return None | |
| task = json.loads(task_file.read_text()) | |
| # Load trajectory | |
| traj_file = d / "trajectory.json" | |
| task["trajectory"] = json.loads(traj_file.read_text()) if traj_file.exists() else [] | |
| # Load agent result | |
| agent_file = d / "agent_result.json" | |
| if agent_file.exists(): | |
| task["agent_result"] = json.loads(agent_file.read_text()) | |
| # Load server logs (for verifier builder) | |
| for log_name in ("server_log", "beacon_log"): | |
| log_file = d / f"{log_name}.json" | |
| if log_file.exists(): | |
| task[log_name] = json.loads(log_file.read_text()) | |
| return task | |
| def list_tasks(annotator: str = None, newest_first: bool = True) -> list[dict]: | |
| """List all tasks, optionally filtered by annotator. | |
| Returns lightweight metadata (no trajectory). | |
| """ | |
| tasks = [] | |
| if annotator: | |
| annotators = [annotator] | |
| else: | |
| if not ANNOTATIONS_DIR.exists(): | |
| return [] | |
| annotators = [ | |
| d.name for d in ANNOTATIONS_DIR.iterdir() | |
| if d.is_dir() and not d.name.startswith(".") | |
| ] | |
| for ann in annotators: | |
| ann_dir = ANNOTATIONS_DIR / ann | |
| if not ann_dir.exists(): | |
| continue | |
| for task_dir in sorted(ann_dir.iterdir()): | |
| task_file = task_dir / "task.json" | |
| if task_file.exists(): | |
| try: | |
| task = json.loads(task_file.read_text()) | |
| if not task: | |
| continue | |
| # Don't include trajectory in list view | |
| task.pop("trajectory", None) | |
| # Directory name is authoritative when task.json lacks it | |
| task.setdefault("annotator", ann) | |
| tasks.append(task) | |
| except (json.JSONDecodeError, OSError): | |
| pass | |
| def last_modified(task): | |
| saved_at = task.get("saved_at", "") | |
| review_at = (task.get("review_tag") or {}).get("at", "") | |
| return max(saved_at, review_at) | |
| return sorted(tasks, key=last_modified, reverse=newest_first) | |
| def delete_task(annotator: str, task_id: str) -> bool: | |
| """Delete a task and its trajectory.""" | |
| import shutil | |
| d = _task_dir(annotator, task_id) | |
| if d.exists(): | |
| shutil.rmtree(d) | |
| return True | |
| return False | |
| def trash_task(annotator: str, task_id: str) -> str | None: | |
| """Move a task dir into the hidden trash (recoverable delete). | |
| Destination is {ANNOTATIONS_DIR}/.trash/{annotator}/{task_id}-{timestamp}; | |
| the timestamp suffix keeps every generation when a task is trashed more | |
| than once (e.g. repeated re-records). Returns the destination path, or | |
| None when the task doesn't exist. The dot-prefix keeps the trash out of | |
| list_tasks/get_annotators/get_stats. | |
| """ | |
| import shutil | |
| d = _task_dir(annotator, task_id) | |
| if not d.exists(): | |
| return None | |
| stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") | |
| dest = ANNOTATIONS_DIR / ".trash" / annotator / f"{task_id}-{stamp}" | |
| n = 0 | |
| while dest.exists(): # same-instant collision: shutil.move would nest | |
| n += 1 | |
| dest = dest.with_name(f"{task_id}-{stamp}-{n}") | |
| dest.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.move(str(d), str(dest)) | |
| return str(dest) | |
| def get_annotators() -> list[str]: | |
| """List all annotator names.""" | |
| if not ANNOTATIONS_DIR.exists(): | |
| return [] | |
| return sorted(d.name for d in ANNOTATIONS_DIR.iterdir() | |
| if d.is_dir() and not d.name.startswith(".")) | |
| def get_stats() -> dict: | |
| """Get annotation statistics.""" | |
| stats = {"total_tasks": 0, "annotators": {}} | |
| if not ANNOTATIONS_DIR.exists(): | |
| return stats | |
| for ann_dir in ANNOTATIONS_DIR.iterdir(): | |
| if not ann_dir.is_dir() or ann_dir.name.startswith("."): | |
| continue | |
| count = sum(1 for t in ann_dir.iterdir() if (t / "task.json").exists()) | |
| stats["annotators"][ann_dir.name] = count | |
| stats["total_tasks"] += count | |
| return stats | |