All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m26s
L1 quality layer — uses a cheap LLM via the OpenAI-compatible API to
validate translation quality. Designed to be the SECOND line of defense
after L0 (script detection, length, pattern).
Architecture:
- sampler.py — picks 5 representative chunks per job (longest first,
skips L0-failed indices, skips too-short or identical pairs)
- llm_judge.py — OpenAI-compatible client, binary verdict per chunk
(accurate / fluent / correct_language / no_leaks), JSON output,
hard timeout, defensive (never raises), cost estimation built in
- pipeline.py — defensive wrapper that integrates both, never breaks
a translation job, always logs a structured event
Integration:
- 5 feature flags in config.py (QUALITY_L1_ENABLED, _LOG_ONLY, etc.)
- QUALITY_L1_LOG_ONLY=true by default: log-only mode, verdict NEVER
blocks or retries a job
- Reuses the chunks extracted by L0 (no double work)
- Passes the set of L0-failed indices so L1 doesn't re-judge them
- Wrapped in try/except so a misconfigured L1 NEVER breaks a job
Default config: deepseek-chat via DeepSeek API
- Cost: ~0.0003 USD per job (5 chunks)
- Speed: typically 1-2s per call, hard ceiling at 8s
- Easy to swap: just set L1_JUDGE_BASE_URL and L1_JUDGE_MODEL
LLM judge is intentionally a SEPARATE model from the translator
(self-evaluation bias mitigation — Meta/Stanford papers 2024-2025).
Tests:
test_sampler.py — 9 tests covering the sampling strategy
test_llm_judge.py — 22 tests covering init, parsing, mocked API,
cost estimation, env factory
test_l1_pipeline.py — 6 tests covering the wrapper
Total new: 37 tests, all pass
Grand total quality+format: 264 tests passing (0 regression)
All 36 new tests + 111 L0 tests + 117 existing translator tests = 264
Phase 1 (observation) for 2 weeks. Then QUALITY_L1_LOG_ONLY=false
to enable auto-retry via the fallback chain.
128 lines
5.0 KiB
Python
128 lines
5.0 KiB
Python
"""
|
|
Tests for services/quality/pipeline.py — the L1 wrapper.
|
|
"""
|
|
import asyncio
|
|
import os
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from services.quality.pipeline import run_l1_check
|
|
from services.quality.llm_judge import L1Result, LLMJudge
|
|
|
|
|
|
class TestRunL1Check:
|
|
"""Test the defensive wrapper around the LLM judge."""
|
|
|
|
def test_too_few_chunks_returns_skip(self):
|
|
sources = ["a", "b", "c"] # only 3 chunks
|
|
translations = ["x", "y", "z"]
|
|
result = asyncio.run(run_l1_check(
|
|
sources, translations, "fr",
|
|
max_samples=5, min_chunks=10,
|
|
))
|
|
assert result.verdict == "skip"
|
|
|
|
def test_no_judge_configured_returns_skip(self, monkeypatch):
|
|
monkeypatch.delenv("L1_JUDGE_API_KEY", raising=False)
|
|
sources = [f"source {i}" for i in range(15)]
|
|
translations = [f"translation {i}" for i in range(15)]
|
|
result = asyncio.run(run_l1_check(
|
|
sources, translations, "fr",
|
|
max_samples=5, min_chunks=10,
|
|
))
|
|
assert result.verdict == "skip"
|
|
assert "judge" in result.error.lower() or "configured" in result.error.lower() or result.error == "not_run"
|
|
|
|
def test_skips_l0_failures(self):
|
|
"""Chunks that L0 already flagged should be excluded from the sample."""
|
|
# All chunks are long enough to be sampled. Use unique markers per
|
|
# index so substring checks don't have false positives (e.g.
|
|
# "source1" being a substring of "source10").
|
|
sources = [f"srcidx{i:02d} with enough length" for i in range(15)]
|
|
translations = [f"transidx{i:02d} avec longueur suffisante" for i in range(15)]
|
|
|
|
# Mock judge that records what it received
|
|
mock_judge = MagicMock(spec=LLMJudge)
|
|
mock_judge.judge_batch = AsyncMock(return_value=L1Result(
|
|
verdict="pass", chunks_evaluated=5, chunks_passed=5,
|
|
chunks_failed=0, failure_rate=0.0,
|
|
))
|
|
|
|
# Mark indices 01, 03, 05, 07, 09, 11, 13 as L0 failures
|
|
l0_failures = {1, 3, 5, 7, 9, 11, 13}
|
|
|
|
result = asyncio.run(run_l1_check(
|
|
sources, translations, "fr",
|
|
l0_failed_indices=l0_failures,
|
|
max_samples=5, min_chunks=10,
|
|
judge=mock_judge,
|
|
))
|
|
|
|
# The mock was called with 5 pairs
|
|
call_args = mock_judge.judge_batch.call_args
|
|
pairs = call_args[0][0]
|
|
# The pairs should NOT include any source from the L0-failed indices
|
|
for src, _trans in pairs:
|
|
for failed_idx in l0_failures:
|
|
marker = f"srcidx{failed_idx:02d}"
|
|
assert marker not in src, (
|
|
f"L0-failed source {failed_idx} was sent to L1: {src}"
|
|
)
|
|
|
|
def test_passes_log_only_flag(self):
|
|
"""The log_only flag should be passed through to the LLMJudge result."""
|
|
mock_judge = MagicMock(spec=LLMJudge)
|
|
mock_judge.judge_batch = AsyncMock(return_value=L1Result(
|
|
verdict="pass", chunks_evaluated=1, chunks_passed=1,
|
|
chunks_failed=0, failure_rate=0.0,
|
|
))
|
|
|
|
sources = [f"long enough source {i} " * 3 for i in range(15)]
|
|
translations = [f"long enough translation {i} " * 3 for i in range(15)]
|
|
|
|
asyncio.run(run_l1_check(
|
|
sources, translations, "fr",
|
|
max_samples=5, min_chunks=10,
|
|
judge=mock_judge,
|
|
log_only=True,
|
|
))
|
|
# log_only doesn't affect the call, just the downstream decision
|
|
|
|
def test_never_raises_on_judge_error(self):
|
|
"""If the judge itself raises, run_l1_check should catch it."""
|
|
mock_judge = MagicMock(spec=LLMJudge)
|
|
mock_judge.judge_batch = AsyncMock(side_effect=Exception("boom"))
|
|
|
|
sources = [f"long enough source {i} " * 3 for i in range(15)]
|
|
translations = [f"long enough translation {i} " * 3 for i in range(15)]
|
|
|
|
# Should NOT raise
|
|
result = asyncio.run(run_l1_check(
|
|
sources, translations, "fr",
|
|
max_samples=5, min_chunks=10,
|
|
judge=mock_judge,
|
|
))
|
|
assert result is not None
|
|
# verdict is either "skip" or an error one — never crashes the call
|
|
|
|
def test_target_lang_name_for_known_lang(self):
|
|
"""Verify the LANG_NAMES mapping is used."""
|
|
mock_judge = MagicMock(spec=LLMJudge)
|
|
mock_judge.judge_batch = AsyncMock(return_value=L1Result(
|
|
verdict="pass", chunks_evaluated=1, chunks_passed=1,
|
|
chunks_failed=0, failure_rate=0.0,
|
|
))
|
|
|
|
sources = [f"long enough source {i} " * 3 for i in range(15)]
|
|
translations = [f"long enough translation {i} " * 3 for i in range(15)]
|
|
|
|
asyncio.run(run_l1_check(
|
|
sources, translations, "fr",
|
|
max_samples=5, min_chunks=10,
|
|
judge=mock_judge,
|
|
))
|
|
call_args = mock_judge.judge_batch.call_args
|
|
# target_lang_name is the 3rd positional arg
|
|
target_lang_name = call_args[0][2]
|
|
assert target_lang_name == "French"
|