feat(quality): A3 — L1 LLM judge via API (5 chunks, 0.0003 USD/job)
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.
This commit is contained in:
2026-07-14 16:39:47 +02:00
parent 5ae1587428
commit 4d466699fd
10 changed files with 1184 additions and 15 deletions

View File

@@ -1,17 +1,28 @@
"""
Quality check layer for translations.
Track A1 — L0 backend (observation only).
Pure Python, no new dependencies, no network calls.
Tracks:
A1 — L0 backend (pure Python, no API calls). Always available.
A2 — L0 frontend JavaScript mirror. Browser/Node compatible.
A3 — L1 LLM judge (API-based, opt-in). Cheap model, sampled.
Designed to be ADDITIVE: existing translation flow is untouched.
Public API:
L0:
QualityCheckResult — per-chunk result dataclass
DocumentQualityResult — aggregated result dataclass
evaluate_chunk(...) — score a single (source, translation) pair
evaluate_document(...) — score a list of pairs and aggregate
run_l0_check(...) — defensive wrapper used by the route
extract_sample(...) — extract text from a finished file
L1:
L1Result — verdict dataclass
LLMJudge — judge client
run_l1_check(...) — async wrapper used by the route
sample_chunks_for_l1(...) — pick representative chunks
Helpers:
get_script, isArabicScriptLang
"""
from .script_detector import (
@@ -21,10 +32,13 @@ from .script_detector import (
evaluate_document,
detect_arabic_variant,
)
from .pipeline import run_l0_check
from .pipeline import run_l0_check, run_l1_check
from .file_extractor import extract_sample
from .sampler import sample_chunks_for_l1
from .llm_judge import L1Result, L1ChunkVerdict, LLMJudge
__all__ = [
# L0
"QualityCheckResult",
"DocumentQualityResult",
"evaluate_chunk",
@@ -32,4 +46,10 @@ __all__ = [
"detect_arabic_variant",
"run_l0_check",
"extract_sample",
# L1
"L1Result",
"L1ChunkVerdict",
"LLMJudge",
"run_l1_check",
"sample_chunks_for_l1",
]

View File

@@ -0,0 +1,365 @@
"""
L1 LLM Judge — uses a cheap LLM via the OpenAI-compatible API to validate
the quality of a translation.
Why a SEPARATE LLM (not the one that did the translation)?
- Self-evaluation bias: a model tends to rate its own output as good
(Meta/Stanford papers on LLM-as-judge bias, 2024-2025).
- Independence gives a more reliable signal.
Design constraints:
- 100% API-based. No local models, no GPU.
- Async, with a hard timeout.
- Defensive: never raises. Returns a "skip" verdict on any internal error.
- Output is structured JSON for reliable parsing.
- Sampled (we never judge all chunks, just a small subset).
The LLM is asked a binary question per chunk: "Is this translation
accurate and fluent, in the correct language?" with a short reason.
We do NOT ask for nuanced MQM scores — binary is more reliable and
cheaper.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import time
from dataclasses import dataclass, field, asdict
from typing import List, Optional, Tuple
from core.logging import get_logger
logger = get_logger(__name__)
# ---------- Result dataclasses ----------
@dataclass
class L1ChunkVerdict:
"""Verdict for a single (source, translation) pair."""
accurate: bool
fluent: bool
correct_language: bool
no_leaks: bool
reason: str = ""
@property
def passed(self) -> bool:
return self.accurate and self.fluent and self.correct_language and self.no_leaks
def to_log_dict(self) -> dict:
return asdict(self)
@dataclass
class L1Result:
"""Aggregate result of an L1 check on a sample of chunks."""
verdict: str # "pass", "fail", "skip"
chunks_evaluated: int
chunks_passed: int
chunks_failed: int
failure_rate: float # 0.0 to 1.0
samples: List[dict] = field(default_factory=list)
model_used: str = ""
elapsed_ms: float = 0.0
cost_estimate_usd: float = 0.0
error: str = ""
def to_log_dict(self) -> dict:
return asdict(self)
# ---------- Prompt template ----------
JUDGE_SYSTEM_PROMPT = """You are a strict translation quality evaluator. Your job is to detect translation failures.
For each (SOURCE, TRANSLATION) pair, check these 4 criteria:
1. ACCURATE — Does the translation preserve the meaning of the source? (yes/no)
2. FLUENT — Is the translation grammatically correct in the target language? (yes/no)
3. CORRECT_LANGUAGE — Is the translation actually in {target_lang_name} (ISO code: {target_lang})? (yes/no)
4. NO_LEAKS — Is the translation free of prompt artifacts, source-language text, or meta-commentary? (yes/no)
A translation FAILS if ANY of the 4 checks is "no".
Respond with a JSON array, one object per pair, in the same order. NO other text, NO markdown fences:
[
{{"accurate": "yes"|"no", "fluent": "yes"|"no", "correct_language": "yes"|"no", "no_leaks": "yes"|"no", "reason": "one short sentence"}}
]
Be honest: if the translation looks suspicious, say "no". The "reason" must be in English and ≤ 12 words.
"""
# ---------- LLM client ----------
class LLMJudge:
"""
Calls a cheap LLM via the OpenAI-compatible API to judge translation quality.
Default configuration targets deepseek-chat via the OpenAI-compatible
DeepSeek API. The judge can be reconfigured to use any OpenAI-compatible
endpoint (OpenAI, OpenRouter, etc.) by passing different params.
"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.deepseek.com/v1",
model: str = "deepseek-chat",
timeout_seconds: float = 8.0,
max_retries: int = 1,
):
if not api_key:
raise ValueError("api_key is required for LLMJudge")
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._model = model
self._timeout = timeout_seconds
self._max_retries = max_retries
# Lazy import — keep services/quality free of the openai dep at import time
self._client = None
def _get_client(self):
if self._client is None:
try:
import openai
self._client = openai.AsyncOpenAI(
api_key=self._api_key,
base_url=self._base_url,
timeout=self._timeout,
)
except Exception as e:
logger.warning("llm_judge_client_init_failed", error=str(e))
return None
return self._client
async def judge_batch(
self,
pairs: List[Tuple[str, str]],
target_lang: str,
target_lang_name: str = "",
) -> L1Result:
"""
Judge a batch of (source, translation) pairs.
Returns an L1Result with verdict="skip" on any internal error.
Never raises.
"""
start = time.time()
if not pairs:
return L1Result(verdict="skip", chunks_evaluated=0, chunks_passed=0,
chunks_failed=0, failure_rate=0.0,
error="empty pairs")
client = self._get_client()
if client is None:
return L1Result(verdict="skip", chunks_evaluated=0, chunks_passed=0,
chunks_failed=0, failure_rate=0.0,
error="client unavailable")
# Build the user message
user_lines = [f"Target language: {target_lang} ({target_lang_name})\n"]
for i, (src, trans) in enumerate(pairs, 1):
user_lines.append(f"\n--- Pair {i} ---")
user_lines.append(f"SOURCE: {src}")
user_lines.append(f"TRANSLATION: {trans}")
user_msg = "\n".join(user_lines)
system_prompt = JUDGE_SYSTEM_PROMPT.format(
target_lang=target_lang,
target_lang_name=target_lang_name or target_lang,
)
try:
response = await asyncio.wait_for(
self._call_with_retries(client, system_prompt, user_msg),
timeout=self._timeout + 2.0, # hard ceiling
)
except asyncio.TimeoutError:
elapsed_ms = round((time.time() - start) * 1000, 2)
logger.warning("llm_judge_timeout", timeout_s=self._timeout, elapsed_ms=elapsed_ms)
return L1Result(verdict="skip", chunks_evaluated=0, chunks_passed=0,
chunks_failed=0, failure_rate=0.0,
error="timeout", elapsed_ms=elapsed_ms)
except Exception as e:
elapsed_ms = round((time.time() - start) * 1000, 2)
logger.warning("llm_judge_error", error=str(e)[:200], elapsed_ms=elapsed_ms)
return L1Result(verdict="skip", chunks_evaluated=0, chunks_passed=0,
chunks_failed=0, failure_rate=0.0,
error=str(e)[:200], elapsed_ms=elapsed_ms)
# Parse the response
verdicts = self._parse_response(response, len(pairs))
passed = sum(1 for v in verdicts if v.passed)
failed = len(verdicts) - passed
failure_rate = (failed / len(verdicts)) if verdicts else 0.0
# Aggregate verdict
if failed == 0:
verdict = "pass"
elif failure_rate >= 0.5:
verdict = "fail"
else:
# Some pass, some fail — degraded but not catastrophic.
# Caller can decide what to do.
verdict = "fail" # conservative: any failure = fail
elapsed_ms = round((time.time() - start) * 1000, 2)
# Cost estimate: deepseek-chat at $0.14/M in, $0.28/M out
# Rough estimate: 500 input tokens + 100 output tokens per call
cost_estimate = self._estimate_cost(len(pairs))
return L1Result(
verdict=verdict,
chunks_evaluated=len(verdicts),
chunks_passed=passed,
chunks_failed=failed,
failure_rate=round(failure_rate, 3),
samples=[v.to_log_dict() for v in verdicts],
model_used=self._model,
elapsed_ms=elapsed_ms,
cost_estimate_usd=cost_estimate,
)
async def _call_with_retries(self, client, system_prompt: str, user_msg: str):
"""Call the LLM with retry on transient errors."""
last_exc = None
for attempt in range(self._max_retries + 1):
try:
response = await client.chat.completions.create(
model=self._model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_msg},
],
temperature=0.0, # deterministic
max_tokens=500, # enough for ~5 verdicts
response_format={"type": "json_object"}, # if supported
)
return response
except Exception as e:
last_exc = e
if attempt < self._max_retries:
await asyncio.sleep(0.5)
raise last_exc
def _parse_response(self, response, expected_count: int) -> List[L1ChunkVerdict]:
"""
Parse the LLM response into a list of verdicts.
Robust to JSON wrapped in code fences or with extra commentary.
"""
# Extract the message content
try:
content = response.choices[0].message.content or ""
except (AttributeError, IndexError) as e:
logger.warning("llm_judge_bad_response", error=str(e))
return []
content = content.strip()
# Strip markdown code fences if present
if content.startswith("```"):
content = re.sub(r"^```(?:json)?\s*\n?", "", content)
content = re.sub(r"\n?```\s*$", "", content)
# Try to parse as JSON object (deepseek with json_object format) or array
try:
data = json.loads(content)
except json.JSONDecodeError as e:
logger.warning("llm_judge_json_parse_error", error=str(e), content_preview=content[:200])
return []
# Handle both {"verdicts": [...]} and direct [...]
if isinstance(data, dict):
# Look for a list-typed value
items = None
for key in ("verdicts", "results", "translations", "data"):
if key in data and isinstance(data[key], list):
items = data[key]
break
if items is None:
# Take the first list-typed value
for v in data.values():
if isinstance(v, list):
items = v
break
if items is None:
logger.warning("llm_judge_no_list_in_response")
return []
elif isinstance(data, list):
items = data
else:
logger.warning("llm_judge_unexpected_response_type", type_=type(data).__name__)
return []
# Parse each item
verdicts: List[L1ChunkVerdict] = []
for item in items:
try:
v = L1ChunkVerdict(
accurate=str(item.get("accurate", "")).lower() == "yes",
fluent=str(item.get("fluent", "")).lower() == "yes",
correct_language=str(item.get("correct_language", "")).lower() == "yes",
no_leaks=str(item.get("no_leaks", "")).lower() == "yes",
reason=str(item.get("reason", ""))[:200],
)
verdicts.append(v)
except Exception as e:
logger.warning("llm_judge_item_parse_error", error=str(e), item=str(item)[:200])
# Continue with what we have
return verdicts
def _estimate_cost(self, num_pairs: int) -> float:
"""Rough USD cost estimate for the call. Conservative (rounded up)."""
# Approximation: 250 input tokens per pair + 50 output tokens per pair
# (rough based on the prompt template + JSON output)
input_tokens = 200 + (num_pairs * 250)
output_tokens = num_pairs * 50
# deepseek-chat pricing
if "deepseek" in self._model.lower():
input_cost = input_tokens / 1_000_000 * 0.14
output_cost = output_tokens / 1_000_000 * 0.28
elif "gpt-4o-mini" in self._model.lower():
input_cost = input_tokens / 1_000_000 * 0.15
output_cost = output_tokens / 1_000_000 * 0.60
elif "gemini" in self._model.lower() and "flash" in self._model.lower():
# gemini-2.5-flash-lite ~ $0.10/M in, $0.40/M out
input_cost = input_tokens / 1_000_000 * 0.10
output_cost = output_tokens / 1_000_000 * 0.40
else:
# Generic conservative estimate
input_cost = input_tokens / 1_000_000 * 0.50
output_cost = output_tokens / 1_000_000 * 1.00
return round(input_cost + output_cost, 6)
# ---------- Convenience factory ----------
def make_judge_from_env() -> Optional[LLMJudge]:
"""
Build an LLMJudge from environment variables. Returns None if no
API key is configured.
Reads:
- L1_JUDGE_API_KEY (required)
- L1_JUDGE_BASE_URL (default: DeepSeek)
- L1_JUDGE_MODEL (default: deepseek-chat)
- L1_JUDGE_TIMEOUT (default: 8.0)
"""
import os
api_key = os.getenv("L1_JUDGE_API_KEY", "").strip()
if not api_key:
return None
return LLMJudge(
api_key=api_key,
base_url=os.getenv("L1_JUDGE_BASE_URL", "https://api.deepseek.com/v1"),
model=os.getenv("L1_JUDGE_MODEL", "deepseek-chat"),
timeout_seconds=float(os.getenv("L1_JUDGE_TIMEOUT", "8.0")),
)

