Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
427 lines
13 KiB
Python
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
|