New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""
|
|
Voice routing policy contract tests.
|
|
|
|
Rules enforced:
|
|
1. voice_fast_uk must NOT contain models with known p50 > 15000ms.
|
|
2. voice_fast_uk must contain at least one model with p50 ≤ 3000ms (fast gate).
|
|
3. voice_quality_uk deadline_ms ≤ 12000 (bounded — not unlimited chat).
|
|
4. voice_fast_uk deadline_ms ≤ 9000.
|
|
5. auto_promote.condition.p95_ratio_vs_next_model < 1.0 (must be faster, not slower).
|
|
6. exclude_models list must contain known-slow models.
|
|
7. voice_fast_uk max_tokens (num_predict equivalent) ≤ 256.
|
|
8. Audit JSON (if present): latest run must show no "FAIL" in TTS scenarios.
|
|
9. voice_guardrails: SOFIIA_VOICE_PROMPT_SUFFIX must contain hard constraints.
|
|
10. BFF: ChatSendBody must accept voice_profile field.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent
|
|
ROUTER_CONFIG = REPO_ROOT / "services" / "router" / "router-config.yml"
|
|
AUDIT_DIR = REPO_ROOT / "ops" / "voice_audit_results"
|
|
BFF_MAIN = REPO_ROOT / "services" / "sofiia-console" / "app" / "main.py"
|
|
|
|
# Known-slow models: p50 > 15s in any voice audit run → banned from voice_fast_uk
|
|
KNOWN_SLOW_MODELS = {
|
|
"glm-4.7-flash:32k",
|
|
"glm-4.7-flash",
|
|
"deepseek-r1:70b",
|
|
}
|
|
|
|
# Hard deadline threshold for voice profiles (ms)
|
|
VOICE_FAST_MAX_DEADLINE_MS = 9_000
|
|
VOICE_QUALITY_MAX_DEADLINE_MS = 12_000
|
|
|
|
# SLO: at least one prefer model must be historically ≤ this latency
|
|
VOICE_FAST_GATE_MS = 6_000 # gemma3 measured at ~2.6s
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def router_config() -> dict:
|
|
with open(ROUTER_CONFIG) as f:
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def voice_fast(router_config) -> dict:
|
|
policies = router_config.get("selection_policies", {})
|
|
assert "voice_fast_uk" in policies, "selection_policies.voice_fast_uk not found in router-config.yml"
|
|
return policies["voice_fast_uk"]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def voice_quality(router_config) -> dict:
|
|
policies = router_config.get("selection_policies", {})
|
|
assert "voice_quality_uk" in policies, "selection_policies.voice_quality_uk not found"
|
|
return policies["voice_quality_uk"]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def latest_audit() -> dict | None:
|
|
"""Load the most recent voice audit JSON if present."""
|
|
if not AUDIT_DIR.exists():
|
|
return None
|
|
files = sorted(AUDIT_DIR.glob("audit_*.json"), reverse=True)
|
|
if not files:
|
|
return None
|
|
with open(files[0]) as f:
|
|
return json.load(f)
|
|
|
|
|
|
# ── 1. voice_fast_uk: no slow models ─────────────────────────────────────────
|
|
|
|
class TestVoiceFastUkPolicy:
|
|
|
|
def test_no_known_slow_models_in_prefer(self, voice_fast):
|
|
prefer = voice_fast.get("prefer_models", [])
|
|
for model in prefer:
|
|
assert model not in KNOWN_SLOW_MODELS, (
|
|
f"SLOW model '{model}' found in voice_fast_uk.prefer_models — "
|
|
f"remove or move to voice_quality_uk"
|
|
)
|
|
|
|
def test_known_slow_models_are_excluded(self, voice_fast):
|
|
excluded = set(voice_fast.get("exclude_models", []))
|
|
missing = KNOWN_SLOW_MODELS - excluded
|
|
assert not missing, (
|
|
f"Known-slow models not in voice_fast_uk.exclude_models: {missing}"
|
|
)
|
|
|
|
def test_deadline_bounded(self, voice_fast):
|
|
deadline = voice_fast.get("deadline_ms", 0)
|
|
assert deadline > 0, "deadline_ms must be set"
|
|
assert deadline <= VOICE_FAST_MAX_DEADLINE_MS, (
|
|
f"voice_fast_uk.deadline_ms={deadline} exceeds hard limit {VOICE_FAST_MAX_DEADLINE_MS}ms"
|
|
)
|
|
|
|
def test_max_tokens_bounded(self, voice_fast):
|
|
tokens = voice_fast.get("max_tokens", 999)
|
|
assert tokens <= 256, (
|
|
f"voice_fast_uk.max_tokens={tokens} — too high for voice (≤256 required)"
|
|
)
|
|
|
|
def test_prefer_has_fast_model(self, voice_fast):
|
|
prefer = voice_fast.get("prefer_models", [])
|
|
assert len(prefer) >= 1, "voice_fast_uk must have at least one prefer_model"
|
|
# gemma3 (known fast) should be first or second
|
|
fast_candidates = [m for m in prefer if "gemma3" in m or "8b" in m]
|
|
assert fast_candidates, (
|
|
f"voice_fast_uk.prefer_models has no known-fast model (gemma3/8b). "
|
|
f"Current list: {prefer}"
|
|
)
|
|
|
|
def test_gemma3_is_first_or_second(self, voice_fast):
|
|
prefer = voice_fast.get("prefer_models", [])
|
|
gemma3_idx = next((i for i, m in enumerate(prefer) if "gemma3" in m), None)
|
|
assert gemma3_idx is not None, "gemma3 must be in voice_fast_uk.prefer_models"
|
|
assert gemma3_idx <= 1, (
|
|
f"gemma3 should be first or second in prefer_models (found at index {gemma3_idx})"
|
|
)
|
|
|
|
def test_auto_promote_ratio_less_than_one(self, voice_fast):
|
|
ap = voice_fast.get("auto_promote", {})
|
|
if not ap:
|
|
pytest.skip("auto_promote not configured")
|
|
ratio = ap.get("condition", {}).get("p95_ratio_vs_next_model", 1.0)
|
|
assert ratio < 1.0, (
|
|
f"auto_promote.condition.p95_ratio_vs_next_model={ratio} must be < 1.0 "
|
|
f"(candidate must be faster than next model)"
|
|
)
|
|
|
|
def test_require_caps_includes_tts(self, voice_fast):
|
|
caps = voice_fast.get("require_caps", [])
|
|
assert "tts" in caps, "voice_fast_uk must require caps=[tts]"
|
|
|
|
|
|
# ── 2. voice_quality_uk: bounded deadline ────────────────────────────────────
|
|
|
|
class TestVoiceQualityUkPolicy:
|
|
|
|
def test_deadline_bounded(self, voice_quality):
|
|
deadline = voice_quality.get("deadline_ms", 0)
|
|
assert deadline <= VOICE_QUALITY_MAX_DEADLINE_MS, (
|
|
f"voice_quality_uk.deadline_ms={deadline} exceeds {VOICE_QUALITY_MAX_DEADLINE_MS}ms"
|
|
)
|
|
|
|
def test_no_known_slow_in_prefer(self, voice_quality):
|
|
prefer = voice_quality.get("prefer_models", [])
|
|
for model in prefer:
|
|
assert model not in KNOWN_SLOW_MODELS, (
|
|
f"SLOW model '{model}' in voice_quality_uk.prefer_models"
|
|
)
|
|
|
|
def test_max_tokens_bounded(self, voice_quality):
|
|
tokens = voice_quality.get("max_tokens", 999)
|
|
assert tokens <= 320, (
|
|
f"voice_quality_uk.max_tokens={tokens} exceeds 320 — high for voice"
|
|
)
|
|
|
|
|
|
# ── 3. Audit JSON: TTS scenarios must pass ───────────────────────────────────
|
|
|
|
class TestAuditResultsContract:
|
|
|
|
def test_tts_scenarios_all_pass(self, latest_audit):
|
|
if latest_audit is None:
|
|
pytest.skip("No audit results found — run ops/voice_latency_audit.sh first")
|
|
results = latest_audit.get("results", [])
|
|
tts_results = [r for r in results if "TTS" in r.get("scenario", "")]
|
|
assert tts_results, "No TTS scenarios in audit results"
|
|
failed = [r for r in tts_results if r.get("status") != "ok"]
|
|
assert not failed, (
|
|
f"TTS scenarios failed: {[r['scenario'] for r in failed]}"
|
|
)
|
|
|
|
def test_tts_p95_under_slo(self, latest_audit):
|
|
if latest_audit is None:
|
|
pytest.skip("No audit results")
|
|
results = latest_audit.get("results", [])
|
|
tts_ms = [r["ms"] for r in results if "TTS" in r.get("scenario", "") and r.get("status") == "ok"]
|
|
if not tts_ms:
|
|
pytest.skip("No successful TTS results")
|
|
tts_p95 = sorted(tts_ms)[int(len(tts_ms) * 0.95)] if len(tts_ms) > 1 else tts_ms[0]
|
|
assert tts_p95 <= 2500, f"TTS p95={tts_p95}ms exceeds SLO of 2500ms"
|
|
|
|
def test_no_glm_in_passing_chat_scenarios(self, latest_audit):
|
|
if latest_audit is None:
|
|
pytest.skip("No audit results")
|
|
results = latest_audit.get("results", [])
|
|
glm_ok = [r for r in results
|
|
if "glm" in r.get("scenario", "").lower()
|
|
and r.get("status") == "ok"
|
|
and r.get("ms", 0) > 15000]
|
|
assert not glm_ok, (
|
|
f"GLM model passed but took >15s — it must remain in exclude_models: {glm_ok}"
|
|
)
|
|
|
|
|
|
# ── 4. Voice guardrails in BFF ────────────────────────────────────────────────
|
|
|
|
class TestVoiceGuardrails:
|
|
|
|
def test_voice_prompt_suffix_exists(self):
|
|
src = BFF_MAIN.read_text()
|
|
assert "SOFIIA_VOICE_PROMPT_SUFFIX" in src, (
|
|
"SOFIIA_VOICE_PROMPT_SUFFIX not found in sofiia-console/app/main.py"
|
|
)
|
|
|
|
def test_voice_prompt_suffix_contains_max_sentences(self):
|
|
src = BFF_MAIN.read_text()
|
|
# Extract the suffix constant
|
|
m = re.search(
|
|
r'SOFIIA_VOICE_PROMPT_SUFFIX\s*=\s*"""(.*?)"""',
|
|
src, re.DOTALL
|
|
)
|
|
assert m, "Could not parse SOFIIA_VOICE_PROMPT_SUFFIX"
|
|
content = m.group(1)
|
|
assert "2 речення" in content or "2 sentence" in content.lower(), (
|
|
"SOFIIA_VOICE_PROMPT_SUFFIX must enforce max 2 sentence constraint"
|
|
)
|
|
|
|
def test_voice_prompt_suffix_bans_markdown(self):
|
|
src = BFF_MAIN.read_text()
|
|
m = re.search(
|
|
r'SOFIIA_VOICE_PROMPT_SUFFIX\s*=\s*"""(.*?)"""',
|
|
src, re.DOTALL
|
|
)
|
|
assert m
|
|
content = m.group(1)
|
|
assert "markdown" in content.lower() or "bold" in content.lower() or "bullet" in content.lower(), (
|
|
"SOFIIA_VOICE_PROMPT_SUFFIX should ban markdown/lists for voice"
|
|
)
|
|
|
|
def test_voice_prompt_suffix_bans_think_tags(self):
|
|
src = BFF_MAIN.read_text()
|
|
m = re.search(
|
|
r'SOFIIA_VOICE_PROMPT_SUFFIX\s*=\s*"""(.*?)"""',
|
|
src, re.DOTALL
|
|
)
|
|
assert m
|
|
content = m.group(1)
|
|
assert "<think>" in content, (
|
|
"SOFIIA_VOICE_PROMPT_SUFFIX should explicitly mention <think> ban"
|
|
)
|
|
|
|
def test_chat_send_body_has_voice_profile_field(self):
|
|
src = BFF_MAIN.read_text()
|
|
assert "voice_profile" in src, (
|
|
"ChatSendBody must have voice_profile field in sofiia-console/app/main.py"
|
|
)
|
|
|
|
def test_voice_guardrails_applied_per_turn(self):
|
|
src = BFF_MAIN.read_text()
|
|
# Check that _system_prompt is used (not hardcoded SOFIIA_SYSTEM_PROMPT) in ollama/glm/grok
|
|
provider_uses_system_prompt = src.count('"content": _system_prompt')
|
|
assert provider_uses_system_prompt >= 2, (
|
|
f"Expected ≥2 providers to use _system_prompt (voice-aware), found {provider_uses_system_prompt}"
|
|
)
|
|
|
|
|
|
# ── 5. Router config structure ────────────────────────────────────────────────
|
|
|
|
class TestRouterConfigStructure:
|
|
|
|
def test_voice_policies_section_exists(self, router_config):
|
|
assert "selection_policies" in router_config, \
|
|
"router-config.yml must have selection_policies section"
|
|
|
|
def test_agent_voice_profiles_sofiia(self, router_config):
|
|
avp = router_config.get("agent_voice_profiles", {})
|
|
assert "sofiia" in avp, "agent_voice_profiles.sofiia must be defined"
|
|
sofiia = avp["sofiia"]
|
|
assert sofiia.get("voice_profile") == "voice_fast_uk"
|
|
assert sofiia.get("quality_profile") == "voice_quality_uk"
|
|
|
|
def test_policies_voice_section_exists(self, router_config):
|
|
policies = router_config.get("policies", {})
|
|
assert "voice" in policies, "policies.voice section missing in router-config.yml"
|
|
v = policies["voice"]
|
|
assert v.get("deadline_ms_voice", 0) <= 9000
|
|
assert v.get("max_tokens_voice", 999) <= 256
|