Files
office_translator/services/quality/pipeline.py
sepehr c794eff823
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
feat(quality): A4 — L2 Pro premium judge (8 dims, gpt-4o, Pro-gated, opt-in)
2026-07-14 16:56:04 +02:00

427 lines
13 KiB
Python

"""
Quality pipeline — defensive wrapper around the L0 and L1 checks.
The pipeline is the integration point for the route. It:
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).
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, 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
from .l2_judge import L2Result, L2ProJudge
logger = get_logger(__name__)
# ---------- L0 ----------
def run_l0_check(
source_chunks: List[str],
translated_chunks: List[str],
target_lang: Optional[str],
job_id: Optional[str] = None,
file_extension: Optional[str] = None,
) -> DocumentQualityResult:
"""
Run the L0 quality checks defensively. Never raises.
"""
start = time.time()
empty = DocumentQualityResult(
passed=True,
score=0.0,
chunk_count=0,
failed_chunk_count=0,
issues={"internal_error": 1},
)
# Best-effort import of metrics; never let it break the check.
try:
from middleware.metrics import record_l0_result, record_l0_error
except Exception:
record_l0_result = None
record_l0_error = None
try:
result = evaluate_document(source_chunks, translated_chunks, target_lang)
except Exception as e:
elapsed_ms = round((time.time() - start) * 1000, 2)
logger.warning(
"quality_l0_check_failed",
job_id=job_id,
file_extension=file_extension,
error=str(e)[:200],
error_type=type(e).__name__,
elapsed_ms=elapsed_ms,
)
if record_l0_error:
try:
record_l0_error(file_type=file_extension or "unknown")
except Exception:
pass
return empty
elapsed_ms = round((time.time() - start) * 1000, 2)
logger.info(
"quality_l0_check",
job_id=job_id,
file_extension=file_extension,
target_lang=target_lang,
chunk_count=result.chunk_count,
failed_chunk_count=result.failed_chunk_count,
score=result.score,
passed=result.passed,
issues=result.issues,
elapsed_ms=elapsed_ms,
)
if record_l0_result:
try:
record_l0_result(
passed=bool(result.passed),
file_type=file_extension or "unknown",
)
except Exception:
pass
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),
)
_record_l1_metric(verdict="skip", model="none")
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",
)
_record_l1_metric(verdict="skip", model="none")
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__,
)
_record_l1_metric(verdict="error", model="unknown")
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,
)
# Record Prometheus metric (best-effort, never breaks the job)
duration_s = None
if result.elapsed_ms is not None:
try:
duration_s = float(result.elapsed_ms) / 1000.0
except Exception:
duration_s = None
_record_l1_metric(
verdict=result.verdict or "skip",
model=result.model_used or "unknown",
duration_seconds=duration_s,
cost_usd=result.cost_estimate_usd,
)
return result
def _record_l1_metric(
verdict: str,
model: str = "unknown",
duration_seconds: float = None,
cost_usd: float = None,
) -> None:
"""Best-effort Prometheus metric emission for L1.
Never raises — if the metrics module fails to import or the
counter is unavailable, we silently skip.
"""
try:
from middleware.metrics import record_l1_verdict
record_l1_verdict(
verdict=verdict,
model=model,
duration_seconds=duration_seconds,
cost_usd=cost_usd,
)
except Exception:
pass
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
# ---------- L2 (Pro tier) ----------
async def run_l2_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 = 15,
min_chunks: int = 20,
judge: Optional[L2ProJudge] = None,
log_only: bool = True,
) -> L2Result:
"""
Run the L2 Pro premium judge (8 dimensions, gpt-4o default).
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 L2ProJudge 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 L2Result. verdict="skip" on any internal error.
Never raises — defensive wrapper.
"""
skip = L2Result(verdict="skip", error="not_run")
if l0_failed_indices is None:
l0_failed_indices = set()
# Sample (reuse the L1 sampler — it's just chunk selection, model-agnostic)
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_l2_check_skipped",
job_id=job_id,
reason="insufficient_chunks_or_all_flagged",
chunk_count=len(source_chunks),
)
_record_l2_metric(verdict="skip", model="none")
return skip
# Get the judge
if judge is None:
judge = make_l2_judge_from_env_safe()
if judge is None:
logger.info(
"quality_l2_check_skipped",
job_id=job_id,
reason="no_l2_judge_configured",
)
_record_l2_metric(verdict="skip", model="none")
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:
logger.warning(
"quality_l2_check_failed",
job_id=job_id,
error=str(e)[:200],
error_type=type(e).__name__,
)
_record_l2_metric(verdict="error", model="unknown")
return L2Result(verdict="skip", error=str(e)[:200])
# Log (always) — caller decides what to do
logger.info(
"quality_l2_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,
average_score=result.average_score,
dimension_pass_rates=result.dimension_pass_rates,
model=result.model_used,
elapsed_ms=result.elapsed_ms,
cost_estimate_usd=result.cost_estimate_usd,
log_only=log_only,
)
# Record Prometheus metric
duration_s = None
if result.elapsed_ms is not None:
try:
duration_s = float(result.elapsed_ms) / 1000.0
except Exception:
duration_s = None
_record_l2_metric(
verdict=result.verdict or "skip",
model=result.model_used or "unknown",
duration_seconds=duration_s,
cost_usd=result.cost_estimate_usd,
)
return result
def _record_l2_metric(
verdict: str,
model: str = "unknown",
duration_seconds: float = None,
cost_usd: float = None,
) -> None:
"""Best-effort Prometheus metric emission for L2.
Never raises.
"""
try:
from middleware.metrics import record_l2_verdict
record_l2_verdict(
verdict=verdict,
model=model,
duration_seconds=duration_seconds,
cost_usd=cost_usd,
)
except Exception:
pass
def make_l2_judge_from_env_safe() -> Optional[L2ProJudge]:
"""Read env vars and build an L2 judge, or return None if not configured.
Defensive wrapper — a misconfigured L2 environment NEVER breaks a job.
"""
try:
from .l2_judge import make_l2_judge_from_env
return make_l2_judge_from_env()
except Exception as e:
logger.warning("l2_judge_init_failed", error=str(e)[:200])
return None