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.
|
- 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.
|
- 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.
|
- **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:]
|
normalized = normalized[0].upper() + normalized[1:]
|
||||||
return normalized
|
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):
|
def maybe_merge_recent_caption(recent_en_lines, current_text, now_ts, window_sec):
|
||||||
if not recent_en_lines:
|
if not recent_en_lines:
|
||||||
return None
|
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("--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-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("--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()
|
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)
|
file_glossary_pairs = load_glossary_file(args.glossary_file)
|
||||||
merged_glossary_pairs = file_glossary_pairs + args.glossary_pair
|
merged_glossary_pairs = file_glossary_pairs + args.glossary_pair
|
||||||
if file_glossary_pairs:
|
if file_glossary_pairs:
|
||||||
print(f"Loaded {len(file_glossary_pairs)} glossary terms from {args.glossary_file}")
|
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:
|
if args.quantize:
|
||||||
WHISPER_MODEL = "mlx-community/whisper-small-mlx-4bit"
|
WHISPER_MODEL = "mlx-community/whisper-small-mlx-4bit"
|
||||||
@@ -437,6 +512,7 @@ def main():
|
|||||||
rolling_context_en = ""
|
rolling_context_en = ""
|
||||||
last_draft_text = ""
|
last_draft_text = ""
|
||||||
recent_en_lines = deque(maxlen=2)
|
recent_en_lines = deque(maxlen=2)
|
||||||
|
llm_post_correct_state = {"available": True, "warned": False}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with sd.InputStream(samplerate=SAMPLERATE, channels=args.channels, callback=callback, blocksize=BLOCK_SIZE, device=device_index):
|
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:
|
if args.smart_correct and english_for_translation:
|
||||||
english_for_translation = normalize_english_caption(english_for_translation)
|
english_for_translation = normalize_english_caption(english_for_translation)
|
||||||
english_for_translation = apply_glossary(english_for_translation, merged_glossary_pairs)
|
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
|
english_for_caption = english_for_translation
|
||||||
if args.smart_correct and english_for_caption:
|
if args.smart_correct and english_for_caption:
|
||||||
|
|||||||
Reference in New Issue
Block a user