feat: add v2 post-correct mode with optional local llm
This commit is contained in:
@@ -160,3 +160,11 @@ The project now features a high-performance, Apple Silicon-optimized pipeline th
|
||||
- Wrapped Arabic caption content in explicit RTL embedding marks for better bidirectional layout stability.
|
||||
- Added punctuation normalization for Arabic display (`،`, `؛`, `؟`) to reduce LTR punctuation artifacts.
|
||||
- **Outcome:** Arabic captions render more consistently in terminal output, especially when adjacent to English/French/Spanish caption lines.
|
||||
|
||||
## Phase 20: Post-Correct Mode (V2 Foundation)
|
||||
- **Goal:** Add a practical second-stage correction pass for finalized English captions.
|
||||
- **Approach:**
|
||||
- Added `--post-correct` mode to run deterministic rule-based cleanup after ASR (spacing, punctuation cleanup, disfluency reduction, immediate duplicate-word collapse).
|
||||
- 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.
|
||||
|
||||
@@ -207,6 +207,69 @@ def normalize_english_caption(text):
|
||||
normalized = normalized[0].upper() + normalized[1:]
|
||||
return normalized
|
||||
|
||||
def apply_rule_based_post_correction(text):
|
||||
"""Apply lightweight deterministic cleanup for finalized English captions."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
corrected = normalize_english_caption(text)
|
||||
corrected = re.sub(r"\s+", " ", corrected).strip()
|
||||
corrected = re.sub(r"\s+([,.;!?])", r"\1", corrected)
|
||||
corrected = re.sub(r"([,.;!?]){2,}", r"\1", corrected)
|
||||
|
||||
# Remove common disfluencies conservatively.
|
||||
corrected = re.sub(r"\b(uh+|um+|erm+|ah+|hmm+)\b", "", corrected, flags=re.IGNORECASE)
|
||||
corrected = re.sub(r"\s+", " ", corrected).strip()
|
||||
|
||||
# Collapse immediate duplicated words (e.g., "the the", "I I").
|
||||
corrected = re.sub(r"\b(\w+)\s+\1\b", r"\1", corrected, flags=re.IGNORECASE)
|
||||
corrected = re.sub(r"\s+", " ", corrected).strip()
|
||||
|
||||
if corrected and corrected[0].isalpha():
|
||||
corrected = corrected[0].upper() + corrected[1:]
|
||||
return corrected
|
||||
|
||||
def post_correct_with_local_llm(text, args, llm_state, context_hint=""):
|
||||
"""Optionally rewrite finalized English caption via a local Ollama model."""
|
||||
if not args.post_correct_llm or not llm_state.get("available", True):
|
||||
return text
|
||||
|
||||
prompt = (
|
||||
"You correct live English captions.\n"
|
||||
"Rules:\n"
|
||||
"- Preserve original meaning exactly.\n"
|
||||
"- Fix grammar, punctuation, and natural wording.\n"
|
||||
"- Remove filler words only when they do not change meaning.\n"
|
||||
"- Keep output concise and in one line.\n"
|
||||
"- Return only corrected text, no quotes or explanation.\n"
|
||||
)
|
||||
if context_hint:
|
||||
prompt += f"Previous caption context: {context_hint}\n"
|
||||
prompt += f"Caption: {text}\nCorrected:"
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
args.post_correct_ollama_url,
|
||||
json={
|
||||
"model": args.post_correct_model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1},
|
||||
},
|
||||
timeout=args.post_correct_llm_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw = response.json().get("response", "").strip()
|
||||
cleaned = normalize_english_caption(raw)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
except Exception as exc:
|
||||
if not llm_state.get("warned", False):
|
||||
print(f"\n[SYSTEM]: Local post-correct LLM unavailable ({exc}). Falling back to rules-only.")
|
||||
llm_state["warned"] = True
|
||||
llm_state["available"] = False
|
||||
return text
|
||||
|
||||
def maybe_merge_recent_caption(recent_en_lines, current_text, now_ts, window_sec):
|
||||
if not recent_en_lines:
|
||||
return None
|
||||
@@ -350,12 +413,24 @@ def main():
|
||||
parser.add_argument("--caption-correction-window", type=float, default=3.0, help="Seconds where previous English line can still be merged/corrected (default: 3.0)")
|
||||
parser.add_argument("--glossary-file", type=str, default="glossary.txt", help="Path to glossary file with SOURCE=TARGET pairs (default: glossary.txt if present)")
|
||||
parser.add_argument("--glossary-pair", action="append", type=parse_glossary_pair, default=[], help="Term replacement pair SOURCE=TARGET (repeatable)")
|
||||
parser.add_argument("--post-correct", action="store_true", help="Enable v2 finalized English caption post-correction")
|
||||
parser.add_argument("--post-correct-llm", action="store_true", help="Use local LLM for post-correction (fallback to rules on failure)")
|
||||
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)")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.post_correct_llm and not args.post_correct:
|
||||
args.post_correct = True
|
||||
print("[SYSTEM]: Enabling --post-correct because --post-correct-llm was requested.")
|
||||
file_glossary_pairs = load_glossary_file(args.glossary_file)
|
||||
merged_glossary_pairs = file_glossary_pairs + args.glossary_pair
|
||||
if file_glossary_pairs:
|
||||
print(f"Loaded {len(file_glossary_pairs)} glossary terms from {args.glossary_file}")
|
||||
if args.post_correct:
|
||||
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.quantize:
|
||||
WHISPER_MODEL = "mlx-community/whisper-small-mlx-4bit"
|
||||
@@ -437,6 +512,7 @@ def main():
|
||||
rolling_context_en = ""
|
||||
last_draft_text = ""
|
||||
recent_en_lines = deque(maxlen=2)
|
||||
llm_post_correct_state = {"available": True, "warned": False}
|
||||
|
||||
try:
|
||||
with sd.InputStream(samplerate=SAMPLERATE, channels=args.channels, callback=callback, blocksize=BLOCK_SIZE, device=device_index):
|
||||
@@ -578,6 +654,19 @@ def main():
|
||||
if args.smart_correct and english_for_translation:
|
||||
english_for_translation = normalize_english_caption(english_for_translation)
|
||||
english_for_translation = apply_glossary(english_for_translation, merged_glossary_pairs)
|
||||
elif merged_glossary_pairs and english_for_translation:
|
||||
# Allow glossary use even when smart-correct is disabled.
|
||||
english_for_translation = apply_glossary(english_for_translation, merged_glossary_pairs)
|
||||
|
||||
if args.post_correct and english_for_translation:
|
||||
context_hint = recent_en_lines[-1]["text"] if recent_en_lines else ""
|
||||
english_for_translation = apply_rule_based_post_correction(english_for_translation)
|
||||
english_for_translation = post_correct_with_local_llm(
|
||||
english_for_translation,
|
||||
args,
|
||||
llm_post_correct_state,
|
||||
context_hint=context_hint,
|
||||
)
|
||||
|
||||
english_for_caption = english_for_translation
|
||||
if args.smart_correct and english_for_caption:
|
||||
|
||||
Reference in New Issue
Block a user