diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..01a55c7 --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,111 @@ +# Handover Notes + +Stand: 2026-05-03 (Beat-20-Reparatur abgeschlossen). + +## Zustand + +- `pytest tests/ -q` → 52/52 grün. +- `python cli.py match --beat 20 --vision` läuft erfolgreich durch und schreibt + einen confirmed Match (Score 0.6632, scene 613, in=5284.706s, dur=0.88s). +- Vorheriger Cache wurde nach `.cache/match_results.json.bak` gesichert. +- Kein offener PR; lokale Änderungen sind committed (siehe letzter Commit). + +## Was zuletzt geändert wurde und warum + +### 1. `cli.py` — `realign_window` wählt das Action-Window pro Segment + +In `_filter_semantically_invalid_vision_matches.realign_window`: + +- **Vorher:** `find_action_window_in_scene(action_beat or check_beat, …)` — bei + segmentierten Beats wurde immer der ganze Beat als semantischer Kontext + benutzt. Das hat für Beat 20 die Source-Position auf die Kuss-Phase + (5270 s) gelegt, obwohl das *sichtbare* Segment nur "approaching and pulling + apart" zeigt — diese Phase liegt im Source erst um 5284 s. +- **Jetzt:** Es werden zwei Fenster gesucht (Segment-Beschreibung *und* Beat- + Beschreibung). Der Beat-Kontext gewinnt nur bei deutlichem (>0.06) Score- + Vorsprung. Der Trailer-Offset-Shift (`visible_content_offset`) wird nur + angewendet, wenn tatsächlich der Beat-Kontext benutzt wurde — sonst zeigt + das Segment-Fenster bereits auf die richtige Phase. + +Effekt für Beat 20: 5270.118 → 5284.706, Score 0.6449 (provisional) → 0.6632 +(confirmed). + +### 2. `cli.py` — Filter-/Repair-Stufe ist crash-tolerant + +`_filter_semantically_invalid_vision_matches` hat den Per-Result-Body in eine +lokale Funktion `_filter_repair_one` herausgezogen und in einen try/except +verpackt. Wenn die Reparatur abbricht (z. B. weil Vision-API mitten in der +Antwort wegfällt), wird der bisher gecachte Treffer behalten statt komplett +verworfen. + +### 3. `src/llm/vision_cache.py` — Vision-Retry für Lesefehler + +`_call_vision_model` fängt jetzt zusätzlich `TimeoutError`, +`socket.timeout`, `ConnectionError` und `OSError` während des Antwort-Lesens +und retryt mit demselben Backoff wie HTTP-/URL-Fehler. Die Auslöse-Bedingung +war ein 24-h-DSL-Disconnect mitten im Lauf; davor wurde der Match-Lauf hart +abgebrochen und der Cache stand auf "kein Match". + +### 4. `README.md` + +Zwei kurze Absätze ergänzt, die (1) die Segment-vs-Beat-Window-Auswahl und +(2) das neue Crash-/Netzfehler-Verhalten beschreiben. + +## Nicht angefasst, aber relevant für die Übergabe + +- Der **vollständige FFmpeg-Vollscan** liefert für Beat 20 weiterhin keinen + bestätigten Treffer (final score 0.419 < provisional 0.430). Den + Confirmed-Match liefert die Action-Window-Reparatur. Das ist erwartet: + das sichtbare Segment ist visuell sehr generisch (Two-Shot Profil mit + unscharfem Hintergrund), die korrekte Phase fällt erst durch die + semantische Aktionsbeschreibung auf. +- Die `candidate_points`-Schleife in `realign_window` (lines ~700–765) sucht + nur ±~2 s um `start_s` herum. Solange `start_s` jetzt aus dem Segment- + Fenster kommt, liegt der korrekte Source-Punkt in diesem Bereich. Wenn + künftig Beats mit längeren visiblen Inseln auftauchen, kann diese Range + zu eng werden — dann den Suchradius erweitern statt das Window-Picking + rückgängig machen. +- Es gibt **keine Tests** für `_filter_semantically_invalid_vision_matches` + oder `realign_window`. Wer das anfasst, sollte Beat 20 als Live-Smoke-Test + benutzen (siehe unten). + +## Reproduktion / Smoke-Test + +```powershell +.\.venv\Scripts\Activate.ps1 +python cli.py match --beat 20 --vision +``` + +Erwartet: `Beat 20: realigned semantically valid long scene by motion/action +windows`, danach `is_confirmed: true` für Beat 20 in +`.cache/match_results.json` mit `in_point_s ≈ 5284.7` und `match_score ≥ 0.65`. + +Wenn das fehlschlägt: + +1. `python -m pytest tests/ -q` — falls rot, ist die Codebasis selbst kaputt. +2. `.cache/vision_descriptions.json` prüfen — die Schlüssel + `beat:20:73.560:74.680:…` und `action_window:613:5282.390:5285.430:…` müssen + existieren, sonst ruft Vision live ab (kostet Credits; braucht Netz). +3. `match_results.json.bak` zurückspielen, falls der Cache zerschossen ist. + +## Offene Risiken / Bekannte Schwächen + +- Die Schwelle `0.06` für "Beat-Kontext gewinnt" in `realign_window` ist + kalibriert an Beat 20. Andere Beats sollten auch durchlaufen werden, bevor + weitere Beats angefasst werden — am besten ein voller `python cli.py match` + ohne `--beat` und Diff der `match_results.json` gegen `.bak`. +- Die Filter-/Repair-Stufe kann durch Vision-Calls minutenlang laufen. Das + ist nicht neu, aber bei Netzproblemen sehr sichtbar. +- Die `_filter_repair_one`-Funktion bekommt viele Argumente durchgereicht + (closure-Variablen aus dem Parent). Bei einer nächsten Iteration könnte das + in eine kleine Klasse umgebaut werden. + +## Useful greps + +- `find_action_window_in_scene` — semantische Action-Window-Suche (Vision). +- `_reference_scoreable_segments` — bestimmt die sichtbaren Inseln eines + Beats. +- `estimate_usable_source_duration` — kürzt Match-Clips, wenn die Source + vor Beat-Ende in eine andere Phase wechselt. +- `_filter_semantically_invalid_vision_matches` — Eintrittspunkt der + Repair-Stufe in `cli.py`. diff --git a/README.md b/README.md index 8634809..52ec881 100644 --- a/README.md +++ b/README.md @@ -185,12 +185,28 @@ Diese In-Scene-Reparatur läuft auch für semantisch gültige Treffer aus langen Source-Szenen. Dadurch kann ein grob passender Dialogmoment nicht bestehen bleiben, wenn ein anderes lokales Fenster derselben Szene die gesuchte Aktionsphase und Bewegung klarer trifft. +Die Kandidatenbewertung dieser Reparatur vergleicht dabei zwei Kontexte: +den ganzen Beat als semantischen Handlungsrahmen und das konkret sichtbare +Beat-Segment als Phasenprüfung. Ein Source-Zeitpunkt muss also nicht nur +"die Szene mit dem Kuss" enthalten, sondern auch zur aktuellen Bewegungsphase +des sichtbaren Trailerabschnitts passen. Pro Kandidat fließt zusätzlich ein +lokaler Content-/Motion-Frame-Score ein, damit cached Vision-Beschreibungen +keinen sichtbar versetzten Bewegungsmoment überstimmen. Bei blendigen oder segmentierten Beats nutzt die semantische Action-Suche den ganzen Trailerbeat als Kontext. Die eigentliche Frame-Ausrichtung bleibt auf das sichtbare Segment begrenzt; der gefundene Source-Inpoint wird dabei um den Trailer-Offset des Segments verschoben. So geht die globale Aktionsbeschreibung eines Beats nicht verloren, nur weil der scorebare Teil erst nach einer Blende beginnt. +Die Suche nach diesem Action-Window prüft pro Segment zwei Beschreibungen: +zuerst die des konkret sichtbaren Segments (so trifft die Phasensuche genau die +gerade gezeigte Bewegung), als Rückfall die des gesamten Beats. Der Beat- +Kontext gewinnt nur, wenn er deutlich (>0.06) besser scort; sonst bleibt das +Segment-Fenster die Wahl, weil die Beat-Beschreibung Aktionen aus Fade-Bildern +mit aufnehmen kann, die im sichtbaren Segment nicht stattfinden. +Der Trailer-Offset-Shift wird nur angewendet, wenn tatsächlich der Beat-Kontext +benutzt wurde; bei segmentbasierter Wahl ist das gefundene Fenster bereits auf +die sichtbare Aktionsphase ausgerichtet. Der Segment-Offset zählt dabei nur über vorherige scorebare Bildinseln, nicht über schwarze oder blendige Lücken. Nach dem Retiming wird die nutzbare Source-Dauer erneut geschätzt; läuft die Source am Ende in eine sichtbar andere @@ -288,6 +304,12 @@ CV-Scan und darf den besseren oder bestätigten Treffer übernehmen. OpenRouter-/Vision-Rate-Limits werden mit progressiv längeren Pausen erneut versucht. Billing-, Credit- oder Token-Guthaben-Fehler werden dagegen sofort als echter Blocker gemeldet, weil Warten dort nicht hilft. +Auch Netzfehler beim Lesen der Antwort (Timeouts, Verbindungsabbrüche während +einer DSL-Trennung) werden als retrybar behandelt, nicht nur Verbindungsfehler +beim Verbindungsaufbau. Schlägt die Vision-Verifikation während der finalen +Filter-/Repair-Stufe trotzdem dauerhaft fehl, wird der bisherige gecachte +Treffer für diesen Beat behalten statt verworfen — ein Netzproblem darf keinen +schon korrekt gefundenen Match aus dem Cache löschen. Lange Trailerbeats werden nicht mehr automatisch über ihre gesamte Beat-Länge gegen einen einzigen Source-Clip validiert. Sobald nach einem sichtbaren Source-Abschnitt eine anhaltende Schwarzblende oder Titel-/Credit-Insel beginnt, diff --git a/cli.py b/cli.py index 5b00d96..ff2241c 100644 --- a/cli.py +++ b/cli.py @@ -662,11 +662,26 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg) scene = scenes_by_id.get(scene_id) if scene is None: return None - found = find_action_window_in_scene(action_beat or check_beat, scene, cfg) + segment_window = find_action_window_in_scene(check_beat, scene, cfg) + if action_beat is not None and action_beat is not check_beat: + beat_window = find_action_window_in_scene(action_beat, scene, cfg) + else: + beat_window = None + use_beat_context = False + if segment_window is None: + found = beat_window + use_beat_context = beat_window is not None + elif beat_window is None: + found = segment_window + elif beat_window[2] > segment_window[2] + 0.06: + found = beat_window + use_beat_context = True + else: + found = segment_window if found is None: return None start_s, end_s, semantic_score, reason = found - if action_beat is not None: + if use_beat_context: segment_start_offset_s = max(0.0, check_beat.start_s - action_beat.start_s) content_offset_s = visible_content_offset(action_beat, segment_start_offset_s) start_s += content_offset_s @@ -713,6 +728,23 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg) kept.append(result) continue + kept_before = len(kept) + try: + _filter_repair_one(result, beat, beats_by_id, scenes_by_id, kept, cfg, realign_window, validate_match_window_with_vision, logger) + except Exception as exc: + logger.warning( + "Beat %d: vision filter/repair failed (%s); keeping previous cached match.", + result.beat_id, + exc, + ) + del kept[kept_before:] + kept.append(result) + return kept + + +def _filter_repair_one(result, beat, beats_by_id, scenes_by_id, kept, cfg, realign_window, validate_match_window_with_vision, logger): + from dataclasses import replace + if True: windows = [] if getattr(result, "segments", ()): for segment in result.segments: @@ -867,7 +899,7 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg) is_confirmed=repaired_score >= cfg.cv.deep_scan.match_threshold, segments=tuple(new_segments), )) - continue + return else: repair = realign_window(beat, result.scene_id) if repair is not None: @@ -886,13 +918,12 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg) match_score=score, is_confirmed=score >= cfg.cv.deep_scan.match_threshold, )) - continue + return logger.warning( "Beat %d: rejected by vision action-phase verification (%s)", result.beat_id, "; ".join(reasons), ) - return kept def _attach_visual_segments(results: list, beats: list, cfg) -> list: diff --git a/src/llm/vision_cache.py b/src/llm/vision_cache.py index a621ba8..8fb12c1 100644 --- a/src/llm/vision_cache.py +++ b/src/llm/vision_cache.py @@ -13,6 +13,7 @@ import base64 import json import logging import re +import socket import time import urllib.error import urllib.request @@ -193,6 +194,19 @@ def _call_vision_model(label: str, image_urls: list[str], cfg: AppConfig) -> str len(delays_s), ) time.sleep(delay_s) + except (TimeoutError, socket.timeout, ConnectionError, OSError) as exc: + if attempt >= len(delays_s): + raise RuntimeError(f"Vision request failed for {url}: {exc}") from exc + delay_s = delays_s[attempt] + logger.warning( + "Vision request network error for %s (%s); waiting %.0fs before retry %d/%d.", + label, + exc, + delay_s, + attempt + 1, + len(delays_s), + ) + time.sleep(delay_s) raise RuntimeError(f"Vision request failed unexpectedly for {url}")