Implement support for segments with an optional categories key
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"]}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user