Move python implementation under python/ in preparation for the upcoming Go rewrite
This commit is contained in:
1
python/tests/__init__.py
Normal file
1
python/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test package root."""
|
||||
206
python/tests/test_framework_chunking.py
Normal file
206
python/tests/test_framework_chunking.py
Normal file
@@ -0,0 +1,206 @@
|
||||
import pytest
|
||||
|
||||
from audita.core.chunking import TokenEstimatorProtocol, chunk_transcript
|
||||
from audita.core.errors import AuditaValidationError
|
||||
from audita.core.schemas import parse_transcript_json
|
||||
|
||||
|
||||
class FakeEstimator(TokenEstimatorProtocol):
|
||||
def estimate_json(self, value):
|
||||
if len(value) == 1:
|
||||
return 4
|
||||
return len(value) * 4
|
||||
|
||||
|
||||
def test_chunk_transcript_batches_sections_by_token_limit():
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one"},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "two"},
|
||||
{"id": 3, "speaker": "A", "start": 2.0, "end": 3.0, "text": "three"}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
sections = chunk_transcript(transcript, max_section_tokens=8, estimator=FakeEstimator())
|
||||
|
||||
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_targets_llm_concurrency_when_feasible():
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one"},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "two"},
|
||||
{"id": 3, "speaker": "A", "start": 2.0, "end": 3.0, "text": "three"},
|
||||
{"id": 4, "speaker": "A", "start": 3.0, "end": 4.0, "text": "four"}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
sections = chunk_transcript(
|
||||
transcript,
|
||||
max_section_tokens=8,
|
||||
min_section_tokens=4,
|
||||
target_section_count=2,
|
||||
estimator=FakeEstimator(),
|
||||
)
|
||||
|
||||
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, 4]
|
||||
|
||||
|
||||
def test_chunk_transcript_increases_section_count_when_target_sections_exceed_max_tokens():
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one"},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "two"},
|
||||
{"id": 3, "speaker": "A", "start": 2.0, "end": 3.0, "text": "three"},
|
||||
{"id": 4, "speaker": "A", "start": 3.0, "end": 4.0, "text": "four"},
|
||||
{"id": 5, "speaker": "A", "start": 4.0, "end": 5.0, "text": "five"}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
sections = chunk_transcript(
|
||||
transcript,
|
||||
max_section_tokens=8,
|
||||
min_section_tokens=4,
|
||||
target_section_count=1,
|
||||
estimator=FakeEstimator(),
|
||||
)
|
||||
|
||||
assert len(sections) > 1
|
||||
assert all(section.token_count <= 8 for section in sections)
|
||||
|
||||
|
||||
def test_chunk_transcript_reduces_section_count_when_target_sections_fall_below_min_tokens():
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one"},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "two"},
|
||||
{"id": 3, "speaker": "A", "start": 2.0, "end": 3.0, "text": "three"}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
sections = chunk_transcript(
|
||||
transcript,
|
||||
max_section_tokens=12,
|
||||
min_section_tokens=8,
|
||||
target_section_count=3,
|
||||
estimator=FakeEstimator(),
|
||||
)
|
||||
|
||||
assert len(sections) == 1
|
||||
assert sections[0].token_count >= 8
|
||||
|
||||
|
||||
def test_chunk_transcript_exact_target_sections_returns_exact_count_when_feasible():
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one"},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "two"},
|
||||
{"id": 3, "speaker": "A", "start": 2.0, "end": 3.0, "text": "three"},
|
||||
{"id": 4, "speaker": "A", "start": 3.0, "end": 4.0, "text": "four"}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
sections = chunk_transcript(
|
||||
transcript,
|
||||
max_section_tokens=8,
|
||||
min_section_tokens=4,
|
||||
exact_target_section_count=2,
|
||||
estimator=FakeEstimator(),
|
||||
)
|
||||
|
||||
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, 4]
|
||||
|
||||
|
||||
def test_chunk_transcript_exact_target_sections_errors_when_target_exceeds_segment_count():
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one"},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "two"}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
with pytest.raises(AuditaValidationError, match="Target section count exceeds the number of transcript segments"):
|
||||
chunk_transcript(
|
||||
transcript,
|
||||
max_section_tokens=8,
|
||||
min_section_tokens=4,
|
||||
exact_target_section_count=3,
|
||||
estimator=FakeEstimator(),
|
||||
)
|
||||
|
||||
|
||||
def test_chunk_transcript_exact_target_sections_errors_when_section_would_exceed_max():
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one"},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "two"},
|
||||
{"id": 3, "speaker": "A", "start": 2.0, "end": 3.0, "text": "three"}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
with pytest.raises(AuditaValidationError, match="AUDITA_TARGET_SECTIONS cannot produce contiguous transcript sections"):
|
||||
chunk_transcript(
|
||||
transcript,
|
||||
max_section_tokens=8,
|
||||
min_section_tokens=4,
|
||||
exact_target_section_count=1,
|
||||
estimator=FakeEstimator(),
|
||||
)
|
||||
|
||||
|
||||
def test_chunk_transcript_exact_target_sections_errors_when_section_would_fall_below_min():
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "one"},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "two"},
|
||||
{"id": 3, "speaker": "A", "start": 2.0, "end": 3.0, "text": "three"}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
with pytest.raises(AuditaValidationError, match="AUDITA_TARGET_SECTIONS cannot produce contiguous transcript sections"):
|
||||
chunk_transcript(
|
||||
transcript,
|
||||
max_section_tokens=12,
|
||||
min_section_tokens=8,
|
||||
exact_target_section_count=3,
|
||||
estimator=FakeEstimator(),
|
||||
)
|
||||
|
||||
|
||||
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"]}
|
||||
]
|
||||
255
python/tests/test_framework_llm.py
Normal file
255
python/tests/test_framework_llm.py
Normal file
@@ -0,0 +1,255 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from audita.core.config import AuditaConfig
|
||||
from audita.core.errors import AuditaLLMError
|
||||
from audita.framework.llm import OpenAICompatibleStructuredLLMClient
|
||||
|
||||
|
||||
class DummyResponseModel:
|
||||
pass
|
||||
|
||||
|
||||
def _config(**overrides):
|
||||
base = AuditaConfig.from_sources(env={})
|
||||
data = {
|
||||
"api_key": "test-key",
|
||||
"llm_concurrency": base.llm_concurrency,
|
||||
"llm_timeout_seconds": base.llm_timeout_seconds,
|
||||
"validation_llm_api_key": base.validation_llm_api_key,
|
||||
"validation_llm_concurrency": base.validation_llm_concurrency,
|
||||
"validation_llm_timeout_seconds": base.validation_llm_timeout_seconds,
|
||||
"validation_model": base.validation_model,
|
||||
"validation_base_url": base.validation_base_url,
|
||||
"validation_max_retries": base.validation_max_retries,
|
||||
"module_keys": base.module_keys,
|
||||
"model": base.model,
|
||||
"base_url": base.base_url,
|
||||
"max_retries": base.max_retries,
|
||||
"max_section_tokens": base.max_section_tokens,
|
||||
"glossary_confidence_threshold": base.glossary_confidence_threshold,
|
||||
"grammar_confidence_threshold": base.grammar_confidence_threshold,
|
||||
"homophones_confidence_threshold": base.homophones_confidence_threshold,
|
||||
"spoken_word_confidence_threshold": base.spoken_word_confidence_threshold,
|
||||
"normalize_max_segment_gap": base.normalize_max_segment_gap,
|
||||
"normalize_ellipsis_gap": base.normalize_ellipsis_gap,
|
||||
"normalize_max_segment_duration": base.normalize_max_segment_duration,
|
||||
"normalize_max_segment_tokens": base.normalize_max_segment_tokens,
|
||||
"work_dir": base.work_dir,
|
||||
"work_dir_retention": base.work_dir_retention,
|
||||
}
|
||||
data.update(overrides)
|
||||
return AuditaConfig(**data)
|
||||
|
||||
|
||||
def _install_fake_llm_modules(monkeypatch):
|
||||
create_calls = []
|
||||
openai_inits = []
|
||||
|
||||
class FakePatchedClient:
|
||||
def __init__(self):
|
||||
self.chat = types.SimpleNamespace(completions=types.SimpleNamespace(create=self._create))
|
||||
|
||||
def _create(self, **kwargs):
|
||||
create_calls.append(kwargs)
|
||||
return {"ok": True}
|
||||
|
||||
class FakeOpenAI:
|
||||
def __init__(self, *, api_key, base_url, timeout):
|
||||
openai_inits.append({"api_key": api_key, "base_url": base_url, "timeout": timeout})
|
||||
|
||||
fake_instructor = types.SimpleNamespace(
|
||||
Mode=types.SimpleNamespace(TOOLS="TOOLS"),
|
||||
patch=lambda client, mode: FakePatchedClient(),
|
||||
)
|
||||
fake_openai = types.SimpleNamespace(OpenAI=FakeOpenAI)
|
||||
monkeypatch.setitem(sys.modules, "instructor", fake_instructor)
|
||||
monkeypatch.setitem(sys.modules, "openai", fake_openai)
|
||||
return create_calls, openai_inits
|
||||
|
||||
|
||||
def test_openrouter_requests_strip_prefix_and_include_extra_body(monkeypatch):
|
||||
create_calls, _ = _install_fake_llm_modules(monkeypatch)
|
||||
client = OpenAICompatibleStructuredLLMClient()
|
||||
|
||||
client.run_structured(
|
||||
stage_name="test-stage",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=_config(model="openrouter/google/gemma-4-31b-it"),
|
||||
)
|
||||
|
||||
assert create_calls == [
|
||||
{
|
||||
"model": "google/gemma-4-31b-it",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"response_model": DummyResponseModel,
|
||||
"max_retries": 3,
|
||||
"extra_body": {"provider": {"require_parameters": True}},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_openrouter_default_base_url_uses_openrouter_request_shape(monkeypatch):
|
||||
create_calls, _ = _install_fake_llm_modules(monkeypatch)
|
||||
client = OpenAICompatibleStructuredLLMClient()
|
||||
|
||||
client.run_structured(
|
||||
stage_name="test-stage",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=_config(model="google/gemma-4-31b-it"),
|
||||
)
|
||||
|
||||
assert create_calls == [
|
||||
{
|
||||
"model": "google/gemma-4-31b-it",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"response_model": DummyResponseModel,
|
||||
"max_retries": 3,
|
||||
"extra_body": {"provider": {"require_parameters": True}},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_generic_endpoint_requests_keep_model_and_omit_extra_body(monkeypatch):
|
||||
create_calls, _ = _install_fake_llm_modules(monkeypatch)
|
||||
client = OpenAICompatibleStructuredLLMClient()
|
||||
|
||||
client.run_structured(
|
||||
stage_name="test-stage",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=_config(
|
||||
model="meta-llama/Llama-3.1-8B-Instruct",
|
||||
base_url="http://localhost:8000/v1",
|
||||
),
|
||||
)
|
||||
|
||||
assert create_calls == [
|
||||
{
|
||||
"model": "meta-llama/Llama-3.1-8B-Instruct",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"response_model": DummyResponseModel,
|
||||
"max_retries": 3,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_client_cache_identity_uses_api_key_and_base_url(monkeypatch):
|
||||
_, openai_inits = _install_fake_llm_modules(monkeypatch)
|
||||
client = OpenAICompatibleStructuredLLMClient()
|
||||
first = _config(api_key="key-1", base_url="http://localhost:8000/v1")
|
||||
second = _config(api_key="key-1", base_url="http://localhost:8000/v1")
|
||||
third = _config(api_key="key-1", base_url="https://api.openai.com/v1")
|
||||
|
||||
client.run_structured(
|
||||
stage_name="one",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=first,
|
||||
)
|
||||
client.run_structured(
|
||||
stage_name="two",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=second,
|
||||
)
|
||||
client.run_structured(
|
||||
stage_name="three",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=third,
|
||||
)
|
||||
|
||||
assert openai_inits == [
|
||||
{"api_key": "key-1", "base_url": "http://localhost:8000/v1", "timeout": 600},
|
||||
{"api_key": "key-1", "base_url": "https://api.openai.com/v1", "timeout": 600},
|
||||
]
|
||||
|
||||
|
||||
def test_client_cache_identity_uses_timeout(monkeypatch):
|
||||
_, openai_inits = _install_fake_llm_modules(monkeypatch)
|
||||
client = OpenAICompatibleStructuredLLMClient()
|
||||
first = _config(api_key="key-1", base_url="http://localhost:8000/v1", llm_timeout_seconds=600)
|
||||
second = _config(api_key="key-1", base_url="http://localhost:8000/v1", llm_timeout_seconds=1200)
|
||||
|
||||
client.run_structured(
|
||||
stage_name="one",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=first,
|
||||
)
|
||||
client.run_structured(
|
||||
stage_name="two",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=second,
|
||||
)
|
||||
|
||||
assert openai_inits == [
|
||||
{"api_key": "key-1", "base_url": "http://localhost:8000/v1", "timeout": 600},
|
||||
{"api_key": "key-1", "base_url": "http://localhost:8000/v1", "timeout": 1200},
|
||||
]
|
||||
|
||||
|
||||
def test_missing_api_key_error_is_provider_neutral():
|
||||
client = OpenAICompatibleStructuredLLMClient()
|
||||
|
||||
with pytest.raises(AuditaLLMError, match="OpenRouter endpoint"):
|
||||
client.run_structured(
|
||||
stage_name="test-stage",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=_config(api_key=None),
|
||||
)
|
||||
|
||||
|
||||
def test_missing_api_key_is_allowed_for_nondefault_endpoint(monkeypatch):
|
||||
create_calls, openai_inits = _install_fake_llm_modules(monkeypatch)
|
||||
client = OpenAICompatibleStructuredLLMClient()
|
||||
|
||||
client.run_structured(
|
||||
stage_name="test-stage",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=_config(
|
||||
api_key=None,
|
||||
model="meta-llama/Llama-3.1-8B-Instruct",
|
||||
base_url="http://localhost:8000/v1",
|
||||
),
|
||||
)
|
||||
|
||||
assert openai_inits == [{"api_key": "audita-no-key-required", "base_url": "http://localhost:8000/v1", "timeout": 600}]
|
||||
assert create_calls == [
|
||||
{
|
||||
"model": "meta-llama/Llama-3.1-8B-Instruct",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"response_model": DummyResponseModel,
|
||||
"max_retries": 3,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_client_initialization_is_safe_under_concurrent_calls(monkeypatch):
|
||||
_, openai_inits = _install_fake_llm_modules(monkeypatch)
|
||||
client = OpenAICompatibleStructuredLLMClient()
|
||||
config = _config(api_key="key-1", base_url="http://localhost:8000/v1")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
list(
|
||||
executor.map(
|
||||
lambda _: client.run_structured(
|
||||
stage_name="test-stage",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
response_model=DummyResponseModel,
|
||||
config=config,
|
||||
),
|
||||
range(4),
|
||||
)
|
||||
)
|
||||
|
||||
assert openai_inits == [{"api_key": "key-1", "base_url": "http://localhost:8000/v1", "timeout": 600}]
|
||||
642
python/tests/test_framework_runner.py
Normal file
642
python/tests/test_framework_runner.py
Normal file
@@ -0,0 +1,642 @@
|
||||
import threading
|
||||
|
||||
from audita.core.chunking import IndexedSegment, TranscriptSection
|
||||
from audita.core.config import AuditaConfig, ConfigOverrides
|
||||
from audita.core.errors import AuditaLLMError
|
||||
from audita.core.schemas import parse_glossary_yaml, parse_transcript_json
|
||||
from audita.framework.models import CorrectionProposal, ModuleContext, ModuleRunSpec
|
||||
from audita.framework.runner import PipelineRunner
|
||||
from audita.validators import (
|
||||
MeaningReversalValidator,
|
||||
ProposalConfidenceValidator,
|
||||
ProtectedGlossaryTermsValidator,
|
||||
SpokenFormPlausibilityValidator,
|
||||
)
|
||||
from audita.validators.base import ValidationContext, ValidationDecision, ValidationResult
|
||||
|
||||
|
||||
class RecordingValidator:
|
||||
execution_kind = "deterministic"
|
||||
|
||||
def __init__(self, name, recorder, approve=True):
|
||||
self.name = name
|
||||
self._recorder = recorder
|
||||
self._approve = approve
|
||||
|
||||
def validate(self, context: ValidationContext) -> ValidationResult:
|
||||
self._recorder.append((self.name, [proposal.corrected_text for proposal in context.proposals]))
|
||||
return ValidationResult(
|
||||
validator_name=self.name,
|
||||
execution_kind=self.execution_kind,
|
||||
decisions=[
|
||||
ValidationDecision(
|
||||
proposal_index=proposal.proposal_index,
|
||||
approved=self._approve,
|
||||
reason=None if self._approve else f"{self.name} rejected proposal",
|
||||
)
|
||||
for proposal in context.proposals
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class RecordingLLMValidator(RecordingValidator):
|
||||
execution_kind = "llm"
|
||||
|
||||
|
||||
class FakeStructuredLLMClient:
|
||||
def __init__(self, responses):
|
||||
self._responses = responses
|
||||
self._lock = threading.Lock()
|
||||
self.calls = []
|
||||
|
||||
def run_structured(self, *, stage_name, messages, response_model, config):
|
||||
with self._lock:
|
||||
self.calls.append(
|
||||
{
|
||||
"stage_name": stage_name,
|
||||
"messages": list(messages),
|
||||
"response_model": response_model,
|
||||
}
|
||||
)
|
||||
payload = _pop_llm_response(self._responses, stage_name)
|
||||
return response_model.model_validate(payload)
|
||||
|
||||
|
||||
def _pop_llm_response(responses, stage_name):
|
||||
if isinstance(responses, dict):
|
||||
if stage_name not in responses:
|
||||
raise AuditaLLMError(f"FakeStructuredLLMClient received unexpected stage_name: {stage_name}")
|
||||
payloads = responses[stage_name]
|
||||
if isinstance(payloads, list):
|
||||
if not payloads:
|
||||
raise AuditaLLMError(f"FakeStructuredLLMClient received too many calls for stage_name: {stage_name}")
|
||||
return payloads.pop(0)
|
||||
payload = payloads
|
||||
del responses[stage_name]
|
||||
return payload
|
||||
if not responses:
|
||||
raise AuditaLLMError("FakeStructuredLLMClient received more calls than expected.")
|
||||
return responses.pop(0)
|
||||
|
||||
|
||||
class TrackingStructuredLLMClient:
|
||||
def __init__(self, responses, barrier=None):
|
||||
self._responses = responses
|
||||
self._barrier = barrier
|
||||
self._lock = threading.Lock()
|
||||
self.calls = []
|
||||
self.in_flight = 0
|
||||
self.max_in_flight = 0
|
||||
|
||||
def run_structured(self, *, stage_name, messages, response_model, config):
|
||||
if self._barrier is not None:
|
||||
self._barrier.wait()
|
||||
with self._lock:
|
||||
self.in_flight += 1
|
||||
self.max_in_flight = max(self.max_in_flight, self.in_flight)
|
||||
self.calls.append(
|
||||
{
|
||||
"stage_name": stage_name,
|
||||
"messages": list(messages),
|
||||
"response_model": response_model,
|
||||
}
|
||||
)
|
||||
payload = _pop_llm_response(self._responses, stage_name)
|
||||
try:
|
||||
return response_model.model_validate(payload)
|
||||
finally:
|
||||
with self._lock:
|
||||
self.in_flight -= 1
|
||||
|
||||
|
||||
class RecordingModule:
|
||||
replacement_policy = "require_unique"
|
||||
|
||||
def __init__(self, module_key, proposals, validators, recorder):
|
||||
self.module_key = module_key
|
||||
self._proposals = proposals
|
||||
self._validators = validators
|
||||
self._recorder = recorder
|
||||
|
||||
def validators(self):
|
||||
return list(self._validators)
|
||||
|
||||
def propose(self, transcript_section, context: ModuleContext):
|
||||
self._recorder.append(("propose", [item.segment.text for item in transcript_section.segments]))
|
||||
return list(self._proposals)
|
||||
|
||||
|
||||
class ConcurrentRecordingModule:
|
||||
replacement_policy = "require_unique"
|
||||
|
||||
def __init__(self, recorder, barrier):
|
||||
self.module_key = "concurrent"
|
||||
self._recorder = recorder
|
||||
self._barrier = barrier
|
||||
|
||||
def validators(self):
|
||||
return []
|
||||
|
||||
def propose(self, transcript_section, context: ModuleContext):
|
||||
texts = [item.segment.text for item in transcript_section.segments]
|
||||
self._recorder.append(("start", transcript_section.section_index, texts))
|
||||
self._barrier.wait()
|
||||
segment = transcript_section.segments[0].segment
|
||||
return [
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance=context.run_spec.instance_name,
|
||||
module_key=context.run_spec.module_key,
|
||||
id=segment.id,
|
||||
original_text=segment.text.rstrip("."),
|
||||
corrected_text=f"{segment.text.rstrip('.')} revised",
|
||||
confidence=0.9,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_pipeline_runner_applies_modules_sequentially(tmp_path):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "Alpha."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Alpha"
|
||||
category: noun
|
||||
summary: "Alpha."
|
||||
"""
|
||||
)
|
||||
seen = []
|
||||
first = RecordingModule(
|
||||
"first",
|
||||
[
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="first",
|
||||
module_key="first",
|
||||
id=1,
|
||||
original_text="Alpha",
|
||||
corrected_text="Beta",
|
||||
confidence=0.9,
|
||||
)
|
||||
],
|
||||
[RecordingValidator("first_validator", seen)],
|
||||
seen,
|
||||
)
|
||||
second = RecordingModule(
|
||||
"second",
|
||||
[
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="second",
|
||||
module_key="second",
|
||||
id=1,
|
||||
original_text="Beta",
|
||||
corrected_text="Gamma",
|
||||
confidence=0.9,
|
||||
)
|
||||
],
|
||||
[RecordingValidator("second_validator", seen)],
|
||||
seen,
|
||||
)
|
||||
|
||||
runner = PipelineRunner()
|
||||
result = runner.run(
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
module_specs=[
|
||||
ModuleRunSpec(instance_name="first", module_key="first", module=first),
|
||||
ModuleRunSpec(instance_name="second", module_key="second", module=second),
|
||||
],
|
||||
config=AuditaConfig.from_sources(env={}),
|
||||
run_dir=tmp_path / "run",
|
||||
)
|
||||
|
||||
assert seen[0] == ("propose", ["Alpha."])
|
||||
assert seen[1] == ("first_validator", ["Beta"])
|
||||
assert seen[2] == ("propose", ["Beta."])
|
||||
assert seen[3] == ("second_validator", ["Gamma"])
|
||||
assert result.transcript[0].text == "Gamma."
|
||||
assert len(result.applied_changes) == 2
|
||||
|
||||
|
||||
def test_pipeline_runner_validator_order_respects_survivors(tmp_path):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "Hello."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Hello"
|
||||
category: noun
|
||||
summary: "Hello."
|
||||
"""
|
||||
)
|
||||
seen = []
|
||||
module = RecordingModule(
|
||||
"mod",
|
||||
[
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="mod",
|
||||
module_key="mod",
|
||||
id=1,
|
||||
original_text="Hello",
|
||||
corrected_text="Goodbye",
|
||||
confidence=0.9,
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordingValidator("first", seen, approve=False),
|
||||
RecordingLLMValidator("second", seen, approve=True),
|
||||
],
|
||||
seen,
|
||||
)
|
||||
|
||||
runner = PipelineRunner()
|
||||
result = runner.run(
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
module_specs=[ModuleRunSpec(instance_name="mod", module_key="mod", module=module)],
|
||||
config=AuditaConfig.from_sources(env={}),
|
||||
run_dir=tmp_path / "run",
|
||||
)
|
||||
|
||||
assert ("first", ["Goodbye"]) in seen
|
||||
assert all(entry[0] != "second" for entry in seen)
|
||||
assert result.module_reports[0].validators[0].rejected_count == 1
|
||||
assert result.module_reports[0].validators[1].candidate_count == 0
|
||||
assert result.skipped_corrections[0].source == "validator:first"
|
||||
|
||||
|
||||
def test_pipeline_runner_supports_deterministic_and_llm_validators_in_one_chain(tmp_path):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "Alpha."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Alpha"
|
||||
category: noun
|
||||
summary: "Alpha."
|
||||
"""
|
||||
)
|
||||
seen = []
|
||||
module = RecordingModule(
|
||||
"mixed",
|
||||
[
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="mixed",
|
||||
module_key="mixed",
|
||||
id=1,
|
||||
original_text="Alpha",
|
||||
corrected_text="Beta",
|
||||
confidence=0.9,
|
||||
)
|
||||
],
|
||||
[
|
||||
RecordingValidator("deterministic_guard", seen),
|
||||
RecordingLLMValidator("llm_review", seen),
|
||||
],
|
||||
seen,
|
||||
)
|
||||
|
||||
runner = PipelineRunner()
|
||||
result = runner.run(
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
module_specs=[ModuleRunSpec(instance_name="mixed", module_key="mixed", module=module)],
|
||||
config=AuditaConfig.from_sources(env={}),
|
||||
run_dir=tmp_path / "run",
|
||||
)
|
||||
|
||||
assert result.transcript[0].text == "Beta."
|
||||
assert [report.execution_kind for report in result.module_reports[0].validators] == [
|
||||
"deterministic",
|
||||
"llm",
|
||||
]
|
||||
|
||||
|
||||
def test_pipeline_runner_supports_real_llm_validators_in_one_chain(tmp_path):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "There were gestures at the dam."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Jesters"
|
||||
category: faction
|
||||
summary: "The Jesters are a faction."
|
||||
"""
|
||||
)
|
||||
module = RecordingModule(
|
||||
"mixed_real",
|
||||
[
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="mixed_real",
|
||||
module_key="glossary",
|
||||
id=1,
|
||||
original_text="gestures",
|
||||
corrected_text="Jesters",
|
||||
confidence=0.9,
|
||||
)
|
||||
],
|
||||
[
|
||||
ProposalConfidenceValidator("proposal_confidence_guard", "glossary_confidence_threshold"),
|
||||
ProtectedGlossaryTermsValidator("protected_glossary_guard"),
|
||||
SpokenFormPlausibilityValidator("spoken_form_plausibility_review"),
|
||||
MeaningReversalValidator("meaning_reversal_review"),
|
||||
],
|
||||
[],
|
||||
)
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
{
|
||||
"mixed_real:spoken_form_plausibility_review": {
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": True,
|
||||
"confidence": 0.95,
|
||||
"reason": "Likely phonetic mistranscription in context.",
|
||||
}
|
||||
]
|
||||
},
|
||||
"mixed_real:meaning_reversal_review": {
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": True,
|
||||
"confidence": 0.98,
|
||||
"reason": "Does not reverse the segment meaning.",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
runner = PipelineRunner()
|
||||
result = runner.run(
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
module_specs=[ModuleRunSpec(instance_name="mixed_real", module_key="glossary", module=module)],
|
||||
config=AuditaConfig.from_sources(env={"OPENROUTER_API_KEY": "test-key"}),
|
||||
run_dir=tmp_path / "run",
|
||||
llm_client=llm_client,
|
||||
)
|
||||
|
||||
assert result.transcript[0].text == "There were Jesters at the dam."
|
||||
assert [report.execution_kind for report in result.module_reports[0].validators] == [
|
||||
"deterministic",
|
||||
"deterministic",
|
||||
"llm",
|
||||
"llm",
|
||||
]
|
||||
assert {call["stage_name"] for call in llm_client.calls} == {
|
||||
"mixed_real:spoken_form_plausibility_review",
|
||||
"mixed_real:meaning_reversal_review",
|
||||
}
|
||||
|
||||
|
||||
def test_pipeline_runner_uses_real_protected_glossary_validator(tmp_path):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "Hrank moves."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Hrank"
|
||||
category: pc
|
||||
summary: "Hrank is a player character."
|
||||
"""
|
||||
)
|
||||
module = RecordingModule(
|
||||
"protected",
|
||||
[
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="protected",
|
||||
module_key="protected",
|
||||
id=1,
|
||||
original_text="Hrank",
|
||||
corrected_text="Frank",
|
||||
confidence=0.9,
|
||||
)
|
||||
],
|
||||
[ProtectedGlossaryTermsValidator("protected_glossary_guard")],
|
||||
[],
|
||||
)
|
||||
|
||||
runner = PipelineRunner()
|
||||
result = runner.run(
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
module_specs=[ModuleRunSpec(instance_name="protected", module_key="protected", module=module)],
|
||||
config=AuditaConfig.from_sources(env={}),
|
||||
run_dir=tmp_path / "run",
|
||||
)
|
||||
|
||||
assert result.transcript[0].text == "Hrank moves."
|
||||
assert result.skipped_corrections[0].source == "validator:protected_glossary_guard"
|
||||
assert result.skipped_corrections[0].reason == "correction changes protected glossary term usage"
|
||||
|
||||
|
||||
def test_pipeline_runner_collects_section_proposals_concurrently_and_preserves_section_order(tmp_path, monkeypatch):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "Alpha."},
|
||||
{"id": 2, "speaker": "A", "start": 1.0, "end": 2.0, "text": "Beta."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Alpha"
|
||||
category: noun
|
||||
summary: "Alpha."
|
||||
"""
|
||||
)
|
||||
sections = [
|
||||
TranscriptSection(
|
||||
section_index=0,
|
||||
start_index=0,
|
||||
segments=[IndexedSegment(index=0, segment=transcript[0])],
|
||||
token_count=1,
|
||||
),
|
||||
TranscriptSection(
|
||||
section_index=1,
|
||||
start_index=1,
|
||||
segments=[IndexedSegment(index=1, segment=transcript[1])],
|
||||
token_count=1,
|
||||
),
|
||||
]
|
||||
seen = []
|
||||
module = ConcurrentRecordingModule(seen, threading.Barrier(2, timeout=1.0))
|
||||
monkeypatch.setattr(
|
||||
"audita.framework.runner.chunk_transcript",
|
||||
lambda working, max_tokens, min_section_tokens=1, target_section_count=None, exact_target_section_count=None: sections,
|
||||
)
|
||||
|
||||
runner = PipelineRunner()
|
||||
result = runner.run(
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
module_specs=[ModuleRunSpec(instance_name="concurrent", module_key="concurrent", module=module)],
|
||||
config=AuditaConfig.from_sources(env={}, overrides=ConfigOverrides(llm_concurrency=2)),
|
||||
run_dir=tmp_path / "run",
|
||||
)
|
||||
|
||||
assert [change.id for change in result.applied_changes] == [1, 2]
|
||||
assert result.applied_changes[0].corrected_text == "Alpha revised"
|
||||
assert result.applied_changes[1].corrected_text == "Beta revised"
|
||||
assert result.transcript[0].text == "Alpha revised."
|
||||
assert result.transcript[1].text == "Beta revised."
|
||||
|
||||
|
||||
def test_pipeline_runner_passes_exact_target_sections_to_chunker(tmp_path, monkeypatch):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "Alpha."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Alpha"
|
||||
category: noun
|
||||
summary: "Alpha."
|
||||
"""
|
||||
)
|
||||
seen_args = {}
|
||||
sections = [
|
||||
TranscriptSection(
|
||||
section_index=0,
|
||||
start_index=0,
|
||||
segments=[IndexedSegment(index=0, segment=transcript[0])],
|
||||
token_count=1,
|
||||
)
|
||||
]
|
||||
module = RecordingModule("noop", [], [], [])
|
||||
|
||||
def _fake_chunk_transcript(working, max_tokens, min_section_tokens=1, target_section_count=None, exact_target_section_count=None):
|
||||
seen_args["target_section_count"] = target_section_count
|
||||
seen_args["exact_target_section_count"] = exact_target_section_count
|
||||
return sections
|
||||
|
||||
monkeypatch.setattr("audita.framework.runner.chunk_transcript", _fake_chunk_transcript)
|
||||
|
||||
runner = PipelineRunner()
|
||||
runner.run(
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
module_specs=[ModuleRunSpec(instance_name="noop", module_key="noop", module=module)],
|
||||
config=AuditaConfig.from_sources(env={}, overrides=ConfigOverrides(target_sections=3)),
|
||||
run_dir=tmp_path / "run",
|
||||
)
|
||||
|
||||
assert seen_args == {
|
||||
"target_section_count": None,
|
||||
"exact_target_section_count": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_pipeline_runner_reports_first_llm_validator_rejection_in_chain_order(tmp_path):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "A", "start": 0.0, "end": 1.0, "text": "There were gestures at the dam."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Jesters"
|
||||
category: faction
|
||||
summary: "The Jesters are a faction."
|
||||
"""
|
||||
)
|
||||
module = RecordingModule(
|
||||
"ordered_real",
|
||||
[
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="ordered_real",
|
||||
module_key="glossary",
|
||||
id=1,
|
||||
original_text="gestures",
|
||||
corrected_text="Jesters",
|
||||
confidence=0.9,
|
||||
)
|
||||
],
|
||||
[
|
||||
ProposalConfidenceValidator("proposal_confidence_guard", "glossary_confidence_threshold"),
|
||||
ProtectedGlossaryTermsValidator("protected_glossary_guard"),
|
||||
SpokenFormPlausibilityValidator("spoken_form_plausibility_review"),
|
||||
MeaningReversalValidator("meaning_reversal_review"),
|
||||
],
|
||||
[],
|
||||
)
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
{
|
||||
"ordered_real:spoken_form_plausibility_review": {
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": False,
|
||||
"confidence": 0.95,
|
||||
"reason": "Not plausibly supported by spoken-form context.",
|
||||
}
|
||||
]
|
||||
},
|
||||
"ordered_real:meaning_reversal_review": {
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": False,
|
||||
"confidence": 0.98,
|
||||
"reason": "Changes meaning too much.",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
runner = PipelineRunner()
|
||||
result = runner.run(
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
module_specs=[ModuleRunSpec(instance_name="ordered_real", module_key="glossary", module=module)],
|
||||
config=AuditaConfig.from_sources(env={"OPENROUTER_API_KEY": "test-key"}),
|
||||
run_dir=tmp_path / "run",
|
||||
llm_client=llm_client,
|
||||
)
|
||||
|
||||
assert result.skipped_corrections[0].source == "validator:spoken_form_plausibility_review"
|
||||
assert result.skipped_corrections[0].reason == "Not plausibly supported by spoken-form context."
|
||||
1441
python/tests/test_llm_validators.py
Normal file
1441
python/tests/test_llm_validators.py
Normal file
File diff suppressed because it is too large
Load Diff
1173
python/tests/test_module_proposals.py
Normal file
1173
python/tests/test_module_proposals.py
Normal file
File diff suppressed because it is too large
Load Diff
659
python/tests/test_new_cli.py
Normal file
659
python/tests/test_new_cli.py
Normal file
@@ -0,0 +1,659 @@
|
||||
import json
|
||||
import io
|
||||
import pytest
|
||||
|
||||
from audita.cli import main
|
||||
from audita.core.errors import AuditaConfigError
|
||||
from audita.core.reporting import ProcessResult, RunReport
|
||||
from audita.core.schemas import parse_transcript_json
|
||||
|
||||
|
||||
def test_cli_help_uses_audita_program_name(capsys):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["--help"])
|
||||
|
||||
assert exc.value.code == 0
|
||||
assert capsys.readouterr().out.startswith("usage: audita ")
|
||||
|
||||
|
||||
def test_process_help_exposes_framework_flags(capsys):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main(["process", "--help"])
|
||||
|
||||
assert exc.value.code == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "--report-json" in output
|
||||
assert "--llm-api-key" in output
|
||||
assert "--llm-concurrency" in output
|
||||
assert "--llm-timeout-seconds" in output
|
||||
assert "--validation-llm-api-key" in output
|
||||
assert "--validation-llm-concurrency" in output
|
||||
assert "--validation-llm-timeout-seconds" in output
|
||||
assert "--validation-model" in output
|
||||
assert "--validation-base-url" in output
|
||||
assert "--validation-max-retries" in output
|
||||
assert "--validation-max-prompt-tokens" in output
|
||||
assert "--target-sections" in output
|
||||
assert "--modules" in output
|
||||
assert "--model" in output
|
||||
assert "--base-url" in output
|
||||
assert "--max-retries" in output
|
||||
assert "--max-section-tokens" in output
|
||||
assert "--min-section-tokens" in output
|
||||
assert "--glossary-confidence-threshold" in output
|
||||
assert "--grammar-confidence-threshold" in output
|
||||
assert "--homophones-confidence-threshold" in output
|
||||
assert "--spoken-word-confidence-threshold" in output
|
||||
assert "--work-dir-retention" in output
|
||||
assert "--normalize-max-segment-gap" in output
|
||||
assert "--grammar-validation-enabled" not in output
|
||||
|
||||
|
||||
def test_cli_process_writes_report_json(monkeypatch, tmp_path):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["glossary_1", "homophones", "glossary_2", "spoken_word", "grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", lambda overrides=None: object())
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", lambda *args, **kwargs: result)
|
||||
|
||||
output_path = tmp_path / "out.json"
|
||||
report_path = tmp_path / "report.json"
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--output",
|
||||
str(output_path),
|
||||
"--report-json",
|
||||
str(report_path),
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert report_path.exists()
|
||||
|
||||
|
||||
def test_cli_process_passes_modules_override_to_config(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
def _fake_from_sources(*, overrides=None):
|
||||
captured["module_keys"] = overrides.module_keys
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", _fake_from_sources)
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", lambda *args, **kwargs: result)
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--modules",
|
||||
"grammar",
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert captured["module_keys"] == "grammar"
|
||||
|
||||
|
||||
def test_cli_process_passes_llm_api_key_override_to_config(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
def _fake_from_sources(*, overrides=None):
|
||||
captured["llm_api_key"] = overrides.llm_api_key
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", _fake_from_sources)
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", lambda *args, **kwargs: result)
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--llm-api-key",
|
||||
"cli-key",
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert captured["llm_api_key"] == "cli-key"
|
||||
|
||||
|
||||
def test_cli_process_passes_llm_concurrency_override_to_config(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
def _fake_from_sources(*, overrides=None):
|
||||
captured["llm_concurrency"] = overrides.llm_concurrency
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", _fake_from_sources)
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", lambda *args, **kwargs: result)
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--llm-concurrency",
|
||||
"3",
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert captured["llm_concurrency"] == 3
|
||||
|
||||
|
||||
def test_cli_process_passes_llm_timeout_seconds_override_to_config(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
def _fake_from_sources(*, overrides=None):
|
||||
captured["llm_timeout_seconds"] = overrides.llm_timeout_seconds
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", _fake_from_sources)
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", lambda *args, **kwargs: result)
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--llm-timeout-seconds",
|
||||
"900",
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert captured["llm_timeout_seconds"] == 900.0
|
||||
|
||||
|
||||
def test_cli_process_passes_validation_llm_overrides_to_config(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
def _fake_from_sources(*, overrides=None):
|
||||
captured["validation_llm_api_key"] = overrides.validation_llm_api_key
|
||||
captured["validation_llm_concurrency"] = overrides.validation_llm_concurrency
|
||||
captured["validation_llm_timeout_seconds"] = overrides.validation_llm_timeout_seconds
|
||||
captured["validation_model"] = overrides.validation_model
|
||||
captured["validation_base_url"] = overrides.validation_base_url
|
||||
captured["validation_max_retries"] = overrides.validation_max_retries
|
||||
captured["validation_max_prompt_tokens"] = overrides.validation_max_prompt_tokens
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", _fake_from_sources)
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", lambda *args, **kwargs: result)
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--validation-llm-api-key",
|
||||
"validator-key",
|
||||
"--validation-llm-concurrency",
|
||||
"4",
|
||||
"--validation-llm-timeout-seconds",
|
||||
"180",
|
||||
"--validation-model",
|
||||
"validator-model",
|
||||
"--validation-base-url",
|
||||
"http://localhost:9000/v1",
|
||||
"--validation-max-retries",
|
||||
"2",
|
||||
"--validation-max-prompt-tokens",
|
||||
"1024",
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert captured == {
|
||||
"validation_llm_api_key": "validator-key",
|
||||
"validation_llm_concurrency": 4,
|
||||
"validation_llm_timeout_seconds": 180.0,
|
||||
"validation_model": "validator-model",
|
||||
"validation_base_url": "http://localhost:9000/v1",
|
||||
"validation_max_retries": 2,
|
||||
"validation_max_prompt_tokens": 1024,
|
||||
}
|
||||
|
||||
|
||||
def test_cli_process_passes_target_sections_override_to_config(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
def _fake_from_sources(*, overrides=None):
|
||||
captured["target_sections"] = overrides.target_sections
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", _fake_from_sources)
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", lambda *args, **kwargs: result)
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--target-sections",
|
||||
"4",
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert captured["target_sections"] == 4
|
||||
|
||||
|
||||
def test_cli_process_passes_min_section_tokens_override_to_config(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
def _fake_from_sources(*, overrides=None):
|
||||
captured["min_section_tokens"] = overrides.min_section_tokens
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", _fake_from_sources)
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", lambda *args, **kwargs: result)
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--min-section-tokens",
|
||||
"5000",
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert captured["min_section_tokens"] == 5000
|
||||
|
||||
|
||||
def test_cli_process_writes_failure_diagnostics_and_keeps_stdout_empty(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setattr(
|
||||
"audita.cli.AuditaConfig.from_sources",
|
||||
lambda overrides=None: (_ for _ in ()).throw(AuditaConfigError("bad config")),
|
||||
)
|
||||
|
||||
report_path = tmp_path / "external-report.json"
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--work-dir",
|
||||
str(tmp_path / "work"),
|
||||
"--report-json",
|
||||
str(report_path),
|
||||
]
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 1
|
||||
assert captured.out == ""
|
||||
assert "audita: error: bad config" in captured.err
|
||||
assert "audita: exit code: 1" in captured.err
|
||||
run_dir = next((tmp_path / "work").iterdir())
|
||||
assert f"audita: run directory: {run_dir}" in captured.err
|
||||
assert f"audita: error log: {run_dir / 'error.log'}" in captured.err
|
||||
assert f"audita: report: {run_dir / 'report.json'}" in captured.err
|
||||
assert (run_dir / "error.log").exists()
|
||||
report = json.loads((run_dir / "report.json").read_text(encoding="utf-8"))
|
||||
assert report["status"] == "failed"
|
||||
assert report["error"] == "bad config"
|
||||
assert report["error_details"]["phase"] == "config"
|
||||
assert report["error_details"]["type"] == "AuditaConfigError"
|
||||
external = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
assert external["error"] == "bad config"
|
||||
|
||||
|
||||
def test_cli_process_writes_failure_diagnostics_for_unexpected_exceptions(monkeypatch, tmp_path, capsys):
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", lambda overrides=None: object())
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--work-dir",
|
||||
str(tmp_path / "work"),
|
||||
]
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 1
|
||||
assert captured.out == ""
|
||||
assert "audita: error: boom" in captured.err
|
||||
run_dir = next((tmp_path / "work").iterdir())
|
||||
report = json.loads((run_dir / "report.json").read_text(encoding="utf-8"))
|
||||
assert report["status"] == "failed"
|
||||
assert report["error_details"]["phase"] == "transcript_load"
|
||||
assert report["error_details"]["type"] == "RuntimeError"
|
||||
assert "RuntimeError: boom" in (run_dir / "error.log").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class _BrokenWriter:
|
||||
def write(self, _message):
|
||||
raise OSError(9, "Bad file descriptor")
|
||||
|
||||
def flush(self):
|
||||
raise OSError(9, "Bad file descriptor")
|
||||
|
||||
|
||||
def test_cli_process_progress_falls_back_to_stdout_when_stderr_is_invalid(monkeypatch, tmp_path):
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Fixed."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
report = RunReport(
|
||||
status="success",
|
||||
config={"model": "m", "base_url": "b"},
|
||||
normalization={"source_segment_count": 1, "normalized_segment_count": 1, "merge_count": 0},
|
||||
pipeline=["grammar"],
|
||||
modules=[],
|
||||
applied_changes=[],
|
||||
skipped_corrections=[],
|
||||
totals={"output_segment_count": 1, "applied_change_count": 0, "skipped_correction_count": 0},
|
||||
work_dir_retention="auto",
|
||||
work_dir_retained=False,
|
||||
work_dir=None,
|
||||
error=None,
|
||||
)
|
||||
result = ProcessResult(
|
||||
transcript=transcript,
|
||||
report=report,
|
||||
run_dir=tmp_path / "run",
|
||||
work_dir_retained=False,
|
||||
)
|
||||
|
||||
def _fake_pipeline(*args, **kwargs):
|
||||
kwargs["progress"]("progress-line")
|
||||
return result
|
||||
|
||||
stdout_capture = io.StringIO()
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", lambda overrides=None: object())
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: [])
|
||||
monkeypatch.setattr("audita.cli.load_glossary", lambda path: object())
|
||||
monkeypatch.setattr("audita.cli.process_transcript_result", _fake_pipeline)
|
||||
monkeypatch.setattr("audita.cli.sys.stderr", _BrokenWriter())
|
||||
monkeypatch.setattr("audita.cli.sys.stdout", stdout_capture)
|
||||
|
||||
output_path = tmp_path / "out.json"
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--output",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
assert "progress-line" in stdout_capture.getvalue()
|
||||
|
||||
|
||||
def test_cli_process_failure_summary_falls_back_to_stdout_when_stderr_is_invalid(monkeypatch, tmp_path):
|
||||
stdout_capture = io.StringIO()
|
||||
monkeypatch.setattr("audita.cli.AuditaConfig.from_sources", lambda overrides=None: object())
|
||||
monkeypatch.setattr("audita.cli.load_transcript", lambda path: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
monkeypatch.setattr("audita.cli.sys.stderr", _BrokenWriter())
|
||||
monkeypatch.setattr("audita.cli.sys.stdout", stdout_capture)
|
||||
|
||||
exit_code = main(
|
||||
[
|
||||
"process",
|
||||
"transcript.json",
|
||||
"--glossary",
|
||||
"glossary.yaml",
|
||||
"--work-dir",
|
||||
str(tmp_path / "work"),
|
||||
]
|
||||
)
|
||||
|
||||
assert exit_code == 1
|
||||
assert "audita: error: boom" in stdout_capture.getvalue()
|
||||
435
python/tests/test_new_config.py
Normal file
435
python/tests/test_new_config.py
Normal file
@@ -0,0 +1,435 @@
|
||||
import pytest
|
||||
|
||||
from audita.core.config import (
|
||||
AuditaConfig,
|
||||
ConfigOverrides,
|
||||
DEFAULT_GLOSSARY_CONFIDENCE_THRESHOLD,
|
||||
DEFAULT_GRAMMAR_CONFIDENCE_THRESHOLD,
|
||||
DEFAULT_HOMOPHONES_CONFIDENCE_THRESHOLD,
|
||||
DEFAULT_LLM_CONCURRENCY,
|
||||
DEFAULT_LLM_TIMEOUT_SECONDS,
|
||||
DEFAULT_MAX_SECTION_TOKENS,
|
||||
DEFAULT_MIN_SECTION_TOKENS,
|
||||
DEFAULT_NORMALIZE_MAX_SEGMENT_GAP,
|
||||
DEFAULT_SPOKEN_WORD_CONFIDENCE_THRESHOLD,
|
||||
DEFAULT_VALIDATION_MAX_PROMPT_TOKENS,
|
||||
DEFAULT_WORK_DIR_RETENTION,
|
||||
)
|
||||
from audita.core.errors import AuditaConfigError
|
||||
from audita.modules import DEFAULT_MODULE_KEYS
|
||||
|
||||
|
||||
def test_default_config_allows_missing_api_key():
|
||||
config = AuditaConfig.from_sources(env={})
|
||||
|
||||
assert config.api_key is None
|
||||
assert config.llm_concurrency == DEFAULT_LLM_CONCURRENCY
|
||||
assert config.llm_timeout_seconds == DEFAULT_LLM_TIMEOUT_SECONDS
|
||||
assert config.validation_llm_api_key is None
|
||||
assert config.validation_llm_concurrency is None
|
||||
assert config.validation_llm_timeout_seconds is None
|
||||
assert config.validation_model is None
|
||||
assert config.validation_base_url is None
|
||||
assert config.validation_max_retries is None
|
||||
assert config.validation_max_prompt_tokens == DEFAULT_VALIDATION_MAX_PROMPT_TOKENS
|
||||
assert config.target_sections is None
|
||||
assert config.max_section_tokens == DEFAULT_MAX_SECTION_TOKENS
|
||||
assert config.min_section_tokens == DEFAULT_MIN_SECTION_TOKENS
|
||||
assert config.module_keys == DEFAULT_MODULE_KEYS
|
||||
assert config.glossary_confidence_threshold == DEFAULT_GLOSSARY_CONFIDENCE_THRESHOLD
|
||||
assert config.grammar_confidence_threshold == DEFAULT_GRAMMAR_CONFIDENCE_THRESHOLD
|
||||
assert config.homophones_confidence_threshold == DEFAULT_HOMOPHONES_CONFIDENCE_THRESHOLD
|
||||
assert config.spoken_word_confidence_threshold == DEFAULT_SPOKEN_WORD_CONFIDENCE_THRESHOLD
|
||||
assert config.normalize_max_segment_gap == DEFAULT_NORMALIZE_MAX_SEGMENT_GAP
|
||||
assert config.work_dir_retention == DEFAULT_WORK_DIR_RETENTION
|
||||
|
||||
|
||||
def test_cli_overrides_take_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_MAX_SECTION_TOKENS": "1000"},
|
||||
overrides=ConfigOverrides(max_section_tokens=2000, min_section_tokens=1000),
|
||||
)
|
||||
|
||||
assert config.max_section_tokens == 2000
|
||||
|
||||
|
||||
def test_min_section_tokens_cli_override_takes_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_MIN_SECTION_TOKENS": "2000"},
|
||||
overrides=ConfigOverrides(min_section_tokens=6000),
|
||||
)
|
||||
|
||||
assert config.min_section_tokens == 6000
|
||||
|
||||
|
||||
def test_llm_concurrency_cli_override_takes_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_LLM_CONCURRENCY": "2"},
|
||||
overrides=ConfigOverrides(llm_concurrency=4),
|
||||
)
|
||||
|
||||
assert config.llm_concurrency == 4
|
||||
|
||||
|
||||
def test_llm_concurrency_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_LLM_CONCURRENCY": "3"})
|
||||
|
||||
assert config.llm_concurrency == 3
|
||||
|
||||
|
||||
def test_llm_timeout_seconds_cli_override_takes_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_LLM_TIMEOUT_SECONDS": "120"},
|
||||
overrides=ConfigOverrides(llm_timeout_seconds=900.0),
|
||||
)
|
||||
|
||||
assert config.llm_timeout_seconds == 900.0
|
||||
|
||||
|
||||
def test_llm_timeout_seconds_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_LLM_TIMEOUT_SECONDS": "120.5"})
|
||||
|
||||
assert config.llm_timeout_seconds == 120.5
|
||||
|
||||
|
||||
def test_validation_llm_concurrency_cli_override_takes_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_VALIDATION_LLM_CONCURRENCY": "2"},
|
||||
overrides=ConfigOverrides(validation_llm_concurrency=4),
|
||||
)
|
||||
|
||||
assert config.validation_llm_concurrency == 4
|
||||
|
||||
|
||||
def test_validation_llm_concurrency_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_VALIDATION_LLM_CONCURRENCY": "3"})
|
||||
|
||||
assert config.validation_llm_concurrency == 3
|
||||
|
||||
|
||||
def test_validation_llm_timeout_seconds_cli_override_takes_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS": "120"},
|
||||
overrides=ConfigOverrides(validation_llm_timeout_seconds=900.0),
|
||||
)
|
||||
|
||||
assert config.validation_llm_timeout_seconds == 900.0
|
||||
|
||||
|
||||
def test_validation_llm_timeout_seconds_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS": "120.5"})
|
||||
|
||||
assert config.validation_llm_timeout_seconds == 120.5
|
||||
|
||||
|
||||
def test_validation_model_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_VALIDATION_MODEL": "validator-model"})
|
||||
|
||||
assert config.validation_model == "validator-model"
|
||||
|
||||
|
||||
def test_validation_base_url_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_VALIDATION_BASE_URL": "http://localhost:9000/v1"})
|
||||
|
||||
assert config.validation_base_url == "http://localhost:9000/v1"
|
||||
|
||||
|
||||
def test_validation_max_retries_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_VALIDATION_MAX_RETRIES": "7"})
|
||||
|
||||
assert config.validation_max_retries == 7
|
||||
|
||||
|
||||
def test_validation_max_prompt_tokens_cli_override_takes_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_VALIDATION_MAX_PROMPT_TOKENS": "1024"},
|
||||
overrides=ConfigOverrides(validation_max_prompt_tokens=4096),
|
||||
)
|
||||
|
||||
assert config.validation_max_prompt_tokens == 4096
|
||||
|
||||
|
||||
def test_validation_max_prompt_tokens_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_VALIDATION_MAX_PROMPT_TOKENS": "1024"})
|
||||
|
||||
assert config.validation_max_prompt_tokens == 1024
|
||||
|
||||
|
||||
def test_target_sections_cli_override_takes_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_TARGET_SECTIONS": "2"},
|
||||
overrides=ConfigOverrides(target_sections=5),
|
||||
)
|
||||
|
||||
assert config.target_sections == 5
|
||||
|
||||
|
||||
def test_target_sections_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_TARGET_SECTIONS": "3"})
|
||||
|
||||
assert config.target_sections == 3
|
||||
|
||||
|
||||
def test_min_section_tokens_env_is_parsed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_MIN_SECTION_TOKENS": "3000"})
|
||||
|
||||
assert config.min_section_tokens == 3000
|
||||
|
||||
|
||||
def test_generic_llm_api_key_env_is_read():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_LLM_API_KEY": "generic-key"})
|
||||
|
||||
assert config.api_key == "generic-key"
|
||||
|
||||
|
||||
def test_generic_llm_api_key_takes_precedence_over_openrouter_env():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={
|
||||
"AUDITA_LLM_API_KEY": "generic-key",
|
||||
"OPENROUTER_API_KEY": "legacy-key",
|
||||
}
|
||||
)
|
||||
|
||||
assert config.api_key == "generic-key"
|
||||
|
||||
|
||||
def test_llm_api_key_cli_override_takes_precedence_over_env():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={
|
||||
"AUDITA_LLM_API_KEY": "generic-key",
|
||||
"OPENROUTER_API_KEY": "legacy-key",
|
||||
},
|
||||
overrides=ConfigOverrides(llm_api_key="cli-key"),
|
||||
)
|
||||
|
||||
assert config.api_key == "cli-key"
|
||||
|
||||
|
||||
def test_blank_llm_api_key_override_resolves_to_none():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"OPENROUTER_API_KEY": "legacy-key"},
|
||||
overrides=ConfigOverrides(llm_api_key=" "),
|
||||
)
|
||||
|
||||
assert config.api_key is None
|
||||
|
||||
|
||||
def test_validation_llm_api_key_env_is_read():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_VALIDATION_LLM_API_KEY": "validation-key"})
|
||||
|
||||
assert config.validation_llm_api_key == "validation-key"
|
||||
|
||||
|
||||
def test_blank_validation_llm_api_key_override_disables_primary_fallback():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_LLM_API_KEY": "primary-key"},
|
||||
overrides=ConfigOverrides(validation_llm_api_key=" "),
|
||||
)
|
||||
|
||||
assert config.validation_llm_api_key == ""
|
||||
assert config.validation_llm_config().api_key == ""
|
||||
|
||||
|
||||
def test_effective_validation_fields_fall_back_to_primary_settings():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={
|
||||
"AUDITA_LLM_API_KEY": "primary-key",
|
||||
"AUDITA_MODEL": "primary-model",
|
||||
"AUDITA_BASE_URL": "http://localhost:8000/v1",
|
||||
"AUDITA_LLM_TIMEOUT_SECONDS": "120",
|
||||
"AUDITA_LLM_CONCURRENCY": "5",
|
||||
"AUDITA_MAX_RETRIES": "9",
|
||||
}
|
||||
)
|
||||
|
||||
validation = config.validation_llm_config()
|
||||
|
||||
assert validation.api_key == "primary-key"
|
||||
assert validation.model == "primary-model"
|
||||
assert validation.base_url == "http://localhost:8000/v1"
|
||||
assert validation.llm_timeout_seconds == 120.0
|
||||
assert validation.llm_concurrency == 5
|
||||
assert validation.max_retries == 9
|
||||
|
||||
|
||||
def test_effective_validation_fields_use_overrides_when_set():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={
|
||||
"AUDITA_LLM_API_KEY": "primary-key",
|
||||
"AUDITA_MODEL": "primary-model",
|
||||
"AUDITA_BASE_URL": "http://localhost:8000/v1",
|
||||
"AUDITA_LLM_TIMEOUT_SECONDS": "120",
|
||||
"AUDITA_LLM_CONCURRENCY": "5",
|
||||
"AUDITA_MAX_RETRIES": "9",
|
||||
"AUDITA_VALIDATION_LLM_API_KEY": "validation-key",
|
||||
"AUDITA_VALIDATION_MODEL": "validation-model",
|
||||
"AUDITA_VALIDATION_BASE_URL": "http://localhost:9000/v1",
|
||||
"AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS": "240",
|
||||
"AUDITA_VALIDATION_LLM_CONCURRENCY": "3",
|
||||
"AUDITA_VALIDATION_MAX_RETRIES": "2",
|
||||
}
|
||||
)
|
||||
|
||||
validation = config.validation_llm_config()
|
||||
|
||||
assert validation.api_key == "validation-key"
|
||||
assert validation.model == "validation-model"
|
||||
assert validation.base_url == "http://localhost:9000/v1"
|
||||
assert validation.llm_timeout_seconds == 240.0
|
||||
assert validation.llm_concurrency == 3
|
||||
assert validation.max_retries == 2
|
||||
|
||||
|
||||
def test_module_key_overrides_take_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"AUDITA_MODULES": "grammar"},
|
||||
overrides=ConfigOverrides(module_keys="homophones,grammar"),
|
||||
)
|
||||
|
||||
assert config.module_keys == ("homophones", "grammar")
|
||||
|
||||
|
||||
def test_module_key_env_is_parsed_and_trimmed():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_MODULES": " glossary , grammar "})
|
||||
|
||||
assert config.module_keys == ("glossary", "grammar")
|
||||
|
||||
|
||||
def test_invalid_work_dir_retention_is_rejected():
|
||||
with pytest.raises(AuditaConfigError):
|
||||
AuditaConfig.from_sources(env={"AUDITA_WORK_DIR_RETENTION": "sometimes"})
|
||||
|
||||
|
||||
def test_report_dict_includes_llm_timeout_seconds():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_LLM_TIMEOUT_SECONDS": "321"})
|
||||
|
||||
assert config.to_report_dict()["llm_timeout_seconds"] == 321.0
|
||||
|
||||
|
||||
def test_report_dict_includes_target_sections():
|
||||
config = AuditaConfig.from_sources(env={"AUDITA_TARGET_SECTIONS": "7"})
|
||||
|
||||
assert config.to_report_dict()["target_sections"] == 7
|
||||
|
||||
|
||||
def test_report_dict_includes_effective_validation_llm_config():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={
|
||||
"AUDITA_LLM_API_KEY": "primary-key",
|
||||
"AUDITA_VALIDATION_MODEL": "validation-model",
|
||||
}
|
||||
)
|
||||
|
||||
report = config.to_report_dict()
|
||||
|
||||
assert report["validation_model"] == "validation-model"
|
||||
assert report["validation_max_prompt_tokens"] == DEFAULT_VALIDATION_MAX_PROMPT_TOKENS
|
||||
assert report["effective_validation_llm"]["api_key_configured"] is True
|
||||
assert report["effective_validation_llm"]["model"] == "validation-model"
|
||||
|
||||
|
||||
def test_threshold_overrides_take_precedence():
|
||||
config = AuditaConfig.from_sources(
|
||||
env={
|
||||
"AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD": "0.6",
|
||||
"AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD": "0.65",
|
||||
"AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD": "0.7",
|
||||
"AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD": "0.75",
|
||||
},
|
||||
overrides=ConfigOverrides(
|
||||
glossary_confidence_threshold=0.85,
|
||||
grammar_confidence_threshold=0.88,
|
||||
homophones_confidence_threshold=0.9,
|
||||
spoken_word_confidence_threshold=0.95,
|
||||
),
|
||||
)
|
||||
|
||||
assert config.glossary_confidence_threshold == 0.85
|
||||
assert config.grammar_confidence_threshold == 0.88
|
||||
assert config.homophones_confidence_threshold == 0.9
|
||||
assert config.spoken_word_confidence_threshold == 0.95
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"",
|
||||
"grammar,,homophones",
|
||||
"bogus",
|
||||
],
|
||||
)
|
||||
def test_invalid_module_sequences_are_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_MODULES"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_MODULES": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_name",
|
||||
[
|
||||
"AUDITA_GLOSSARY_CONFIDENCE_THRESHOLD",
|
||||
"AUDITA_GRAMMAR_CONFIDENCE_THRESHOLD",
|
||||
"AUDITA_HOMOPHONES_CONFIDENCE_THRESHOLD",
|
||||
"AUDITA_SPOKEN_WORD_CONFIDENCE_THRESHOLD",
|
||||
],
|
||||
)
|
||||
def test_invalid_thresholds_are_rejected(env_name):
|
||||
with pytest.raises(AuditaConfigError):
|
||||
AuditaConfig.from_sources(env={env_name: "1.5"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "many"])
|
||||
def test_invalid_llm_concurrency_is_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_LLM_CONCURRENCY"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_LLM_CONCURRENCY": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "many"])
|
||||
def test_invalid_llm_timeout_seconds_is_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_LLM_TIMEOUT_SECONDS"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_LLM_TIMEOUT_SECONDS": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "many"])
|
||||
def test_invalid_validation_llm_concurrency_is_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_VALIDATION_LLM_CONCURRENCY"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_VALIDATION_LLM_CONCURRENCY": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "many"])
|
||||
def test_invalid_validation_llm_timeout_seconds_is_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_VALIDATION_LLM_TIMEOUT_SECONDS": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["-1", "many"])
|
||||
def test_invalid_validation_max_retries_is_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_VALIDATION_MAX_RETRIES"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_VALIDATION_MAX_RETRIES": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "many"])
|
||||
def test_invalid_validation_max_prompt_tokens_is_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_VALIDATION_MAX_PROMPT_TOKENS"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_VALIDATION_MAX_PROMPT_TOKENS": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "many"])
|
||||
def test_invalid_target_sections_is_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_TARGET_SECTIONS"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_TARGET_SECTIONS": value})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "many"])
|
||||
def test_invalid_min_section_tokens_is_rejected(value):
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_MIN_SECTION_TOKENS"):
|
||||
AuditaConfig.from_sources(env={"AUDITA_MIN_SECTION_TOKENS": value})
|
||||
|
||||
|
||||
def test_min_section_tokens_must_not_exceed_max_section_tokens():
|
||||
with pytest.raises(AuditaConfigError, match="AUDITA_MIN_SECTION_TOKENS"):
|
||||
AuditaConfig.from_sources(
|
||||
env={
|
||||
"AUDITA_MIN_SECTION_TOKENS": "9000",
|
||||
"AUDITA_MAX_SECTION_TOKENS": "8000",
|
||||
}
|
||||
)
|
||||
117
python/tests/test_new_launcher.py
Normal file
117
python/tests/test_new_launcher.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LAUNCHER = ROOT / "audita"
|
||||
|
||||
|
||||
def _load_launcher_module():
|
||||
loader = importlib.machinery.SourceFileLoader("audita_launcher", str(LAUNCHER))
|
||||
spec = importlib.util.spec_from_file_location("audita_launcher", LAUNCHER, loader=loader)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_root_launcher_exists_and_is_executable():
|
||||
assert LAUNCHER.is_file()
|
||||
assert os.access(LAUNCHER, os.X_OK)
|
||||
|
||||
|
||||
def test_root_launcher_help_smoke():
|
||||
if shutil.which("uv") is None:
|
||||
pytest.skip("uv is not installed")
|
||||
|
||||
result = subprocess.run(
|
||||
[str(LAUNCHER), "--help"],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.startswith("usage: audita ")
|
||||
|
||||
|
||||
def test_root_launcher_writes_error_log_when_uv_is_missing(tmp_path):
|
||||
work_dir = tmp_path / "work"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(LAUNCHER), "process", "transcript.json", "--glossary", "glossary.yaml", "--work-dir", str(work_dir)],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env={**os.environ, "PATH": ""},
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert result.stdout == ""
|
||||
assert "audita: error: uv is required to run this launcher" in result.stderr
|
||||
run_dir = next(work_dir.iterdir())
|
||||
assert f"audita: run directory: {run_dir}" in result.stderr
|
||||
assert (run_dir / "error.log").exists()
|
||||
|
||||
|
||||
def test_root_launcher_preserves_child_exit_code_and_writes_fallback_error_log(tmp_path):
|
||||
work_dir = tmp_path / "work"
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
fake_uv = fake_bin / "uv"
|
||||
fake_uv.write_text("#!/bin/sh\nexit 120\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(LAUNCHER), "process", "transcript.json", "--glossary", "glossary.yaml", "--work-dir", str(work_dir)],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env={**os.environ, "PATH": str(fake_bin)},
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 120
|
||||
assert result.stdout == ""
|
||||
assert "audita: subprocess exited with status 120" in result.stderr
|
||||
run_dir = next(work_dir.iterdir())
|
||||
assert (run_dir / "error.log").exists()
|
||||
|
||||
|
||||
class _BrokenWriter:
|
||||
def write(self, _message):
|
||||
raise OSError(9, "Bad file descriptor")
|
||||
|
||||
def flush(self):
|
||||
raise OSError(9, "Bad file descriptor")
|
||||
|
||||
|
||||
def test_root_launcher_falls_back_to_stdout_when_stderr_is_invalid(monkeypatch, tmp_path):
|
||||
launcher = _load_launcher_module()
|
||||
work_dir = tmp_path / "work"
|
||||
stdout_capture = io.StringIO()
|
||||
|
||||
monkeypatch.setattr(launcher.shutil, "which", lambda _name: None)
|
||||
monkeypatch.setattr(
|
||||
launcher.sys,
|
||||
"argv",
|
||||
["audita", "process", "transcript.json", "--glossary", "glossary.yaml", "--work-dir", str(work_dir)],
|
||||
)
|
||||
monkeypatch.setattr(launcher.sys, "stderr", _BrokenWriter())
|
||||
monkeypatch.setattr(launcher.sys, "stdout", stdout_capture)
|
||||
|
||||
exit_code = launcher.main()
|
||||
|
||||
assert exit_code == 1
|
||||
assert "audita: error: uv is required to run this launcher" in stdout_capture.getvalue()
|
||||
run_dir = next(work_dir.iterdir())
|
||||
assert (run_dir / "error.log").exists()
|
||||
698
python/tests/test_new_pipeline.py
Normal file
698
python/tests/test_new_pipeline.py
Normal file
@@ -0,0 +1,698 @@
|
||||
import json
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from audita.core.config import AuditaConfig, ConfigOverrides
|
||||
from audita.core.errors import AuditaLLMError
|
||||
from audita.core.io import write_report
|
||||
from audita.core.schemas import parse_glossary_yaml, parse_source_transcript_json
|
||||
from audita.modules import DEFAULT_MODULE_KEYS, default_module_specs, resolve_module_specs
|
||||
from audita.pipeline import process_transcript, process_transcript_result
|
||||
|
||||
|
||||
class FakeStructuredLLMClient:
|
||||
def __init__(self, responses):
|
||||
self._responses = responses
|
||||
self._lock = threading.Lock()
|
||||
self.calls = []
|
||||
|
||||
def run_structured(self, *, stage_name, messages, response_model, config):
|
||||
with self._lock:
|
||||
self.calls.append(
|
||||
{
|
||||
"stage_name": stage_name,
|
||||
"messages": list(messages),
|
||||
"response_model": response_model,
|
||||
"config": config,
|
||||
}
|
||||
)
|
||||
response = _pop_llm_response(self._responses, stage_name)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response_model.model_validate(response)
|
||||
|
||||
|
||||
def _pop_llm_response(responses, stage_name):
|
||||
if isinstance(responses, dict):
|
||||
if stage_name not in responses:
|
||||
raise AuditaLLMError(f"FakeStructuredLLMClient received unexpected stage_name: {stage_name}")
|
||||
payloads = responses[stage_name]
|
||||
if isinstance(payloads, list):
|
||||
if not payloads:
|
||||
raise AuditaLLMError(f"FakeStructuredLLMClient received too many calls for stage_name: {stage_name}")
|
||||
return payloads.pop(0)
|
||||
payload = payloads
|
||||
del responses[stage_name]
|
||||
return payload
|
||||
if not responses:
|
||||
raise AuditaLLMError("FakeStructuredLLMClient received more calls than expected.")
|
||||
return responses.pop(0)
|
||||
|
||||
|
||||
def _glossary():
|
||||
return parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Jesters"
|
||||
category: faction
|
||||
summary: "A faction."
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _transcript():
|
||||
return parse_source_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"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"]}
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_process_transcript_runs_noop_framework(tmp_path):
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
[
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
]
|
||||
)
|
||||
revised = process_transcript(
|
||||
_transcript(),
|
||||
_glossary(),
|
||||
AuditaConfig.from_sources(env={}, overrides=None),
|
||||
llm_client=llm_client,
|
||||
)
|
||||
|
||||
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",
|
||||
"glossary_2:proposal",
|
||||
"spoken_word:proposal",
|
||||
"grammar:proposal",
|
||||
]
|
||||
|
||||
|
||||
def test_process_transcript_result_can_use_different_validation_llm_settings(tmp_path):
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
{
|
||||
"grammar:proposal": {
|
||||
"corrections": [
|
||||
{
|
||||
"id": 1,
|
||||
"original_text": "hello world",
|
||||
"corrected_text": "Hello world.",
|
||||
"confidence": 0.95,
|
||||
}
|
||||
]
|
||||
},
|
||||
"grammar:grammar_only_guard": {
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": True,
|
||||
"confidence": 0.98,
|
||||
"reason": "ok",
|
||||
}
|
||||
]
|
||||
},
|
||||
"grammar:meaning_reversal_review": {
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": True,
|
||||
"confidence": 0.99,
|
||||
"reason": "ok",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"OPENROUTER_API_KEY": "primary-key"},
|
||||
overrides=ConfigOverrides(
|
||||
model="primary-model",
|
||||
base_url="http://localhost:8000/v1",
|
||||
max_retries=7,
|
||||
llm_timeout_seconds=120,
|
||||
validation_llm_api_key="validation-key",
|
||||
validation_model="validation-model",
|
||||
validation_base_url="http://localhost:9000/v1",
|
||||
validation_max_retries=2,
|
||||
validation_llm_timeout_seconds=240,
|
||||
validation_llm_concurrency=3,
|
||||
work_dir=tmp_path / "work",
|
||||
work_dir_retention="always",
|
||||
),
|
||||
)
|
||||
|
||||
result = process_transcript_result(
|
||||
parse_source_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"speaker": "Eric", "start": 0.0, "end": 1.0, "text": "hello world"}
|
||||
]
|
||||
"""
|
||||
),
|
||||
_glossary(),
|
||||
config,
|
||||
module_keys=["grammar"],
|
||||
llm_client=llm_client,
|
||||
)
|
||||
|
||||
assert result.transcript[0].text == "Hello world."
|
||||
calls_by_stage = {call["stage_name"]: call["config"] for call in llm_client.calls}
|
||||
assert calls_by_stage["grammar:proposal"].model == "primary-model"
|
||||
assert calls_by_stage["grammar:proposal"].base_url == "http://localhost:8000/v1"
|
||||
assert calls_by_stage["grammar:proposal"].api_key == "primary-key"
|
||||
assert calls_by_stage["grammar:grammar_only_guard"].model == "validation-model"
|
||||
assert calls_by_stage["grammar:grammar_only_guard"].base_url == "http://localhost:9000/v1"
|
||||
assert calls_by_stage["grammar:grammar_only_guard"].api_key == "validation-key"
|
||||
assert calls_by_stage["grammar:grammar_only_guard"].max_retries == 2
|
||||
assert calls_by_stage["grammar:grammar_only_guard"].llm_timeout_seconds == 240
|
||||
|
||||
|
||||
def test_process_transcript_result_writes_report_and_preserves_skips_per_policy(tmp_path):
|
||||
config = AuditaConfig.from_sources(
|
||||
env={},
|
||||
overrides=None,
|
||||
)
|
||||
config = AuditaConfig(
|
||||
api_key=config.api_key,
|
||||
model=config.model,
|
||||
base_url=config.base_url,
|
||||
max_retries=config.max_retries,
|
||||
max_section_tokens=config.max_section_tokens,
|
||||
glossary_confidence_threshold=config.glossary_confidence_threshold,
|
||||
grammar_confidence_threshold=config.grammar_confidence_threshold,
|
||||
homophones_confidence_threshold=config.homophones_confidence_threshold,
|
||||
spoken_word_confidence_threshold=config.spoken_word_confidence_threshold,
|
||||
normalize_max_segment_gap=config.normalize_max_segment_gap,
|
||||
normalize_ellipsis_gap=config.normalize_ellipsis_gap,
|
||||
normalize_max_segment_duration=config.normalize_max_segment_duration,
|
||||
normalize_max_segment_tokens=config.normalize_max_segment_tokens,
|
||||
work_dir=tmp_path / "work",
|
||||
work_dir_retention="always",
|
||||
)
|
||||
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
[
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
]
|
||||
)
|
||||
result = process_transcript_result(_transcript(), _glossary(), config, llm_client=llm_client)
|
||||
|
||||
assert result.work_dir_retained is True
|
||||
assert result.report.pipeline == [
|
||||
"glossary_1",
|
||||
"homophones",
|
||||
"glossary_2",
|
||||
"spoken_word",
|
||||
"grammar",
|
||||
]
|
||||
assert result.report.totals["applied_change_count"] == 0
|
||||
assert (result.run_dir / "report.json").exists()
|
||||
assert (result.run_dir / "normalization" / "summary.json").exists()
|
||||
assert [validator["name"] for validator in result.report.modules[0].to_dict()["validators"]] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"glossary_stage_protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"spoken_form_plausibility_review",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
assert [validator["name"] for validator in result.report.modules[2].to_dict()["validators"]] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"glossary_stage_protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"spoken_form_plausibility_review",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
assert [validator["name"] for validator in result.report.modules[3].to_dict()["validators"]] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"spoken_word_review",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
assert [validator["name"] for validator in result.report.modules[4].to_dict()["validators"]] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"grammar_only_guard",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
|
||||
|
||||
def test_external_report_can_be_written(tmp_path):
|
||||
config = AuditaConfig.from_sources(env={})
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
[
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
]
|
||||
)
|
||||
result = process_transcript_result(_transcript(), _glossary(), config, llm_client=llm_client)
|
||||
report_path = tmp_path / "report.json"
|
||||
write_report(report_path, result.report)
|
||||
|
||||
payload = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
assert payload["pipeline"][0] == "glossary_1"
|
||||
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()
|
||||
|
||||
assert DEFAULT_MODULE_KEYS == ("glossary", "homophones", "glossary", "spoken_word", "grammar")
|
||||
assert [spec.instance_name for spec in specs] == [
|
||||
"glossary_1",
|
||||
"homophones",
|
||||
"glossary_2",
|
||||
"spoken_word",
|
||||
"grammar",
|
||||
]
|
||||
assert [validator.name for validator in specs[0].module.validators()] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"glossary_stage_protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"spoken_form_plausibility_review",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
assert [validator.name for validator in specs[1].module.validators()] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"spoken_form_plausibility_review",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
assert [validator.name for validator in specs[2].module.validators()] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"glossary_stage_protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"spoken_form_plausibility_review",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
assert [validator.name for validator in specs[3].module.validators()] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"spoken_word_review",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
assert [validator.name for validator in specs[4].module.validators()] == [
|
||||
"identical_text_guard",
|
||||
"original_text_present_guard",
|
||||
"proposal_confidence_guard",
|
||||
"protected_glossary_guard",
|
||||
"non_empty_segment_guard",
|
||||
"grammar_only_guard",
|
||||
"meaning_reversal_review",
|
||||
]
|
||||
|
||||
|
||||
def test_process_transcript_result_missing_api_key_writes_failed_report(tmp_path):
|
||||
config = AuditaConfig.from_sources(
|
||||
env={},
|
||||
overrides=None,
|
||||
)
|
||||
config = AuditaConfig(
|
||||
api_key=None,
|
||||
model=config.model,
|
||||
base_url=config.base_url,
|
||||
max_retries=config.max_retries,
|
||||
max_section_tokens=config.max_section_tokens,
|
||||
glossary_confidence_threshold=config.glossary_confidence_threshold,
|
||||
grammar_confidence_threshold=config.grammar_confidence_threshold,
|
||||
homophones_confidence_threshold=config.homophones_confidence_threshold,
|
||||
spoken_word_confidence_threshold=config.spoken_word_confidence_threshold,
|
||||
normalize_max_segment_gap=config.normalize_max_segment_gap,
|
||||
normalize_ellipsis_gap=config.normalize_ellipsis_gap,
|
||||
normalize_max_segment_duration=config.normalize_max_segment_duration,
|
||||
normalize_max_segment_tokens=config.normalize_max_segment_tokens,
|
||||
work_dir=tmp_path / "work",
|
||||
work_dir_retention="never",
|
||||
)
|
||||
|
||||
with pytest.raises(AuditaLLMError, match="OpenRouter endpoint"):
|
||||
process_transcript_result(_transcript(), _glossary(), config)
|
||||
|
||||
run_dir = next((tmp_path / "work").iterdir())
|
||||
report = json.loads((run_dir / "report.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert report["status"] == "failed"
|
||||
assert report["normalization"]["normalized_segment_count"] == 2
|
||||
assert report["pipeline"] == [
|
||||
"glossary_1",
|
||||
"homophones",
|
||||
"glossary_2",
|
||||
"spoken_word",
|
||||
"grammar",
|
||||
]
|
||||
assert report["modules"] == []
|
||||
assert report["applied_changes"] == []
|
||||
assert report["skipped_corrections"] == []
|
||||
assert report["work_dir_retained"] is True
|
||||
assert report["work_dir"] == str(run_dir)
|
||||
assert "AUDITA_LLM_API_KEY" in report["error"]
|
||||
assert "OPENROUTER_API_KEY" in report["error"]
|
||||
assert "OpenRouter endpoint" in report["error"]
|
||||
assert report["error_details"]["type"] == "AuditaLLMError"
|
||||
assert report["error_details"]["phase"] == "pipeline"
|
||||
assert report["error_details"]["error_log"] == str(run_dir / "error.log")
|
||||
assert (run_dir / "error.log").exists()
|
||||
|
||||
|
||||
def test_process_transcript_result_allows_missing_api_key_for_nondefault_proposal_endpoint(tmp_path):
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
[
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
{"corrections": []},
|
||||
]
|
||||
)
|
||||
config = AuditaConfig.from_sources(
|
||||
env={},
|
||||
overrides=ConfigOverrides(
|
||||
base_url="http://localhost:8000/v1",
|
||||
model="meta-llama/Llama-3.1-8B-Instruct",
|
||||
work_dir=tmp_path / "work",
|
||||
work_dir_retention="always",
|
||||
),
|
||||
)
|
||||
|
||||
result = process_transcript_result(_transcript(), _glossary(), config, llm_client=llm_client)
|
||||
|
||||
assert result.report.status == "success"
|
||||
assert llm_client.calls[0]["config"].api_key is None
|
||||
assert llm_client.calls[0]["config"].base_url == "http://localhost:8000/v1"
|
||||
|
||||
|
||||
def test_process_transcript_result_allows_missing_validation_api_key_for_nondefault_validation_endpoint(tmp_path):
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
{
|
||||
"grammar:proposal": {
|
||||
"corrections": [
|
||||
{
|
||||
"id": 1,
|
||||
"original_text": "hello world",
|
||||
"corrected_text": "Hello world.",
|
||||
"confidence": 0.95,
|
||||
}
|
||||
]
|
||||
},
|
||||
"grammar:grammar_only_guard": {
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": True,
|
||||
"confidence": 0.98,
|
||||
"reason": "ok",
|
||||
}
|
||||
]
|
||||
},
|
||||
"grammar:meaning_reversal_review": {
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": True,
|
||||
"confidence": 0.99,
|
||||
"reason": "ok",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
config = AuditaConfig.from_sources(
|
||||
env={"OPENROUTER_API_KEY": "primary-key"},
|
||||
overrides=ConfigOverrides(
|
||||
work_dir=tmp_path / "work",
|
||||
work_dir_retention="always",
|
||||
validation_llm_api_key=" ",
|
||||
validation_base_url="http://localhost:9000/v1",
|
||||
validation_model="meta-llama/Llama-3.1-8B-Instruct",
|
||||
),
|
||||
)
|
||||
|
||||
result = process_transcript_result(
|
||||
parse_source_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"speaker": "Eric", "start": 0.0, "end": 1.0, "text": "hello world"}
|
||||
]
|
||||
"""
|
||||
),
|
||||
_glossary(),
|
||||
config,
|
||||
module_keys=["grammar"],
|
||||
llm_client=llm_client,
|
||||
)
|
||||
|
||||
assert result.transcript[0].text == "Hello world."
|
||||
calls_by_stage = {call["stage_name"]: call["config"] for call in llm_client.calls}
|
||||
assert calls_by_stage["grammar:proposal"].api_key == "primary-key"
|
||||
assert calls_by_stage["grammar:grammar_only_guard"].api_key == ""
|
||||
assert calls_by_stage["grammar:grammar_only_guard"].base_url == "http://localhost:9000/v1"
|
||||
|
||||
|
||||
def test_process_transcript_result_preserves_partial_progress_when_later_module_fails(tmp_path):
|
||||
transcript = parse_source_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"speaker": "Eric", "start": 0.0, "end": 1.0, "text": "There were gestures at the dam."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
config = AuditaConfig.from_sources(
|
||||
env={},
|
||||
overrides=None,
|
||||
)
|
||||
config = AuditaConfig(
|
||||
api_key=config.api_key,
|
||||
model=config.model,
|
||||
base_url=config.base_url,
|
||||
max_retries=config.max_retries,
|
||||
max_section_tokens=config.max_section_tokens,
|
||||
glossary_confidence_threshold=config.glossary_confidence_threshold,
|
||||
grammar_confidence_threshold=config.grammar_confidence_threshold,
|
||||
homophones_confidence_threshold=config.homophones_confidence_threshold,
|
||||
spoken_word_confidence_threshold=config.spoken_word_confidence_threshold,
|
||||
normalize_max_segment_gap=config.normalize_max_segment_gap,
|
||||
normalize_ellipsis_gap=config.normalize_ellipsis_gap,
|
||||
normalize_max_segment_duration=config.normalize_max_segment_duration,
|
||||
normalize_max_segment_tokens=config.normalize_max_segment_tokens,
|
||||
work_dir=tmp_path / "work",
|
||||
work_dir_retention="never",
|
||||
)
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
[
|
||||
{
|
||||
"corrections": [
|
||||
{
|
||||
"id": 1,
|
||||
"original_text": "gestures",
|
||||
"corrected_text": "Jesters",
|
||||
"confidence": 0.95,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": True,
|
||||
"confidence": 0.97,
|
||||
"reason": "Likely spoken-form correction in context.",
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 0,
|
||||
"approved": True,
|
||||
"confidence": 0.99,
|
||||
"reason": "Does not reverse the segment meaning.",
|
||||
}
|
||||
]
|
||||
},
|
||||
AuditaLLMError("Simulated homophones proposal failure."),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(AuditaLLMError, match="Simulated homophones proposal failure"):
|
||||
process_transcript_result(transcript, _glossary(), config, llm_client=llm_client)
|
||||
|
||||
run_dir = next((tmp_path / "work").iterdir())
|
||||
report = json.loads((run_dir / "report.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert report["status"] == "failed"
|
||||
assert report["normalization"]["normalized_segment_count"] == 1
|
||||
assert [module["instance_name"] for module in report["modules"]] == ["glossary_1"]
|
||||
assert report["applied_changes"][0]["corrected_text"] == "Jesters"
|
||||
assert report["applied_changes"][0]["segment_text_after"] == "There were Jesters at the dam."
|
||||
assert report["totals"]["applied_change_count"] == 1
|
||||
assert report["skipped_corrections"] == []
|
||||
assert report["pipeline"][1] == "homophones"
|
||||
assert "Simulated homophones proposal failure." in report["error"]
|
||||
assert report["error_details"]["module_instance"] == "homophones"
|
||||
assert report["error_details"]["phase"] == "pipeline"
|
||||
assert (run_dir / "error.log").exists()
|
||||
|
||||
|
||||
def test_process_transcript_result_preserves_partial_skips_and_validator_diagnostics_on_failure(tmp_path):
|
||||
config = AuditaConfig.from_sources(
|
||||
env={},
|
||||
overrides=None,
|
||||
)
|
||||
config = AuditaConfig(
|
||||
api_key=config.api_key,
|
||||
model=config.model,
|
||||
base_url=config.base_url,
|
||||
max_retries=config.max_retries,
|
||||
max_section_tokens=config.max_section_tokens,
|
||||
glossary_confidence_threshold=0.8,
|
||||
grammar_confidence_threshold=config.grammar_confidence_threshold,
|
||||
homophones_confidence_threshold=config.homophones_confidence_threshold,
|
||||
spoken_word_confidence_threshold=config.spoken_word_confidence_threshold,
|
||||
normalize_max_segment_gap=config.normalize_max_segment_gap,
|
||||
normalize_ellipsis_gap=config.normalize_ellipsis_gap,
|
||||
normalize_max_segment_duration=config.normalize_max_segment_duration,
|
||||
normalize_max_segment_tokens=config.normalize_max_segment_tokens,
|
||||
work_dir=tmp_path / "work",
|
||||
work_dir_retention="never",
|
||||
)
|
||||
llm_client = FakeStructuredLLMClient(
|
||||
[
|
||||
{
|
||||
"corrections": [
|
||||
{
|
||||
"id": 1,
|
||||
"original_text": "Hello",
|
||||
"corrected_text": "Jesters",
|
||||
"confidence": 0.40,
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"original_text": "Hello",
|
||||
"corrected_text": "Jesters",
|
||||
"confidence": 0.95,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"validations": [
|
||||
{
|
||||
"correction_index": 99,
|
||||
"approved": True,
|
||||
"confidence": 0.98,
|
||||
"reason": "Malformed response for testing.",
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(AuditaLLMError, match="unknown correction_index"):
|
||||
process_transcript_result(_transcript(), _glossary(), config, llm_client=llm_client)
|
||||
|
||||
run_dir = next((tmp_path / "work").iterdir())
|
||||
report = json.loads((run_dir / "report.json").read_text(encoding="utf-8"))
|
||||
validator_dir = run_dir / "glossary_1"
|
||||
|
||||
assert report["status"] == "failed"
|
||||
assert report["modules"] == []
|
||||
assert len(report["skipped_corrections"]) == 1
|
||||
assert report["skipped_corrections"][0]["reason"] == "proposal confidence below threshold"
|
||||
assert report["skipped_corrections"][0]["source"] == "validator:proposal_confidence_guard"
|
||||
assert "unknown correction_index" in report["error"]
|
||||
assert report["error_details"]["module_instance"] == "glossary_1"
|
||||
assert report["error_details"]["phase"] == "pipeline"
|
||||
assert (run_dir / "error.log").exists()
|
||||
assert (validator_dir / "spoken_form_plausibility_review-prompt-0000.json").exists()
|
||||
assert (validator_dir / "spoken_form_plausibility_review-response-0000.json").exists()
|
||||
|
||||
|
||||
def test_resolve_module_specs_numbers_repeated_keys():
|
||||
specs = resolve_module_specs(["glossary", "homophones", "glossary"])
|
||||
|
||||
assert [spec.instance_name for spec in specs] == ["glossary_1", "homophones", "glossary_2"]
|
||||
assert [spec.module_key for spec in specs] == ["glossary", "homophones", "glossary"]
|
||||
|
||||
|
||||
def test_process_transcript_result_supports_grammar_only_module_override(tmp_path):
|
||||
config = AuditaConfig.from_sources(
|
||||
env={},
|
||||
overrides=ConfigOverrides(work_dir=tmp_path / "work", work_dir_retention="always"),
|
||||
)
|
||||
|
||||
result = process_transcript_result(
|
||||
_transcript(),
|
||||
_glossary(),
|
||||
config,
|
||||
module_keys=["grammar"],
|
||||
llm_client=FakeStructuredLLMClient([{"corrections": []}]),
|
||||
)
|
||||
|
||||
assert [segment.id for segment in result.transcript] == [1, 2]
|
||||
assert result.report.pipeline == ["grammar"]
|
||||
assert result.report.totals["applied_change_count"] == 0
|
||||
84
python/tests/test_new_transcript_schema.py
Normal file
84
python/tests/test_new_transcript_schema.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import json
|
||||
|
||||
from audita.core.schemas import parse_source_transcript_json, parse_transcript_json, transcript_to_json
|
||||
|
||||
|
||||
SERIATIM_TRANSCRIPT = """
|
||||
{
|
||||
"metadata": {
|
||||
"application": "seriatim",
|
||||
"version": "dev",
|
||||
"input_reader": "json-files",
|
||||
"input_files": ["eric.json", "mike.json"],
|
||||
"preprocessing_modules": ["validate-raw", "normalize-speakers", "trim-text"],
|
||||
"postprocessing_modules": ["detect-overlaps", "resolve-overlaps", "backchannel"],
|
||||
"output_modules": ["json"]
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"source": "eric.json",
|
||||
"source_segment_index": 0,
|
||||
"speaker": "Eric Rakestraw",
|
||||
"start": 1.25,
|
||||
"end": 3.5,
|
||||
"text": "Hello there.",
|
||||
"overlap_group_id": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"source": "eric.json",
|
||||
"source_ref": "word-run:1:1:1",
|
||||
"derived_from": ["eric.json#0"],
|
||||
"speaker": "Eric Rakestraw",
|
||||
"start": 4.0,
|
||||
"end": 4.5,
|
||||
"text": "Resolved word run",
|
||||
"categories": ["backchannel"]
|
||||
}
|
||||
],
|
||||
"overlap_groups": [
|
||||
{
|
||||
"id": 1,
|
||||
"start": 1.25,
|
||||
"end": 4.0,
|
||||
"segments": ["eric.json#0", "mike.json#0"],
|
||||
"speakers": ["Eric Rakestraw", "Mike Brown"],
|
||||
"class": "unknown",
|
||||
"resolution": "unresolved"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_source_transcript_json_accepts_seriatim_transcript_object():
|
||||
segments = parse_source_transcript_json(SERIATIM_TRANSCRIPT)
|
||||
|
||||
assert len(segments) == 2
|
||||
assert segments[0].id == 1
|
||||
assert segments[0].speaker == "Eric Rakestraw"
|
||||
assert segments[0].start == 1.25
|
||||
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():
|
||||
segments = parse_transcript_json(SERIATIM_TRANSCRIPT)
|
||||
|
||||
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"]
|
||||
320
python/tests/test_protected_validator.py
Normal file
320
python/tests/test_protected_validator.py
Normal file
@@ -0,0 +1,320 @@
|
||||
from audita.core.schemas import parse_glossary_yaml, parse_transcript_json
|
||||
from audita.framework.models import CorrectionProposal, ModuleRunSpec
|
||||
from audita.validators import (
|
||||
GlossaryStageProtectedGlossaryTermsValidator,
|
||||
ProtectedGlossaryTermsValidator,
|
||||
ProtectedVocabulary,
|
||||
)
|
||||
from audita.validators.base import ValidationContext
|
||||
|
||||
|
||||
def _glossary():
|
||||
return parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Hrank"
|
||||
aliases:
|
||||
- "Greenfield"
|
||||
category: pc
|
||||
summary: "Hrank Greenfield is a player character."
|
||||
- name: "Popov"
|
||||
category: npc
|
||||
summary: "Popov is an allied NPC."
|
||||
- name: "Jesters"
|
||||
aliases:
|
||||
- "Jester"
|
||||
category: faction
|
||||
summary: "The Jesters are a faction."
|
||||
- name: "Svend"
|
||||
category: pc
|
||||
summary: "Svend is a player character."
|
||||
- name: "Godfrey"
|
||||
category: npc
|
||||
summary: "Godfrey is an NPC."
|
||||
- name: "Lyra"
|
||||
category: npc
|
||||
summary: "Lyra is an NPC."
|
||||
- name: "Loviator"
|
||||
category: deity
|
||||
summary: "Loviator is a deity."
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_protected_vocabulary_blocks_replacing_protected_term():
|
||||
vocabulary = ProtectedVocabulary.from_glossary(_glossary())
|
||||
|
||||
assert (
|
||||
vocabulary.violation_reason("Hrank moves.", "Frank moves.")
|
||||
== "correction changes protected glossary term usage"
|
||||
)
|
||||
|
||||
|
||||
def test_protected_vocabulary_glossary_stage_allows_glossary_to_glossary_changes():
|
||||
vocabulary = ProtectedVocabulary.from_glossary(_glossary())
|
||||
|
||||
assert vocabulary.violation_reason("Hrank moves.", "Popov moves.") == "correction changes protected glossary term usage"
|
||||
assert vocabulary.glossary_stage_violation_reason("Hrank moves.", "Popov moves.") is None
|
||||
|
||||
|
||||
def test_protected_vocabulary_glossary_stage_still_blocks_glossary_to_nonglossary_changes():
|
||||
vocabulary = ProtectedVocabulary.from_glossary(_glossary())
|
||||
|
||||
assert (
|
||||
vocabulary.glossary_stage_violation_reason("Hrank moves.", "Frank moves.")
|
||||
== "correction changes protected glossary term usage"
|
||||
)
|
||||
|
||||
|
||||
def test_protected_vocabulary_blocks_noncanonical_capitalization():
|
||||
vocabulary = ProtectedVocabulary.from_glossary(_glossary())
|
||||
|
||||
assert (
|
||||
vocabulary.violation_reason("Popov moves.", "POPOV moves.")
|
||||
== "correction changes protected glossary term capitalization"
|
||||
)
|
||||
assert (
|
||||
vocabulary.violation_reason("Jesters", "jesters")
|
||||
== "correction changes protected glossary term capitalization"
|
||||
)
|
||||
assert (
|
||||
vocabulary.glossary_stage_violation_reason("Popov moves.", "POPOV moves.")
|
||||
== "correction changes protected glossary term capitalization"
|
||||
)
|
||||
|
||||
|
||||
def test_protected_vocabulary_allows_corrections_toward_protected_terms():
|
||||
vocabulary = ProtectedVocabulary.from_glossary(_glossary())
|
||||
|
||||
assert vocabulary.violation_reason("Pawpaw moves.", "Popov moves.") is None
|
||||
assert vocabulary.violation_reason("gestures", "Jesters") is None
|
||||
assert vocabulary.violation_reason("gestures", "jesters") is None
|
||||
assert vocabulary.violation_reason("rank", "Hrank") is None
|
||||
assert vocabulary.violation_reason("rank", "hrank") is None
|
||||
assert vocabulary.violation_reason("spend", "Svend") is None
|
||||
|
||||
|
||||
def test_protected_vocabulary_allows_unchanged_noncanonical_terms_and_quote_wrapping():
|
||||
vocabulary = ProtectedVocabulary.from_glossary(_glossary())
|
||||
|
||||
assert vocabulary.violation_reason("jesters advance.", "jesters advance.") is None
|
||||
before = (
|
||||
"When you say that, Popov will say, when I was in that room with the jesters, "
|
||||
"I just knew that Godfrey and Lyra came directly from Loviator herself."
|
||||
)
|
||||
after = (
|
||||
'When you say that, Popov will say, "When I was in that room with the jesters, '
|
||||
'I just knew that Godfrey and Lyra came directly from Loviator herself."'
|
||||
)
|
||||
assert vocabulary.violation_reason(before, after) is None
|
||||
|
||||
|
||||
def test_protected_vocabulary_allows_inferred_and_explicit_plurals():
|
||||
vocabulary = ProtectedVocabulary.from_glossary(_glossary())
|
||||
explicit = ProtectedVocabulary.from_glossary(
|
||||
parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Mox"
|
||||
plural: "Moxen"
|
||||
category: faction
|
||||
summary: "The Mox are a faction."
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
assert vocabulary.violation_reason("Godfrey's", "Godfreys") is None
|
||||
assert vocabulary.violation_reason("gesture", "Jesters") is None
|
||||
assert explicit.violation_reason("Mox's", "Moxen") is None
|
||||
|
||||
|
||||
def test_protected_vocabulary_does_not_match_embedded_substrings():
|
||||
vocabulary = ProtectedVocabulary.from_glossary(_glossary())
|
||||
|
||||
assert vocabulary.violation_reason("The shrank spell worked.", "The shrank spell works.") is None
|
||||
|
||||
|
||||
def test_protected_glossary_terms_validator_returns_proposal_indexed_decisions():
|
||||
validator = ProtectedGlossaryTermsValidator("protected_glossary_guard")
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Frank moves."},
|
||||
{"id": 2, "speaker": "Eric", "start": 1.0, "end": 2.0, "text": "Pawpaw waits."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
proposals = [
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="glossary_primary",
|
||||
module_key="glossary",
|
||||
id=1,
|
||||
original_text="Hrank",
|
||||
corrected_text="Frank",
|
||||
confidence=0.9,
|
||||
),
|
||||
CorrectionProposal(
|
||||
proposal_index=1,
|
||||
module_instance="glossary_primary",
|
||||
module_key="glossary",
|
||||
id=2,
|
||||
original_text="Pawpaw",
|
||||
corrected_text="Popov",
|
||||
confidence=0.9,
|
||||
),
|
||||
]
|
||||
result = validator.validate(
|
||||
ValidationContext(
|
||||
proposals=proposals,
|
||||
transcript=transcript,
|
||||
glossary=_glossary(),
|
||||
config=None, # type: ignore[arg-type]
|
||||
run_spec=ModuleRunSpec(instance_name="glossary_primary", module_key="glossary", module=None), # type: ignore[arg-type]
|
||||
run_dir=transcript[0].__class__.__module__ and __import__("pathlib").Path("."),
|
||||
)
|
||||
)
|
||||
|
||||
assert [decision.proposal_index for decision in result.decisions] == [0, 1]
|
||||
assert result.decisions[0].approved is False
|
||||
assert result.decisions[0].reason == "correction changes protected glossary term usage"
|
||||
assert result.decisions[1].approved is True
|
||||
|
||||
|
||||
def test_protected_glossary_terms_validator_allows_nonglossary_to_lowercase_glossary_replacement():
|
||||
validator = ProtectedGlossaryTermsValidator("protected_glossary_guard")
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "gestures advance."},
|
||||
{"id": 2, "speaker": "Eric", "start": 1.0, "end": 2.0, "text": "rank moves."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
proposals = [
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="homophones",
|
||||
module_key="homophones",
|
||||
id=1,
|
||||
original_text="gestures",
|
||||
corrected_text="jesters",
|
||||
confidence=0.9,
|
||||
),
|
||||
CorrectionProposal(
|
||||
proposal_index=1,
|
||||
module_instance="homophones",
|
||||
module_key="homophones",
|
||||
id=2,
|
||||
original_text="rank",
|
||||
corrected_text="hrank",
|
||||
confidence=0.9,
|
||||
),
|
||||
]
|
||||
|
||||
result = validator.validate(
|
||||
ValidationContext(
|
||||
proposals=proposals,
|
||||
transcript=transcript,
|
||||
glossary=_glossary(),
|
||||
config=None, # type: ignore[arg-type]
|
||||
run_spec=ModuleRunSpec(instance_name="homophones", module_key="homophones", module=None), # type: ignore[arg-type]
|
||||
run_dir=__import__("pathlib").Path("."),
|
||||
)
|
||||
)
|
||||
|
||||
assert [decision.approved for decision in result.decisions] == [True, True]
|
||||
|
||||
|
||||
def test_glossary_stage_protected_glossary_terms_validator_allows_glossary_to_glossary_replacement():
|
||||
validator = GlossaryStageProtectedGlossaryTermsValidator("glossary_stage_protected_glossary_guard")
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "Hrank moves."},
|
||||
{"id": 2, "speaker": "Eric", "start": 1.0, "end": 2.0, "text": "Hrank moves."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
proposals = [
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="glossary_1",
|
||||
module_key="glossary",
|
||||
id=1,
|
||||
original_text="Hrank",
|
||||
corrected_text="Popov",
|
||||
confidence=0.9,
|
||||
),
|
||||
CorrectionProposal(
|
||||
proposal_index=1,
|
||||
module_instance="glossary_1",
|
||||
module_key="glossary",
|
||||
id=2,
|
||||
original_text="Hrank",
|
||||
corrected_text="POPOV",
|
||||
confidence=0.9,
|
||||
),
|
||||
]
|
||||
|
||||
result = validator.validate(
|
||||
ValidationContext(
|
||||
proposals=proposals,
|
||||
transcript=transcript,
|
||||
glossary=_glossary(),
|
||||
config=None, # type: ignore[arg-type]
|
||||
run_spec=ModuleRunSpec(instance_name="glossary_1", module_key="glossary", module=None), # type: ignore[arg-type]
|
||||
run_dir=__import__("pathlib").Path("."),
|
||||
)
|
||||
)
|
||||
|
||||
assert [decision.proposal_index for decision in result.decisions] == [0, 1]
|
||||
assert result.decisions[0].approved is True
|
||||
assert result.decisions[1].approved is True
|
||||
assert result.decisions[1].reason is None
|
||||
|
||||
|
||||
def test_protected_glossary_terms_validator_uses_proposal_span_only():
|
||||
validator = ProtectedGlossaryTermsValidator("protected_glossary_guard")
|
||||
glossary = parse_glossary_yaml(
|
||||
"""
|
||||
glossary:
|
||||
- name: "Svend"
|
||||
category: pc
|
||||
summary: "Svend is a player character."
|
||||
- name: "Jesters"
|
||||
category: faction
|
||||
summary: "The Jesters are a faction."
|
||||
"""
|
||||
)
|
||||
transcript = parse_transcript_json(
|
||||
"""
|
||||
[
|
||||
{"id": 1, "speaker": "Eric", "start": 0.0, "end": 1.0, "text": "You have to keep it bind. Svend sees the jesters."}
|
||||
]
|
||||
"""
|
||||
)
|
||||
proposals = [
|
||||
CorrectionProposal(
|
||||
proposal_index=0,
|
||||
module_instance="spoken_word",
|
||||
module_key="spoken_word",
|
||||
id=1,
|
||||
original_text="keep it bind",
|
||||
corrected_text="keep in mind",
|
||||
confidence=0.9,
|
||||
)
|
||||
]
|
||||
|
||||
result = validator.validate(
|
||||
ValidationContext(
|
||||
proposals=proposals,
|
||||
transcript=transcript,
|
||||
glossary=glossary,
|
||||
config=None, # type: ignore[arg-type]
|
||||
run_spec=ModuleRunSpec(instance_name="spoken_word", module_key="spoken_word", module=None), # type: ignore[arg-type]
|
||||
run_dir=__import__("pathlib").Path("."),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.decisions[0].approved is True
|
||||
Reference in New Issue
Block a user