feat(run): per-frame detection log at logs/detections/YYYY-MM-DD.jsonl

Writes one JSONL line per detector.step() with ts, rgb, match_name,
distance, confidence, dot_found, window_found, accepted, color.
Captures UNKNOWN classifications and no-dot frames that today's
audit log skips, so the user can verify post-session what colors
the program actually saw.

Reuses AuditLog for daily rotation + buffering. Separate subdir
keeps audit.jsonl uncluttered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-16 14:48:27 +00:00
parent e7369ca632
commit f4b9000100

View File

@@ -447,6 +447,7 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
fsm = StateMachine(lockout_s=cfg.lockout_s)
canary = Canary(cfg, pause_flag_path=Path("logs/pause.flag"))
audit = AuditLog(Path("logs"))
detection_log = AuditLog(Path("logs/detections"))
backends = [
DiscordNotifier(cfg.discord.webhook_url),
TelegramNotifier(cfg.telegram.bot_token, cfg.telegram.chat_id),
@@ -503,6 +504,18 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
continue
# detection
res = detector.step(now)
detection_log.log({
"ts": now,
"event": "frame",
"window_found": res.window_found,
"dot_found": res.dot_found,
"rgb": list(res.rgb) if res.rgb is not None else None,
"match_name": res.match.name if res.match is not None else None,
"distance": round(res.match.distance, 2) if res.match is not None else None,
"confidence": round(res.match.confidence, 3) if res.match is not None else None,
"accepted": res.accepted,
"color": res.color,
})
if res.accepted and res.color:
is_first = first_accepted
first_accepted = False
@@ -569,6 +582,7 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
pass
notifier.stop()
audit.close()
detection_log.close()
def _build_capture(cfg, capture_stub: bool = False):