From 2881bd96dd01d05d29db5a81978171cf4152ea3b Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Fri, 6 Mar 2026 19:56:07 -0500 Subject: [PATCH] feat: add optional llm merge-decider for smart caption revisions --- history.md | 8 +++++ transcribe.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/history.md b/history.md index fd6fddb..dbf61db 100644 --- a/history.md +++ b/history.md @@ -168,3 +168,11 @@ The project now features a high-performance, Apple Silicon-optimized pipeline th - Added optional local LLM correction via Ollama (`--post-correct-llm`, model/url/timeout flags) with strict fallback to rules-only when unavailable. - Integrated post-correction after bridge-to-English and before downstream translation so improved English text propagates to target-language MT. - **Outcome:** The pipeline now supports a two-stage transcription flow (ASR -> correction) with low-latency defaults and optional local generative enhancement. + +## Phase 21: LLM Merge-Decider for Line Revisions +- **Goal:** Improve English line-merge quality when smart-correct revision is ambiguous. +- **Approach:** + - Added optional `--llm-merge-decider` to validate/refine heuristic merge candidates using the local post-correct LLM. + - Enforced strict low-latency behavior with `--llm-merge-timeout` (default 0.7s) and automatic fallback to heuristic merges on errors/timeouts. + - Added safety guards to reject overly large LLM rewrites and preserve buffered caption state when merge is rejected. +- **Outcome:** Merge decisions remain fast and deterministic by default, with optional LLM arbitration for cleaner sentence continuity. diff --git a/transcribe.py b/transcribe.py index cf36bfc..0b449ce 100644 --- a/transcribe.py +++ b/transcribe.py @@ -292,6 +292,66 @@ def maybe_merge_recent_caption(recent_en_lines, current_text, now_ts, window_sec previous["ts"] = now_ts return old, merged +def llm_decide_caption_merge(previous_text, current_text, candidate_text, args, llm_state): + """Ask local LLM to approve/refine a merge candidate; fallback to heuristic decision.""" + if not args.llm_merge_decider or not args.post_correct_llm or not llm_state.get("available", True): + return True, candidate_text + + prompt = ( + "You decide whether two live English caption lines should be merged.\n" + "Return strict JSON only in this shape:\n" + "{\"merge\": true|false, \"text\": \"final merged text or empty\"}\n" + "Rules:\n" + "- Preserve meaning exactly.\n" + "- If uncertain, return merge=false.\n" + "- If merge=true, provide a single corrected line in text.\n" + f"Previous: {previous_text}\n" + f"Current: {current_text}\n" + f"HeuristicMerged: {candidate_text}\n" + "JSON:" + ) + + try: + response = requests.post( + args.post_correct_ollama_url, + json={ + "model": args.post_correct_model, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.0}, + }, + timeout=args.llm_merge_timeout, + ) + response.raise_for_status() + raw = response.json().get("response", "").strip() + if not raw: + return True, candidate_text + + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + match = re.search(r"\{.*\}", raw, re.DOTALL) + if not match: + return True, candidate_text + parsed = json.loads(match.group(0)) + + merge = bool(parsed.get("merge", False)) + llm_text = normalize_english_caption(parsed.get("text", "").strip()) + if not merge: + return False, "" + if not llm_text: + llm_text = candidate_text + + # Safety: reject large rewrites and keep heuristic merge. + if len(llm_text) > max(2 * len(candidate_text), len(candidate_text) + 80): + return True, candidate_text + return True, llm_text + except Exception as exc: + if not llm_state.get("merge_warned", False): + print(f"\n[SYSTEM]: LLM merge-decider unavailable ({exc}). Falling back to heuristic merge.") + llm_state["merge_warned"] = True + return True, candidate_text + def normalize_arabic_punctuation(text): """Convert common Latin punctuation to Arabic-friendly equivalents.""" updated = text.replace(",", "،").replace(";", "؛") @@ -418,6 +478,8 @@ def main(): parser.add_argument("--post-correct-model", type=str, default="llama3.1:8b-instruct", help="Local Ollama model for post-correction") parser.add_argument("--post-correct-ollama-url", type=str, default="http://127.0.0.1:11434/api/generate", help="Ollama generate endpoint for local post-correction") parser.add_argument("--post-correct-llm-timeout", type=float, default=3.0, help="Timeout seconds for local LLM post-correction (default: 3.0)") + parser.add_argument("--llm-merge-decider", action="store_true", help="Use local LLM to validate/refine smart-correct line merges") + parser.add_argument("--llm-merge-timeout", type=float, default=0.7, help="Timeout seconds for LLM merge decision (default: 0.7)") args = parser.parse_args() if args.post_correct_llm and not args.post_correct: @@ -431,6 +493,9 @@ def main(): print("Post-correct mode: rules enabled.") if args.post_correct_llm: print(f"Post-correct LLM: {args.post_correct_model} via {args.post_correct_ollama_url}") + if args.llm_merge_decider and not args.post_correct_llm: + print("[SYSTEM]: --llm-merge-decider requires --post-correct-llm. Falling back to heuristic merge.") + args.llm_merge_decider = False if args.quantize: WHISPER_MODEL = "mlx-community/whisper-small-mlx-4bit" @@ -512,7 +577,7 @@ def main(): rolling_context_en = "" last_draft_text = "" recent_en_lines = deque(maxlen=2) - llm_post_correct_state = {"available": True, "warned": False} + llm_post_correct_state = {"available": True, "warned": False, "merge_warned": False} try: with sd.InputStream(samplerate=SAMPLERATE, channels=args.channels, callback=callback, blocksize=BLOCK_SIZE, device=device_index): @@ -679,8 +744,21 @@ def main(): ) if merged: old_line, new_line = merged - english_for_caption = new_line - print(f"[EN-REV]: {old_line} -> {new_line}") + merge_ok, merge_text = llm_decide_caption_merge( + old_line, + english_for_caption, + new_line, + args, + llm_post_correct_state, + ) + if merge_ok: + english_for_caption = merge_text + print(f"[EN-REV]: {old_line} -> {english_for_caption}") + else: + if recent_en_lines: + recent_en_lines[-1]["text"] = old_line + recent_en_lines[-1]["ts"] = now_ts + recent_en_lines.append({"text": english_for_caption, "ts": now_ts}) else: recent_en_lines.append({"text": english_for_caption, "ts": now_ts})