alami-vision-api / ml /scripts /sync_supabase_feedback.py
alami-ci
Deploy from alami-eco/alami-trash-ai@aee69796b70947e95efdb9c7483fa52f8d3b4520
76838d6
Raw
History Blame Contribute Delete
9.94 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sync_supabase_feedback.py — pull predictions + human corrections from Supabase
into the JSONL format that ingest_feedback.py consumes.
Why: the HF Space's local feedback_logs/*.jsonl are ephemeral (lost on every
restart). Supabase (`trash_predictions`) is the durable store — this script
closes the gap between "app users correct labels" and "training dataset".
Reads : Supabase table trash_predictions (service role)
Writes : <out>/predictions.jsonl — {prediction_id, ts, image_url, user_id,
model_version, predictions:[{xyxy,cls,conf,label,raw_label}]}
<out>/feedback.jsonl — {prediction_id, ts, corrected_type,
corrected_weight_kg, notes, source, corrected_items}
Rows without a per-object `predictions` JSONB array cannot yield YOLO boxes;
they are counted and skipped for predictions.jsonl (apply the migration in
docs/FLYWHEEL.md so the server stores full boxes).
Usage:
SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... \
python ml/scripts/sync_supabase_feedback.py --out feedback_logs_sync
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("sync_supabase_feedback")
PAGE_SIZE = 1000
# Rows whose `source` is in this set feed LITTER-detection training. Anything
# else (e.g. "product-scan" = supermarket shelf photos) goes to a separate
# pool file — kept for other purposes (brand radar!) but NEVER mixed into the
# litter training set, where it would hurt detection quality.
LITTER_SOURCES = {None, "", "alami-mobile"}
# Known test/dummy rows (e2e wiring checks) that must never become training
# data. One id or unique id-prefix per line, '#' comments.
DEFAULT_EXCLUDE_FILE = Path(__file__).resolve().parents[1] / "flywheel" / "exclude_predictions.txt"
def load_exclusions(path: Optional[Path]) -> Tuple[str, ...]:
"""Read id/prefix exclusion list; missing file -> empty (never fails)."""
if path is None or not path.is_file():
return ()
prefixes = []
for line in path.read_text(encoding="utf-8").splitlines():
entry = line.split("#", 1)[0].strip()
if entry:
prefixes.append(entry)
return tuple(prefixes)
def row_to_prediction_entry(row: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Transform one trash_predictions row into a predictions.jsonl entry.
Returns None when the row carries no usable per-object boxes.
"""
preds = row.get("predictions")
if isinstance(preds, str):
try:
preds = json.loads(preds)
except Exception:
preds = None
if not isinstance(preds, list) or not preds:
return None
boxes = []
for b in preds:
if not isinstance(b, dict):
continue
xyxy = b.get("xyxy")
if not isinstance(xyxy, list) or len(xyxy) < 4:
continue
boxes.append({
"xyxy": [float(x) for x in xyxy[:4]],
"cls": int(b.get("cls", 0)),
"conf": float(b.get("conf", 0.0)),
"label": str(b.get("label", "")),
"raw_label": str(b.get("raw_label", b.get("label", ""))),
})
if not boxes:
return None
if not row.get("image_url") or not row.get("prediction_id"):
return None
return {
"prediction_id": str(row["prediction_id"]),
"ts": row.get("created_at"),
"image_url": row["image_url"],
"user_id": row.get("user_id"),
"model_version": row.get("model_version"),
"predictions": boxes,
}
def row_to_feedback_entry(row: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Transform one corrected trash_predictions row into a feedback.jsonl entry."""
if not row.get("corrected_at") or not row.get("prediction_id"):
return None
if row.get("corrected_type") is None and row.get("corrected_weight_kg") is None \
and not row.get("corrected_items") and not row.get("added_items"):
return None
def _maybe_json(v: Any) -> Any:
"""jsonb columns arrive as objects; some drivers hand back a JSON string."""
if isinstance(v, str):
try:
return json.loads(v)
except Exception:
return None
return v
return {
"prediction_id": str(row["prediction_id"]),
"ts": row.get("corrected_at"),
"corrected_type": row.get("corrected_type"),
"corrected_weight_kg": row.get("corrected_weight_kg"),
"notes": row.get("notes"),
"source": row.get("feedback_source"),
"corrected_items": _maybe_json(row.get("corrected_items")),
# v2 (#141): recall signal (objects the AI missed) + failure chips
"added_items": _maybe_json(row.get("added_items")),
"reasons": _maybe_json(row.get("feedback_reasons")),
}
def fetch_all_rows(url: str, key: str, table: str) -> List[Dict[str, Any]]:
from supabase import create_client
sb = create_client(url, key)
rows: List[Dict[str, Any]] = []
offset = 0
while True:
resp = (sb.table(table)
.select("*")
.order("created_at", desc=False)
.range(offset, offset + PAGE_SIZE - 1)
.execute())
page = resp.data or []
rows.extend(page)
if len(page) < PAGE_SIZE:
break
offset += PAGE_SIZE
return rows
def write_jsonl(path: Path, entries: List[Dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
for e in entries:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
def transform_rows(rows: List[Dict[str, Any]],
exclude: Tuple[str, ...] = ()) -> Tuple[List[Dict], List[Dict], List[Dict], Dict[str, Any]]:
"""Pure transform: rows -> (litter predictions, other-source predictions,
feedback entries, stats). Pool separation happens HERE: non-litter sources
(product-scan etc.) never reach the litter training set. Rows whose
prediction_id matches an `exclude` prefix (known test/dummy rows) are
dropped from EVERY output."""
pred_entries: List[Dict] = []
other_entries: List[Dict] = []
fb_entries: List[Dict] = []
no_boxes = 0
excluded = 0
other_by_source: Dict[str, int] = {}
for row in rows:
pid = str(row.get("prediction_id") or "")
if pid and any(pid.startswith(x) for x in exclude):
excluded += 1
continue
p = row_to_prediction_entry(row)
src = row.get("source")
if p is not None:
if src in LITTER_SOURCES:
pred_entries.append(p)
else:
p["source"] = src
other_entries.append(p)
other_by_source[str(src)] = other_by_source.get(str(src), 0) + 1
elif row.get("prediction_id") and row.get("image_url"):
no_boxes += 1
fb = row_to_feedback_entry(row)
if fb is not None and src in LITTER_SOURCES:
fb_entries.append(fb)
stats = {
"rows_total": len(rows),
"rows_excluded_testdata": excluded,
"predictions_with_boxes": len(pred_entries),
"predictions_other_sources": len(other_entries),
"other_sources_breakdown": other_by_source,
"predictions_without_boxes": no_boxes,
"feedback_entries": len(fb_entries),
}
return pred_entries, other_entries, fb_entries, stats
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Sync trash_predictions from Supabase to ingest-ready JSONL.")
ap.add_argument("--out", default="feedback_logs_sync", help="output directory")
ap.add_argument("--table", default=os.environ.get("SB_TABLE_PREDICTIONS", "trash_predictions"))
ap.add_argument("--exclude-file", default=str(DEFAULT_EXCLUDE_FILE),
help="id/prefix blocklist for known test rows (default: ml/flywheel/exclude_predictions.txt)")
args = ap.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
url = os.environ.get("SUPABASE_URL")
key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
if not url or not key:
logger.error("SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY not set — nothing to sync.")
return 2
exclude = load_exclusions(Path(args.exclude_file))
rows = fetch_all_rows(url, key, args.table)
pred_entries, other_entries, fb_entries, stats = transform_rows(rows, exclude=exclude)
if stats["rows_excluded_testdata"]:
logger.info("Excluded %d known test/dummy row(s) via %s.",
stats["rows_excluded_testdata"], args.exclude_file)
out = Path(args.out)
write_jsonl(out / "predictions.jsonl", pred_entries)
write_jsonl(out / "predictions_other_sources.jsonl", other_entries)
write_jsonl(out / "feedback.jsonl", fb_entries)
(out / "sync_stats.json").write_text(json.dumps(stats, indent=2), encoding="utf-8")
logger.info("Synced %d rows: %d litter predictions, %d other-source (separate pool: %s), "
"%d without boxes (need migration), %d feedback entries.",
stats["rows_total"], stats["predictions_with_boxes"],
stats["predictions_other_sources"], stats["other_sources_breakdown"],
stats["predictions_without_boxes"], stats["feedback_entries"])
if stats["predictions_without_boxes"] > 0 and stats["predictions_with_boxes"] == 0:
logger.warning("No rows carry per-object boxes — apply the 'predictions jsonb' migration "
"(docs/FLYWHEEL.md) so new predictions become trainable.")
return 0
if __name__ == "__main__":
sys.exit(main())