View File

@@ -1,27 +1,34 @@
"""
Quality pipeline — defensive wrapper around the L0 checks.
Quality pipeline — defensive wrapper around the L0 and L1 checks.
The pipeline is the integration point for the route. It:
1. Catches all exceptions (L0 must NEVER break a translation job)
1. Catches all exceptions (quality checks must NEVER break a translation job)
2. Adds timing
3. Emits a single structured log line per job
4. For L1, reads configuration from environment and is opt-in via
a flag passed in by the route (the route reads config.py).
The actual checks live in `script_detector`, `length_checker`, `pattern_leak`.
This module is the orchestration / safety layer.
L0 = pure-Python script + length + pattern checks. No API calls.
L1 = cheap LLM judge via API. Sampled (5 chunks per job by default).
"""
from __future__ import annotations
import os
import time
from typing import List, Optional
from typing import List, Optional, Set
from core.logging import get_logger
from .script_detector import evaluate_document, DocumentQualityResult
from .sampler import sample_chunks_for_l1
from .llm_judge import L1Result, LLMJudge
logger = get_logger(__name__)
# ---------- L0 ----------
def run_l0_check(
source_chunks: List[str],
translated_chunks: List[str],
@@ -31,10 +38,6 @@ def run_l0_check(
) -> DocumentQualityResult:
"""
Run the L0 quality checks defensively. Never raises.
Returns an empty/neutral DocumentQualityResult on internal error
so the calling route can log and continue without affecting the
translation job outcome.
"""
start = time.time()
empty = DocumentQualityResult(
@@ -73,3 +76,138 @@ def run_l0_check(
elapsed_ms=elapsed_ms,
)
return result
# ---------- L1 ----------
# Common ISO codes → language name for the prompt
_LANG_NAMES = {
"en": "English", "fr": "French", "es": "Spanish", "de": "German",
"it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian",
"ja": "Japanese", "ko": "Korean", "zh": "Chinese", "ar": "Arabic",
"fa": "Persian", "hi": "Hindi", "tr": "Turkish", "pl": "Polish",
"vi": "Vietnamese", "th": "Thai", "id": "Indonesian", "ms": "Malay",
"uk": "Ukrainian", "cs": "Czech", "sv": "Swedish", "ro": "Romanian",
"hu": "Hungarian", "el": "Greek", "he": "Hebrew", "da": "Danish",
"fi": "Finnish", "no": "Norwegian", "bg": "Bulgarian", "hr": "Croatian",
}
async def run_l1_check(
source_chunks: List[str],
translated_chunks: List[str],
target_lang: Optional[str],
l0_failed_indices: Optional[Set[int]] = None,
job_id: Optional[str] = None,
file_extension: Optional[str] = None,
max_samples: int = 5,
min_chunks: int = 10,
judge: Optional[LLMJudge] = None,
log_only: bool = True,
) -> L1Result:
"""
Run the L1 LLM judge check on a sample of chunks.
Args:
source_chunks: Original texts.
translated_chunks: Translated texts.
target_lang: Target language code (e.g. "fr", "en").
l0_failed_indices: Indices that L0 flagged as bad — skipped.
job_id: For logging.
file_extension: For logging.
max_samples: How many chunks to send to the LLM.
min_chunks: Skip the check if document has fewer chunks.
judge: An LLMJudge instance. If None, created from env vars.
log_only: If True, never propagate the verdict (observation mode).
If False, the caller can decide what to do with the verdict.
Returns an L1Result. verdict="skip" on any internal error.
Never raises — defensive wrapper.
"""
skip = L1Result(
verdict="skip",
chunks_evaluated=0, chunks_passed=0, chunks_failed=0,
failure_rate=0.0, error="not_run",
)
if l0_failed_indices is None:
l0_failed_indices = set()
# Sample
sample = sample_chunks_for_l1(
source_chunks, translated_chunks, l0_failed_indices,
max_samples=max_samples, min_chunks=min_chunks,
)
if not sample:
logger.info(
"quality_l1_check_skipped",
job_id=job_id,
reason="insufficient_chunks_or_all_flagged",
chunk_count=len(source_chunks),
)
return skip
# Get the judge
if judge is None:
judge = make_judge_from_env_safe()
if judge is None:
logger.info(
"quality_l1_check_skipped",
job_id=job_id,
reason="no_judge_configured",
)
return skip
# Get the language name for the prompt
target_lang_name = _LANG_NAMES.get((target_lang or "").lower(), target_lang or "auto")
# Call the LLM
try:
result = await judge.judge_batch(sample, target_lang or "auto", target_lang_name)
except Exception as e:
elapsed_ms = 0.0
logger.warning(
"quality_l1_check_failed",
job_id=job_id,
error=str(e)[:200],
error_type=type(e).__name__,
)
return L1Result(
verdict="skip",
chunks_evaluated=0, chunks_passed=0, chunks_failed=0,
failure_rate=0.0, error=str(e)[:200], elapsed_ms=elapsed_ms,
)
# Log (always) — caller decides what to do
logger.info(
"quality_l1_check",
job_id=job_id,
file_extension=file_extension,
target_lang=target_lang,
verdict=result.verdict,
chunks_evaluated=result.chunks_evaluated,
chunks_passed=result.chunks_passed,
chunks_failed=result.chunks_failed,
failure_rate=result.failure_rate,
model=result.model_used,
elapsed_ms=result.elapsed_ms,
cost_estimate_usd=result.cost_estimate_usd,
log_only=log_only,
)
return result
def make_judge_from_env_safe() -> Optional[LLMJudge]:
"""Read env vars and build a judge, or return None if not configured.
This is a thin wrapper around `llm_judge.make_judge_from_env` that
catches any import-time or init-time error and returns None — so
a misconfigured L1 environment NEVER breaks a translation job.
"""
try:
from .llm_judge import make_judge_from_env
return make_judge_from_env()
except Exception as e:
logger.warning("l1_judge_init_failed", error=str(e)[:200])
return None

View File

@@ -0,0 +1,79 @@
"""
Stratified sampler for the L1 quality layer.
The L1 layer sends a small number of (source, translation) pairs to a
cheap LLM for a quality verdict. We don't want to send ALL chunks
(cost, latency) and we don't want to send random chunks (might miss
the problematic ones). We use a simple but effective strategy:
- Prefer the LONGEST chunks (they contain the most diagnostic
information per call).
- Skip chunks that the L0 already flagged (we already know they're
bad; we don't need the LLM to confirm).
- Never sample more than `max_samples` chunks.
- If `min_chunks` chunks aren't available, skip the L1 entirely
(small documents don't need it).
"""
from __future__ import annotations
from typing import List, Tuple
def sample_chunks_for_l1(
source_chunks: List[str],
translated_chunks: List[str],
failed_indices: set,
max_samples: int = 5,
min_chunks: int = 10,
) -> List[Tuple[str, str]]:
"""
Select a sample of (source, translation) pairs to send to the L1 judge.
Args:
source_chunks: Original texts.
translated_chunks: Translated texts (same length as source_chunks).
failed_indices: Set of indices that L0 already flagged as bad.
These are SKIPPED — we want fresh signal, not
confirmation of an already-detected failure.
max_samples: Maximum number of pairs to return.
min_chunks: If the document has fewer than this many chunks,
return an empty list (not enough signal to bother
calling the LLM).
Returns:
A list of (source, translated) tuples, ready to send to the LLM
judge. Order: longest chunks first.
"""
n = min(len(source_chunks), len(translated_chunks))
if n < min_chunks:
return []
# Build candidate list, excluding L0 failures
candidates: List[Tuple[int, int, str, str]] = []
for i in range(n):
if i in failed_indices:
continue
src = (source_chunks[i] or "").strip()
trans = (translated_chunks[i] or "").strip()
# Skip very short pairs (not enough context for the LLM to judge)
if len(src) < 5 and len(trans) < 5:
continue
# Skip pairs where source and translation are identical
# (probably a non-translated cell like a number, code, or brand)
if src == trans:
continue
# Rank by length of the translation (longer = more diagnostic)
rank = len(trans) + len(src) // 2
candidates.append((rank, i, src, trans))
# Sort by length descending and take top N
candidates.sort(key=lambda c: c[0], reverse=True)
selected = candidates[:max_samples]
# Return in original document order (helps the LLM judge maintain
# context, and makes the verdict easier to interpret)
selected.sort(key=lambda c: c[1])
return [(src, trans) for _rank, _i, src, trans in selected]