Implement support for segments with an optional categories key

This commit is contained in:
2026-04-28 22:41:16 -05:00
parent 177e45f1fd
commit db6004fadc
10 changed files with 115 additions and 9 deletions

View File

@@ -91,7 +91,10 @@ class IndexedSegment:
return self.segment.model_dump(mode="json")
def prompt_payload(self) -> dict:
return {"id": self.segment.id, "original_text": self.segment.text}
payload = {"id": self.segment.id, "original_text": self.segment.text}
if self.segment.categories is not None:
payload["categories"] = list(self.segment.categories)
return payload
@dataclass(frozen=True)

View File

@@ -31,6 +31,7 @@ class _WorkingSegment:
start: float
end: float
text: str
categories: Optional[List[str]]
order: int
@@ -49,6 +50,7 @@ def normalize_transcript(
start=segment.start,
end=segment.end,
text=segment.text,
categories=None if segment.categories is None else list(segment.categories),
order=index,
)
for index, segment in enumerate(segments)
@@ -145,6 +147,7 @@ def _merge_segments(left: _WorkingSegment, right: _WorkingSegment, ellipsis_gap:
start=left.start,
end=right.end,
text=_joined_text(left.text, right.text, gap, ellipsis_gap),
categories=_merged_categories(left.categories, right.categories),
order=left.order,
)
@@ -158,6 +161,14 @@ def _estimate_prompt_tokens(text: str, estimator: TokenEstimatorProtocol) -> int
return estimator.estimate_json([{"id": 1, "original_text": text}])
def _merged_categories(left: Optional[List[str]], right: Optional[List[str]]) -> Optional[List[str]]:
merged: List[str] = []
for category in (left or []) + (right or []):
if category not in merged:
merged.append(category)
return merged or None
def _assign_ids(segments: List[_WorkingSegment]) -> List[TranscriptSegment]:
ordered = sorted(segments, key=lambda segment: (segment.start, segment.end, segment.order))
return [
@@ -167,6 +178,7 @@ def _assign_ids(segments: List[_WorkingSegment]) -> List[TranscriptSegment]:
start=segment.start,
end=segment.end,
text=segment.text,
categories=None if segment.categories is None else list(segment.categories),
)
for index, segment in enumerate(ordered)
]

View File

@@ -15,6 +15,7 @@ class TranscriptSegment(BaseModel):
start: float
end: float
text: StrictStr
categories: Optional[List[StrictStr]] = None
@field_validator("id", mode="before")
@classmethod
@@ -30,6 +31,16 @@ class TranscriptSegment(BaseModel):
raise ValueError("must not be empty")
return value
@field_validator("categories")
@classmethod
def require_non_empty_categories(cls, categories: Optional[List[str]]) -> Optional[List[str]]:
if categories is None:
return None
for category in categories:
if not category.strip():
raise ValueError("categories must not contain empty strings")
return categories
@field_validator("start", "end", mode="before")
@classmethod
def require_number(cls, value: Any) -> float:
@@ -57,6 +68,7 @@ class SourceTranscriptSegment(BaseModel):
start: float
end: float
text: StrictStr
categories: Optional[List[StrictStr]] = None
@field_validator("id", mode="before")
@classmethod
@@ -74,6 +86,16 @@ class SourceTranscriptSegment(BaseModel):
raise ValueError("must not be empty")
return value
@field_validator("categories")
@classmethod
def require_non_empty_categories(cls, categories: Optional[List[str]]) -> Optional[List[str]]:
if categories is None:
return None
for category in categories:
if not category.strip():
raise ValueError("categories must not contain empty strings")
return categories
@field_validator("start", "end", mode="before")
@classmethod
def require_number(cls, value: Any) -> float:
@@ -208,7 +230,7 @@ def parse_glossary_yaml(raw: str) -> Glossary:
def transcript_to_json(segments: List[TranscriptSegment]) -> str:
payload = [segment.model_dump(mode="json") for segment in segments]
payload = [segment.model_dump(mode="json", exclude_none=True) for segment in segments]
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"

View File

