#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ run_flywheel.py — one command that closes the human-reinforcement loop. Stages (select with --stages, comma-separated, or "all"): DATA stages (run anywhere, incl. CI — no GPU needed): sync pull predictions + corrections from Supabase → JSONL (sync_supabase_feedback.py; needs SUPABASE_URL/-KEY, else skipped) ingest JSONL → YOLO dataset (ingest_feedback.py --download) report dataset/feedback statistics → flywheel_report.json TRAIN stages (GPU box / Colab; need `ultralytics` installed): train train_yolov8_seg.py on --data (default: the ingested dataset) calibrate calibrate_temp.py on the new artifacts evaluate evaluate.py on the new artifacts export export_onnx.py → candidate bundle (--candidate-out) RELEASE stages: gate quality_gate.py candidate vs deploy/latest (regression = stop) promote promote_bundle.py → deploy/ + deploy/latest + registry Typical uses: # CI / laptop: refresh training data + readiness report python ml/scripts/run_flywheel.py --stages sync,ingest,report # GPU box: full loop python ml/scripts/run_flywheel.py --stages all --data ml/datasets/merged/dataset.yaml Note on training data: the ingested user-feedback set alone is usually too small/biased for a full retrain. Merge it with the TACO base set first (ml/scripts/ingest_yolo_dataset.py / augment_external_datasets.py) and pass the merged dataset.yaml via --data. """ from __future__ import annotations import argparse import datetime as dt import json import os import subprocess import sys from collections import Counter from pathlib import Path from typing import Dict, List, Optional REPO = Path(__file__).resolve().parents[2] SCRIPTS = REPO / "ml" / "scripts" DATA_STAGES = ["sync", "ingest", "report"] TRAIN_STAGES = ["train", "calibrate", "evaluate", "export"] RELEASE_STAGES = ["gate", "promote"] ALL_STAGES = DATA_STAGES + TRAIN_STAGES + RELEASE_STAGES def run(cmd: List[str], allow_fail: bool = False) -> int: print(f"\n>>> {' '.join(str(c) for c in cmd)}", flush=True) rc = subprocess.call([str(c) for c in cmd]) if rc != 0 and not allow_fail: print(f"FLYWHEEL STOP — stage command failed (rc={rc}).") sys.exit(rc) return rc def dataset_stats(ds_dir: Path) -> Dict: stats: Dict = {"exists": ds_dir.exists(), "path": str(ds_dir)} if not ds_dir.exists(): return stats names_p = ds_dir / "names.json" names = [] if names_p.exists(): try: names = json.loads(names_p.read_text(encoding="utf-8")) except Exception: pass class_counts: Counter = Counter() for split in ("train", "val"): imgs = list((ds_dir / "images" / split).glob("*")) if (ds_dir / "images" / split).exists() else [] labels = list((ds_dir / "labels" / split).glob("*.txt")) if (ds_dir / "labels" / split).exists() else [] stats[split] = {"images": len(imgs), "labels": len(labels)} for lp in labels: try: for line in lp.read_text(encoding="utf-8").splitlines(): parts = line.split() if parts: cid = int(parts[0]) cname = names[cid] if 0 <= cid < len(names) else str(cid) class_counts[cname] += 1 except Exception: continue stats["class_counts"] = dict(class_counts) stats["classes"] = names return stats def newest_artifacts_dir(root: Path) -> Optional[Path]: if not root.exists(): return None candidates = [d for d in root.iterdir() if d.is_dir()] return max(candidates, key=lambda d: d.stat().st_mtime) if candidates else None def main(argv=None) -> int: ap = argparse.ArgumentParser(description="Run the Alami Vision human-reinforcement flywheel.") ap.add_argument("--stages", default="sync,ingest,report", help=f"comma-separated from {ALL_STAGES} or 'all'") ap.add_argument("--sync-out", default="feedback_logs_sync", type=Path) ap.add_argument("--dataset-out", default="ml/datasets/alami_user", type=Path) ap.add_argument("--data", default=None, type=Path, help="dataset.yaml for training (default: /dataset.yaml)") ap.add_argument("--artifacts-root", default="artifacts", type=Path) ap.add_argument("--candidate-out", default="deploy_candidate", type=Path) ap.add_argument("--max-samples", type=int, default=5000) ap.add_argument("--report-out", default="flywheel_report.json", type=Path) args = ap.parse_args(argv) stages = ALL_STAGES if args.stages.strip() == "all" else [s.strip() for s in args.stages.split(",") if s.strip()] unknown = [s for s in stages if s not in ALL_STAGES] if unknown: print(f"Unknown stages: {unknown}. Valid: {ALL_STAGES}") return 2 py = sys.executable report: Dict = {"stages_run": stages} # ---------------- DATA ---------------- if "sync" in stages: if os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_SERVICE_ROLE_KEY"): run([py, SCRIPTS / "sync_supabase_feedback.py", "--out", args.sync_out]) stats_p = args.sync_out / "sync_stats.json" if stats_p.exists(): report["sync"] = json.loads(stats_p.read_text(encoding="utf-8")) else: print("sync: SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY not set — skipping " "(will use local feedback_logs/ if present).") report["sync"] = {"skipped": "no supabase credentials"} if "ingest" in stages: pred_file = args.sync_out / "predictions.jsonl" fb_file = args.sync_out / "feedback.jsonl" if not pred_file.exists(): pred_file = Path("feedback_logs/predictions.jsonl") fb_file = Path("feedback_logs/feedback.jsonl") if pred_file.exists(): run([py, SCRIPTS / "ingest_feedback.py", "--predictions", pred_file, "--feedback", fb_file, "--out", args.dataset_out, "--max", str(args.max_samples), "--names", "deploy/latest/names.json", "--download"]) else: print("ingest: no predictions.jsonl found (neither synced nor local) — skipping.") report["ingest"] = {"skipped": "no source jsonl"} if "report" in stages: report["dataset"] = dataset_stats(args.dataset_out) args.report_out.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8") print(f"\nFlywheel report -> {args.report_out}") print(json.dumps(report.get("dataset", {}), indent=2)[:2000]) # ---------------- TRAIN ---------------- needs_training = any(s in stages for s in TRAIN_STAGES) artifacts_dir: Optional[Path] = None if needs_training: try: import ultralytics # noqa: F401 except ImportError: print("TRAIN stages need `ultralytics` (pip install ultralytics). " "Run these stages on a GPU box/Colab — data stages already completed.") return 3 data_yaml = args.data or (args.dataset_out / "dataset.yaml") if "train" in stages: run([py, SCRIPTS / "train_yolov8_seg.py", "--data", data_yaml, "--artifacts_root", args.artifacts_root]) if any(s in stages for s in ("calibrate", "evaluate", "export")): artifacts_dir = newest_artifacts_dir(args.artifacts_root) if artifacts_dir is None: print(f"No artifacts found under {args.artifacts_root} — did training run?") return 3 print(f"Using artifacts: {artifacts_dir}") if "calibrate" in stages: run([py, SCRIPTS / "calibrate_temp.py", "--artifacts_dir", artifacts_dir, "--dataset_yaml", data_yaml]) if "evaluate" in stages: run([py, SCRIPTS / "evaluate.py", "--artifacts_dir", artifacts_dir, "--dataset_yaml", data_yaml]) if "export" in stages: # A stale gate report must never survive a re-export (the hash binding # in promote_bundle would catch it, but don't even leave it around). (args.candidate_out / "gate_report.json").unlink(missing_ok=True) run([py, SCRIPTS / "export_onnx.py", "--artifacts_dir", artifacts_dir, "--out_dir", args.candidate_out, "--simplify"]) # ---------------- RELEASE ---------------- baseline = REPO / "deploy" / "latest" # absolute: gate must not depend on cwd if "gate" in stages: rc = run([py, SCRIPTS / "quality_gate.py", "--candidate", args.candidate_out, "--baseline", baseline], allow_fail=True) if rc != 0: print("Gate failed — candidate NOT promoted. Inspect gate_report.json.") return 1 if "promote" in stages: # Traceable version name: artifacts dir if we trained in this run, # else the candidate's model_card lineage, else a timestamp. version = artifacts_dir.name if artifacts_dir is not None else None if not version: try: card = json.loads((args.candidate_out / "model_card.json").read_text(encoding="utf-8")) version = Path(card.get("artifacts_dir", "")).name or None except Exception: version = None if not version: version = dt.datetime.now(dt.timezone.utc).strftime("v%Y%m%d-%H%M%S") run([py, SCRIPTS / "promote_bundle.py", "--candidate", args.candidate_out, "--version", version, "--deploy-root", REPO / "deploy", "--registry", REPO / "model_registry" / "registry.json"]) print("\nFlywheel done.") return 0 if __name__ == "__main__": sys.exit(main())