diff --git a/src/audita/core/chunking.py b/src/audita/core/chunking.py index 544cefd..1bbe19d 100644 --- a/src/audita/core/chunking.py +++ b/src/audita/core/chunking.py @@ -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) diff --git a/src/audita/core/normalization.py b/src/audita/core/normalization.py index 3f7a05d..959a3ca 100644 --- a/src/audita/core/normalization.py +++ b/src/audita/core/normalization.py @@ -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) ] diff --git a/src/audita/core/schemas.py b/src/audita/core/schemas.py index 1ca00ca..dd8a911 100644 --- a/src/audita/core/schemas.py +++ b/src/audita/core/schemas.py @@ -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" diff --git a/src/audita/framework/proposals.py b/src/audita/framework/proposals.py index 42bb66c..d0ac8f4 100644 --- a/src/audita/framework/proposals.py +++ b/src/audita/framework/proposals.py @@ -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) diff --git a/src/audita/modules/prompts.py b/src/audita/modules/prompts.py index a40a4d0..4a9bcf6 100644 --- a/src/audita/modules/prompts.py +++ b/src/audita/modules/prompts.py @@ -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" diff --git a/src/audita/validators/prompts.py b/src/audita/validators/prompts.py index 1e22281..3d077e1 100644 --- a/src/audita/validators/prompts.py +++ b/src/audita/validators/prompts.py @@ -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}" diff --git a/tests/test_framework_chunking.py b/tests/test_framework_chunking.py index 53f026b..437fc19 100644 --- a/tests/test_framework_chunking.py +++ b/tests/test_framework_chunking.py @@ -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"]} + ] diff --git a/tests/test_llm_validators.py b/tests/test_llm_validators.py index 730aa6f..3533f24 100644 --- a/tests/test_llm_validators.py +++ b/tests/test_llm_validators.py @@ -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 diff --git a/tests/test_new_pipeline.py b/tests/test_new_pipeline.py index 791af82..0d0a2c3 100644 --- a/tests/test_new_pipeline.py +++ b/tests/test_new_pipeline.py @@ -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() diff --git a/tests/test_new_transcript_schema.py b/tests/test_new_transcript_schema.py index 29802d6..f62ad15 100644 --- a/tests/test_new_transcript_schema.py +++ b/tests/test_new_transcript_schema.py @@ -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"]