Grammar corrections now run through a deterministic guard that blocks protected glossary names/aliases from being changed

This commit is contained in:
2026-04-22 09:04:00 -05:00
parent e50124fa0d
commit 9afb5c5a4e
9 changed files with 238 additions and 5 deletions

View File

@@ -33,7 +33,6 @@ def test_config_uses_defaults_with_api_key():
assert config.normalize_max_segment_gap == DEFAULT_NORMALIZE_MAX_SEGMENT_GAP
assert config.normalize_max_segment_gap == 5.0
assert config.normalize_ellipsis_gap == DEFAULT_NORMALIZE_ELLIPSIS_GAP
assert config.normalize_ellipsis_gap == 2.0
assert config.normalize_max_segment_duration == DEFAULT_NORMALIZE_MAX_SEGMENT_DURATION
assert config.normalize_max_segment_duration == 60.0
assert config.normalize_max_segment_tokens == DEFAULT_NORMALIZE_MAX_SEGMENT_TOKENS

View File

@@ -171,6 +171,56 @@ def test_apply_corrections_requires_unique_match_when_configured():
assert "more than once" in result.skipped[0].reason
def test_apply_corrections_skips_when_guard_rejects_replacement():
transcript = parse_transcript_json(
"""
[
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Hrank moves."}
]
"""
)
correction = CorrectionCandidate(
id=1,
original_text="Hrank",
corrected_text="Frank",
confidence=0.8,
)
result = apply_corrections(
transcript,
[correction],
confidence_threshold=0.8,
replacement_mode="require_unique",
correction_guard=lambda before, after: "protected term changed" if before != after else None,
)
assert result.transcript[0].text == "Hrank moves."
assert len(result.skipped) == 1
assert result.skipped[0].reason == "protected term changed"
assert result.skipped[0].actual_text == "Hrank moves."
def test_apply_corrections_without_guard_remains_permissive():
transcript = parse_transcript_json(
"""
[
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Frank moves."}
]
"""
)
correction = CorrectionCandidate(
id=1,
original_text="Frank",
corrected_text="Hrank",
confidence=0.8,
)
result = apply_corrections(transcript, [correction], confidence_threshold=0.8)
assert result.transcript[0].text == "Hrank moves."
assert result.skipped == []
def test_apply_corrections_skips_empty_original_text():
transcript = _transcript()
correction = CorrectionCandidate(

View File

@@ -305,6 +305,59 @@ def test_grammar_stage_runs_after_glossary_and_sees_corrected_text(tmp_path):
assert revised[0].text == "I ask Chauntea."
def test_grammar_stage_cannot_reverse_glossary_protected_term(tmp_path):
transcript = parse_source_transcript_json(
"""
[
{"speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Frank moves."}
]
"""
)
glossary = parse_glossary_yaml(
"""
glossary:
- name: "Hrank"
category: pc
summary: "Hrank is a player character."
"""
)
glossary_correction = CorrectionCandidate(
id=1,
original_text="Frank",
corrected_text="Hrank",
confidence=0.95,
)
grammar_reversal = CorrectionCandidate(
id=1,
original_text="Hrank",
corrected_text="Frank",
confidence=0.95,
)
fake_client = FakeLLMClient(
[
CorrectionSet(corrections=[glossary_correction]),
CorrectionSet(corrections=[grammar_reversal]),
]
)
progress = []
revised = process_transcript(
transcript,
glossary,
_config(tmp_path, grammar_max_llm_passes=1),
llm_client=fake_client,
progress=progress.append,
)
assert revised[0].text == "Hrank moves."
assert any("Skipping grammar correction for id 1" in message for message in progress)
run_dirs = list((tmp_path / "work").iterdir())
assert len(run_dirs) == 1
diagnostics = json.loads((run_dirs[0] / "skipped-corrections.json").read_text(encoding="utf-8"))
assert diagnostics["skipped_corrections"][0]["stage"] == "grammar"
assert "protected glossary term" in diagnostics["skipped_corrections"][0]["reason"]
def test_grammar_stage_retries_repeated_span_and_applies_unique_retry(tmp_path):
transcript = parse_source_transcript_json(
"""

View File

@@ -93,7 +93,9 @@ def test_grammar_prompt_limits_readability_corrections_and_protects_glossary():
assert "homophone fixes" in prompt_text
assert "spelling fixes" in prompt_text
assert "Do not paraphrase" in prompt_text
assert "protected vocabulary" in prompt_text
assert "glossary names and aliases as protected spellings" in prompt_text
assert "Do not autocorrect, Anglicize, replace, normalize, lowercase" in prompt_text
assert "Preserve canonical glossary capitalization" in prompt_text
assert "appears exactly once" in prompt_text
assert "Do not return speaker, start, or end fields" in prompt_text

52
tests/test_protection.py Normal file
View File

@@ -0,0 +1,52 @@
from audita.protection import ProtectedVocabulary
from audita.schemas import parse_glossary_yaml
def _vocabulary():
glossary = parse_glossary_yaml(
"""
glossary:
- name: "Hrank"
aliases:
- "Greenfield"
category: pc
summary: "Hrank Greenfield is a player character."
"""
)
return ProtectedVocabulary.from_glossary(glossary)
def test_protection_blocks_replacing_protected_term():
vocabulary = _vocabulary()
assert vocabulary.violation_reason("Hrank moves.", "Frank moves.") is not None
def test_protection_blocks_lowercasing_protected_term():
vocabulary = _vocabulary()
assert vocabulary.violation_reason("Hrank moves.", "hrank moves.") is not None
def test_protection_allows_canonical_capitalization():
vocabulary = _vocabulary()
assert vocabulary.violation_reason("hrank moves.", "Hrank moves.") is None
def test_protection_allows_punctuation_around_protected_term():
vocabulary = _vocabulary()
assert vocabulary.violation_reason("Hrank, moves.", "Hrank. Moves.") is None
def test_protection_does_not_match_terms_inside_larger_words():
vocabulary = _vocabulary()
assert vocabulary.violation_reason("The shrank spell worked.", "The shrank spell works.") is None
def test_protection_applies_to_aliases():
vocabulary = _vocabulary()
assert vocabulary.violation_reason("Greenfield waits.", "greenfield waits.") is not None