@@ -24,7 +24,7 @@ class ProposalPreview:
return self.segment.text
def to_prompt_payload(self) -> dict:
return {
payload = {
"correction_index": self.proposal.proposal_index,
"id": self.proposal.id,
"original_segment_text": self.original_segment_text,
@@ -32,6 +32,9 @@ class ProposalPreview:
"original_text": self.proposal.original_text,
"corrected_text": self.proposal.corrected_text,
}
if self.segment.categories is not None:
payload["categories"] = list(self.segment.categories)
return payload
@dataclass(frozen=True)

View File

@@ -32,6 +32,7 @@ def build_glossary_proposal_messages(section: TranscriptSection, glossary: Gloss
"- Treat glossary names and aliases already present in the transcript as protected spellings.\n"
"- Do not replace, Anglicize, normalize, lowercase, or otherwise alter protected glossary names or aliases away from their glossary spelling.\n"
"- Preserve canonical glossary capitalization for protected names and aliases, even if they look unusual.\n"
"- If a segment includes categories, treat them as additional transcript context.\n"
"- Plural forms of glossary names and aliases are allowed targets when spoken similarity and context support them, even if the plural is not explicitly listed in the glossary.\n"
"- Use the exact 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"
@@ -71,6 +72,7 @@ def build_homophones_proposal_messages(section: TranscriptSection, glossary: Glo
"- You may correct toward glossary names, aliases, or their plural forms when the correction is acoustically plausible and supported by local context.\n"
"- Do not replace, Anglicize, normalize, lowercase, or otherwise alter protected glossary names or aliases that already appear correctly in the transcript.\n"
"- Preserve canonical glossary capitalization for protected names and aliases, even if they look unusual.\n"
"- If a segment includes categories, treat them as additional transcript context.\n"
"- Use the exact 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"
"- Choose an original_text span that appears exactly once in the current segment text.\n"
@@ -111,6 +113,7 @@ def build_spoken_word_proposal_messages(section: TranscriptSection, glossary: Gl
"- Treat glossary names and aliases as protected spellings and context.\n"
"- Do not replace, Anglicize, normalize, lowercase, or otherwise alter protected glossary names or aliases that already appear correctly in the transcript.\n"
"- Preserve canonical glossary capitalization for protected names and aliases, even if they look unusual.\n"
"- If a segment includes categories, treat them as additional transcript context.\n"
"- Use the exact 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"
"- Choose an original_text span that appears exactly once in the current segment text.\n"
@@ -149,6 +152,7 @@ def build_grammar_proposal_messages(section: TranscriptSection, glossary: Glossa
"- Treat glossary names and aliases as protected spellings and context.\n"
"- Do not replace, Anglicize, normalize, lowercase, or otherwise alter protected glossary names or aliases away from their glossary spelling.\n"
"- Preserve canonical glossary capitalization for protected names and aliases, even if they look unusual.\n"
"- If a segment includes categories, treat them as additional transcript context.\n"
"- Use the exact 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"
"- Choose an original_text span that appears exactly once in the current segment text.\n"

View File

@@ -25,6 +25,7 @@ def build_spoken_form_plausibility_messages(validation_payload: List[dict]) -> L
"- Reject unrelated substitutions like changing \"Lyra\" to \"Jesters\".\n"
"- Judge spoken-form plausibility, not whether the correction is cleaner, more formal, or more grammatical.\n"
"- Do not approve paraphrases, stylistic rewrites, or arbitrary semantic substitutions.\n"
"- If a correction includes categories, treat them as additional segment context.\n"
"- Each returned validation must contain only correction_index, approved, confidence, and reason.\n"
"- confidence must be between 0.0 and 1.0.\n\n"
f"Corrections to validate:\n{payload_json}"
@@ -49,6 +50,7 @@ def build_meaning_reversal_messages(validation_payload: List[dict]) -> List[Mess
"- Evaluate the full original_segment_text and corrected_segment_text, not only the replacement span.\n"
"- Do not reject a correction merely because the literal written word changes.\n"
"- Do not act as a general semantic-style reviewer; this validator is only a guard against antonyms and meaning reversals.\n"
"- If a correction includes categories, treat them as additional segment context.\n"
"- Each returned validation must contain only correction_index, approved, confidence, and reason.\n"
"- confidence must be between 0.0 and 1.0.\n\n"
f"Corrections to validate:\n{payload_json}"
@@ -76,6 +78,7 @@ def build_spoken_word_messages(validation_payload: List[dict]) -> List[Message]:
"- Reject free-standing stylistic polishing, readability edits, paraphrases, and general rewriting.\n"
"- Reject edits that materially change the segment's substantive meaning, even if they are not literal antonyms.\n"
"- Evaluate the full original_segment_text and corrected_segment_text, not only the replacement span.\n"
"- If a correction includes categories, treat them as additional segment context.\n"
"- Each returned validation must contain only correction_index, approved, confidence, and reason.\n"
"- confidence must be between 0.0 and 1.0.\n\n"
f"Corrections to validate:\n{payload_json}"
@@ -101,6 +104,7 @@ def build_grammar_only_messages(validation_payload: List[dict]) -> List[Message]
"- Reject filler cleanup, repetition cleanup, spoken-word dysfluency cleanup, stylistic polishing, broad paraphrase, and unrelated content substitutions.\n"
"- Reject corrections that go beyond conservative grammar-stage cleanup, even if some punctuation or capitalization cleanup is also present.\n"
"- Evaluate the full original_segment_text and corrected_segment_text, not only the replacement span.\n"
"- If a correction includes categories, treat them as additional segment context.\n"
"- Each returned validation must contain only correction_index, approved, confidence, and reason.\n"
"- confidence must be between 0.0 and 1.0.\n\n"
f"Corrections to validate:\n{payload_json}"

View File

@@ -25,3 +25,19 @@ def test_chunk_transcript_batches_sections_by_token_limit():
assert len(sections) == 2
assert [segment.segment.id for segment in sections[0].segments] == [1, 2]
assert [segment.segment.id for segment in sections[1].segments] == [3]
def test_chunk_transcript_prompt_payload_includes_categories_when_present():
transcript = parse_transcript_json(
"""
[
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one", "categories": ["intro", "aside"]}
]
"""
)
sections = chunk_transcript(transcript, max_section_tokens=8, estimator=FakeEstimator())
assert sections[0].prompt_payload() == [
{"id": 1, "original_text": "one", "categories": ["intro", "aside"]}
]

View File

@@ -120,8 +120,8 @@ def test_spoken_form_plausibility_validator_approves_plausible_and_rejects_impla
transcript = parse_transcript_json(
"""
[
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "There were gestures at the temple."},
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "Lyra moved first."}
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "There were gestures at the temple.", "categories": ["narration"]},
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "Lyra moved first.", "categories": ["combat"]}
]
"""
)
@@ -176,6 +176,7 @@ def test_spoken_form_plausibility_validator_approves_plausible_and_rejects_impla
]
prompt_text = client.calls[0]["messages"][1]["content"]
assert '"original_segment_text"' in prompt_text
assert '"categories"' in prompt_text
assert "There were Jesters at the temple." in prompt_text
assert "Lyra moved first." in prompt_text

View File

@@ -46,9 +46,9 @@ def _transcript():
return parse_source_transcript_json(
"""
[
{"speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Hello."},
{"speaker": "Eric", "start": 1.5, "end": 2.0, "text": "Again."},
{"speaker": "Mike", "start": 10.0, "end": 11.0, "text": "Done."}
{"speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Hello.", "categories": ["intro"]},
{"speaker": "Eric", "start": 1.5, "end": 2.0, "text": "Again.", "categories": ["intro", "aside"]},
{"speaker": "Mike", "start": 10.0, "end": 11.0, "text": "Done.", "categories": ["response"]}
]
"""
)
@@ -74,6 +74,8 @@ def test_process_transcript_runs_noop_framework(tmp_path):
assert [segment.id for segment in revised] == [1, 2]
assert revised[0].text == "Hello. Again."
assert revised[1].text == "Done."
assert revised[0].categories == ["intro", "aside"]
assert revised[1].categories == ["response"]
assert [call["stage_name"] for call in llm_client.calls] == [
"glossary_1:proposal",
"homophones:proposal",
@@ -186,6 +188,30 @@ def test_external_report_can_be_written(tmp_path):
assert payload["totals"]["applied_change_count"] == 0
def test_process_transcript_preserves_categories_in_llm_prompt_payloads(tmp_path):
llm_client = FakeStructuredLLMClient(
[
{"corrections": []},
{"corrections": []},
{"corrections": []},
{"corrections": []},
{"corrections": []},
]
)
process_transcript(
_transcript(),
_glossary(),
AuditaConfig.from_sources(env={}, overrides=None),
llm_client=llm_client,
)
proposal_prompt = llm_client.calls[0]["messages"][1]["content"]
assert '"categories": [' in proposal_prompt
assert '"intro"' in proposal_prompt
assert '"aside"' in proposal_prompt
def test_default_module_specs_expose_final_validator_order():
specs = default_module_specs()

View File

@@ -1,4 +1,6 @@
from audita.core.schemas import parse_source_transcript_json, parse_transcript_json
import json
from audita.core.schemas import parse_source_transcript_json, parse_transcript_json, transcript_to_json
SERIATIM_TRANSCRIPT = """
@@ -60,6 +62,8 @@ def test_parse_source_transcript_json_accepts_seriatim_transcript_object():
assert segments[0].end == 3.5
assert segments[0].text == "Hello there."
assert segments[1].text == "Resolved word run"
assert segments[0].categories is None
assert segments[1].categories == ["backchannel"]
def test_parse_transcript_json_accepts_seriatim_transcript_object_and_ignores_unused_fields():
@@ -67,3 +71,14 @@ def test_parse_transcript_json_accepts_seriatim_transcript_object_and_ignores_un
assert [segment.id for segment in segments] == [1, 2]
assert [segment.text for segment in segments] == ["Hello there.", "Resolved word run"]
assert segments[0].categories is None
assert segments[1].categories == ["backchannel"]
def test_transcript_to_json_emits_categories_only_when_present():
segments = parse_transcript_json(SERIATIM_TRANSCRIPT)
payload = json.loads(transcript_to_json(segments))
assert "categories" not in payload[0]
assert payload[1]["categories"] == ["backchannel"]