Refactor to request matching/replacement substrings from LLMs, rather than requesting a complete replacement for the full original text

This commit is contained in:
2026-04-21 12:33:15 -05:00
parent 23532cade1
commit 4296e3576e
5 changed files with 102 additions and 50 deletions

View File

@@ -1,5 +1,5 @@
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Dict, Iterable, List, Optional, Tuple from typing import Iterable, List, Optional, Tuple
from .errors import AuditaValidationError from .errors import AuditaValidationError
from .schemas import CorrectionCandidate, TranscriptSegment from .schemas import CorrectionCandidate, TranscriptSegment
@@ -32,33 +32,20 @@ def apply_corrections(
if not 0.0 <= confidence_threshold <= 1.0: if not 0.0 <= confidence_threshold <= 1.0:
raise AuditaValidationError("Confidence threshold must be between 0.0 and 1.0.") raise AuditaValidationError("Confidence threshold must be between 0.0 and 1.0.")
correction_by_id: Dict[int, CorrectionCandidate] = {} revised = list(transcript)
skipped: List[SkippedCorrection] = [] skipped: List[SkippedCorrection] = []
for correction in corrections: for correction in corrections:
segment_id = correction.segment_id if correction.confidence < confidence_threshold:
if segment_id in correction_by_id:
skipped.append(
_skip(
correction,
"duplicate correction for segment already handled",
)
)
continue continue
reason, actual_text = _target_error(transcript, correction) reason, actual_text = _target_error(revised, correction)
if reason is not None: if reason is not None:
skipped.append(_skip(correction, reason, actual_text=actual_text)) skipped.append(_skip(correction, reason, actual_text=actual_text))
continue continue
correction_by_id[segment_id] = correction segment = revised[correction.segment_id]
revised_text = segment.text.replace(correction.original_text, correction.corrected_text, 1)
revised: List[TranscriptSegment] = [] revised[correction.segment_id] = segment.model_copy(update={"text": revised_text})
for original_id, segment in enumerate(transcript):
correction = correction_by_id.get(original_id)
if correction is not None and correction.confidence >= confidence_threshold:
revised.append(segment.model_copy(update={"text": correction.corrected_text}))
else:
revised.append(segment)
indexed_revised = list(enumerate(revised)) indexed_revised = list(enumerate(revised))
indexed_revised.sort(key=lambda item: (item[1].start, item[1].end, item[0])) indexed_revised.sort(key=lambda item: (item[1].start, item[1].end, item[0]))
@@ -76,8 +63,16 @@ def _target_error(
return "segment_id does not exist in transcript", None return "segment_id does not exist in transcript", None
segment = transcript[correction.segment_id] segment = transcript[correction.segment_id]
if correction.original_text != segment.text: if correction.original_text == "":
return "original_text does not exactly match segment text", segment.text return "original_text is empty", segment.text
if correction.original_text == correction.corrected_text:
return "original_text and corrected_text are identical", segment.text
match_count = segment.text.count(correction.original_text)
if match_count == 0:
return "original_text does not match any substring in segment text", segment.text
if match_count > 1:
return "original_text appears multiple times in segment text", segment.text
return None, None return None, None

View File

@@ -31,11 +31,13 @@ def build_glossary_correction_messages(section: TranscriptSection, glossary: Glo
"- Inappropriate example: correcting \"Lyra\" to \"Jesters\" should be omitted because those words are not similar in spoken English, even if \"Jesters\" appears in the glossary.\n" "- Inappropriate example: correcting \"Lyra\" to \"Jesters\" should be omitted because those words are not similar in spoken English, even if \"Jesters\" appears in the glossary.\n"
"- Do not replace one clear glossary term, character name, location, or ordinary word with a different glossary term unless it is a plausible mishearing.\n" "- Do not replace one clear glossary term, character name, location, or ordinary word with a different glossary term unless it is a plausible mishearing.\n"
"- Assign high confidence only when the correction is supported by glossary evidence, local context, and spoken-word similarity; otherwise omit the correction.\n" "- Assign high confidence only when the correction is supported by glossary evidence, local context, and spoken-word similarity; otherwise omit the correction.\n"
"- Use the exact segment_id and original_text from the input segment.\n" "- Use the exact segment_id from the input segment.\n"
"- For returned corrections, original_text must be only the exact text span that needs replacement, not the full segment text unless the whole segment is the replacement span.\n"
"- corrected_text must be only the replacement text for that span, not the full corrected segment text unless the whole segment is the replacement span.\n"
"- Each returned correction must contain only segment_id, original_text, corrected_text, and confidence.\n" "- Each returned correction must contain only segment_id, original_text, corrected_text, and confidence.\n"
"- Do not return corrections where original_text and corrected_text are identical.\n"
"- Do not return speaker, start, or end fields.\n" "- Do not return speaker, start, or end fields.\n"
"- Return only changed segments; do not return entries for unchanged segments.\n" "- Return only changed segments; do not return entries for unchanged segments.\n"
"- corrected_text must contain the full corrected text for that segment.\n"
"- confidence must be between 0.0 and 1.0.\n" "- confidence must be between 0.0 and 1.0.\n"
"- If no corrections are needed, return an empty corrections list.\n\n" "- If no corrections are needed, return an empty corrections list.\n\n"
f"Glossary:\n{glossary_json}\n\n" f"Glossary:\n{glossary_json}\n\n"

View File

@@ -9,7 +9,7 @@ def _transcript():
return parse_transcript_json( return parse_transcript_json(
""" """
[ [
{"speaker": "Eric", "start": 10.0, "end": 11.0, "text": "I ask Chontia."}, {"speaker": "Eric", "start": 10.0, "end": 11.0, "text": "I ask Chontia for help."},
{"speaker": "Mike", "start": 0.0, "end": 1.0, "text": "Then Lyra."} {"speaker": "Mike", "start": 0.0, "end": 1.0, "text": "Then Lyra."}
] ]
""" """
@@ -21,8 +21,8 @@ def test_apply_corrections_uses_threshold_and_sorts_chronologically():
corrections = [ corrections = [
CorrectionCandidate( CorrectionCandidate(
segment_id=0, segment_id=0,
original_text="I ask Chontia.", original_text="Chontia",
corrected_text="I ask Chauntea.", corrected_text="Chauntea",
confidence=0.8, confidence=0.8,
) )
] ]
@@ -30,7 +30,7 @@ def test_apply_corrections_uses_threshold_and_sorts_chronologically():
result = apply_corrections(transcript, corrections, confidence_threshold=0.8) result = apply_corrections(transcript, corrections, confidence_threshold=0.8)
assert [segment.speaker for segment in result.transcript] == ["Mike", "Eric"] assert [segment.speaker for segment in result.transcript] == ["Mike", "Eric"]
assert result.transcript[1].text == "I ask Chauntea." assert result.transcript[1].text == "I ask Chauntea for help."
assert result.skipped == [] assert result.skipped == []
@@ -39,57 +39,55 @@ def test_apply_corrections_ignores_below_threshold():
corrections = [ corrections = [
CorrectionCandidate( CorrectionCandidate(
segment_id=0, segment_id=0,
original_text="I ask Chontia.", original_text="Chontia",
corrected_text="I ask Chauntea.", corrected_text="Chauntea",
confidence=0.79, confidence=0.79,
) )
] ]
result = apply_corrections(transcript, corrections, confidence_threshold=0.8) result = apply_corrections(transcript, corrections, confidence_threshold=0.8)
assert result.transcript[1].text == "I ask Chontia." assert result.transcript[1].text == "I ask Chontia for help."
assert result.skipped == [] assert result.skipped == []
def test_apply_corrections_skips_duplicate_targets(): def test_apply_corrections_allows_multiple_distinct_spans_in_one_segment():
transcript = _transcript() transcript = _transcript()
first = CorrectionCandidate( first = CorrectionCandidate(
segment_id=0, segment_id=0,
original_text="I ask Chontia.", original_text="Chontia",
corrected_text="I ask Chauntea.", corrected_text="Chauntea",
confidence=0.8, confidence=0.8,
) )
second = CorrectionCandidate( second = CorrectionCandidate(
segment_id=0, segment_id=0,
original_text="I ask Chontia.", original_text="help",
corrected_text="I ask Something Else.", corrected_text="guidance",
confidence=0.9, confidence=0.9,
) )
result = apply_corrections(transcript, [first, second], confidence_threshold=0.8) result = apply_corrections(transcript, [first, second], confidence_threshold=0.8)
assert result.transcript[1].text == "I ask Chauntea." assert result.transcript[1].text == "I ask Chauntea for guidance."
assert len(result.skipped) == 1 assert result.skipped == []
assert result.skipped[0].segment_id == 0
assert "duplicate" in result.skipped[0].reason
def test_apply_corrections_skips_mismatched_original_text(): def test_apply_corrections_skips_missing_substring():
transcript = _transcript() transcript = _transcript()
correction = CorrectionCandidate( correction = CorrectionCandidate(
segment_id=0, segment_id=0,
original_text="Different text.", original_text="Different text.",
corrected_text="I ask Chauntea.", corrected_text="Chauntea",
confidence=0.8, confidence=0.8,
) )
result = apply_corrections(transcript, [correction], confidence_threshold=0.8) result = apply_corrections(transcript, [correction], confidence_threshold=0.8)
assert result.transcript[1].text == "I ask Chontia." assert result.transcript[1].text == "I ask Chontia for help."
assert len(result.skipped) == 1 assert len(result.skipped) == 1
assert result.skipped[0].segment_id == 0 assert result.skipped[0].segment_id == 0
assert result.skipped[0].actual_text == "I ask Chontia." assert result.skipped[0].actual_text == "I ask Chontia for help."
assert "original_text" in result.skipped[0].reason assert "does not match any substring" in result.skipped[0].reason
def test_apply_corrections_skips_missing_segment_id(): def test_apply_corrections_skips_missing_segment_id():
@@ -103,12 +101,66 @@ def test_apply_corrections_skips_missing_segment_id():
result = apply_corrections(transcript, [correction], confidence_threshold=0.8) result = apply_corrections(transcript, [correction], confidence_threshold=0.8)
assert [segment.text for segment in result.transcript] == ["Then Lyra.", "I ask Chontia."] assert [segment.text for segment in result.transcript] == ["Then Lyra.", "I ask Chontia for help."]
assert len(result.skipped) == 1 assert len(result.skipped) == 1
assert result.skipped[0].segment_id == 99 assert result.skipped[0].segment_id == 99
assert "does not exist" in result.skipped[0].reason assert "does not exist" in result.skipped[0].reason
def test_apply_corrections_skips_no_op():
transcript = _transcript()
correction = CorrectionCandidate(
segment_id=0,
original_text="Chontia",
corrected_text="Chontia",
confidence=0.8,
)
result = apply_corrections(transcript, [correction], confidence_threshold=0.8)
assert result.transcript[1].text == "I ask Chontia for help."
assert len(result.skipped) == 1
assert "identical" in result.skipped[0].reason
def test_apply_corrections_skips_ambiguous_repeated_substring():
transcript = parse_transcript_json(
"""
[
{"speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Bane met Bane."}
]
"""
)
correction = CorrectionCandidate(
segment_id=0,
original_text="Bane",
corrected_text="Bain",
confidence=0.8,
)
result = apply_corrections(transcript, [correction], confidence_threshold=0.8)
assert result.transcript[0].text == "Bane met Bane."
assert len(result.skipped) == 1
assert "multiple times" in result.skipped[0].reason
def test_apply_corrections_skips_empty_original_text():
transcript = _transcript()
correction = CorrectionCandidate(
segment_id=0,
original_text="",
corrected_text="Chauntea",
confidence=0.8,
)
result = apply_corrections(transcript, [correction], confidence_threshold=0.8)
assert result.transcript[1].text == "I ask Chontia for help."
assert len(result.skipped) == 1
assert "empty" in result.skipped[0].reason
def test_apply_corrections_rejects_invalid_threshold(): def test_apply_corrections_rejects_invalid_threshold():
with pytest.raises(AuditaValidationError): with pytest.raises(AuditaValidationError):
apply_corrections(_transcript(), [], confidence_threshold=1.1) apply_corrections(_transcript(), [], confidence_threshold=1.1)

View File

@@ -49,8 +49,8 @@ def _transcript():
def test_pipeline_processes_with_fake_llm_and_cleans_work_dir(tmp_path): def test_pipeline_processes_with_fake_llm_and_cleans_work_dir(tmp_path):
correction = CorrectionCandidate( correction = CorrectionCandidate(
segment_id=0, segment_id=0,
original_text="I ask Chontia.", original_text="Chontia",
corrected_text="I ask Chauntea.", corrected_text="Chauntea",
confidence=0.95, confidence=0.95,
) )
fake_client = FakeLLMClient([CorrectionSet(corrections=[correction])]) fake_client = FakeLLMClient([CorrectionSet(corrections=[correction])])
@@ -71,7 +71,7 @@ def test_pipeline_skips_bad_correction_and_preserves_diagnostics(tmp_path):
correction = CorrectionCandidate( correction = CorrectionCandidate(
segment_id=0, segment_id=0,
original_text="Different text.", original_text="Different text.",
corrected_text="I ask Chauntea.", corrected_text="Chauntea",
confidence=0.95, confidence=0.95,
) )
fake_client = FakeLLMClient([CorrectionSet(corrections=[correction])]) fake_client = FakeLLMClient([CorrectionSet(corrections=[correction])])
@@ -93,4 +93,4 @@ def test_pipeline_skips_bad_correction_and_preserves_diagnostics(tmp_path):
assert skipped_path.exists() assert skipped_path.exists()
diagnostics = json.loads(skipped_path.read_text(encoding="utf-8")) diagnostics = json.loads(skipped_path.read_text(encoding="utf-8"))
assert diagnostics["skipped_corrections"][0]["segment_id"] == 0 assert diagnostics["skipped_corrections"][0]["segment_id"] == 0
assert "original_text" in diagnostics["skipped_corrections"][0]["reason"] assert "does not match any substring" in diagnostics["skipped_corrections"][0]["reason"]

View File

@@ -34,6 +34,9 @@ def test_prompt_requires_acoustically_plausible_transcription_errors():
assert '"gestures" to "Jesters"' in prompt_text assert '"gestures" to "Jesters"' in prompt_text
assert '"Lyra" to "Jesters"' in prompt_text assert '"Lyra" to "Jesters"' in prompt_text
assert "should be omitted" in prompt_text assert "should be omitted" in prompt_text
assert "exact text span that needs replacement" in prompt_text
assert "replacement text for that span" in prompt_text
assert "Do not return corrections where original_text and corrected_text are identical" in prompt_text
def test_prompt_uses_simplified_segment_payload(): def test_prompt_uses_simplified_segment_payload():