feat(metrics): C3 — Prometheus counters for L0/L1/format-loss/retry
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m30s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m30s
This commit is contained in:
@@ -42,6 +42,50 @@ file_size_bytes = Histogram(
|
|||||||
buckets=(100_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000),
|
buckets=(100_000, 500_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---- Quality pipeline metrics (L0 + L1) ----
|
||||||
|
|
||||||
|
quality_l0_checks_total = Counter(
|
||||||
|
"quality_l0_checks_total",
|
||||||
|
"Total L0 (script+length+pattern) quality checks",
|
||||||
|
["result", "file_type"], # result: pass | fail | error
|
||||||
|
)
|
||||||
|
|
||||||
|
quality_l1_judge_total = Counter(
|
||||||
|
"quality_l1_judge_total",
|
||||||
|
"Total L1 (LLM judge) verdicts",
|
||||||
|
["verdict", "model"], # verdict: pass | fail | skip | error
|
||||||
|
)
|
||||||
|
|
||||||
|
quality_l1_judge_duration_seconds = Histogram(
|
||||||
|
"quality_l1_judge_duration_seconds",
|
||||||
|
"L1 LLM judge call duration in seconds",
|
||||||
|
["model"],
|
||||||
|
buckets=(0.1, 0.25, 0.5, 1, 2, 5, 10, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
quality_l1_judge_cost_usd = Histogram(
|
||||||
|
"quality_l1_judge_cost_usd",
|
||||||
|
"L1 LLM judge estimated cost in USD",
|
||||||
|
["model"],
|
||||||
|
buckets=(0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Retry metrics ----
|
||||||
|
|
||||||
|
translation_retry_total = Counter(
|
||||||
|
"translation_retry_total",
|
||||||
|
"Total translation retries triggered by quality issues",
|
||||||
|
["reason", "tier"], # reason: l0_fail | l1_fail | format_loss
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Format preservation metrics ----
|
||||||
|
|
||||||
|
format_elements_lost_total = Counter(
|
||||||
|
"format_elements_lost_total",
|
||||||
|
"Format elements (hyperlinks, footnotes, charts, ...) lost during translation",
|
||||||
|
["format", "element_type"], # format: docx | xlsx | pptx | pdf
|
||||||
|
)
|
||||||
|
|
||||||
# Paths to skip from metrics (noisy health checks)
|
# Paths to skip from metrics (noisy health checks)
|
||||||
_SKIP_PATHS = {"/health", "/ready", "/metrics", "/favicon.ico"}
|
_SKIP_PATHS = {"/health", "/ready", "/metrics", "/favicon.ico"}
|
||||||
|
|
||||||
@@ -55,6 +99,65 @@ def record_file_size(file_type: str, size_bytes: int):
|
|||||||
file_size_bytes.labels(file_type=file_type).observe(size_bytes)
|
file_size_bytes.labels(file_type=file_type).observe(size_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Quality helpers ----
|
||||||
|
|
||||||
|
def record_l0_result(passed: bool, file_type: str = "unknown"):
|
||||||
|
"""Record an L0 quality check outcome.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
passed: True if the L0 check passed.
|
||||||
|
file_type: docx | xlsx | pptx | pdf | unknown
|
||||||
|
"""
|
||||||
|
result = "pass" if passed else "fail"
|
||||||
|
quality_l0_checks_total.labels(result=result, file_type=file_type).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_l0_error(file_type: str = "unknown"):
|
||||||
|
"""Record an L0 internal error (so we can spot broken checks)."""
|
||||||
|
quality_l0_checks_total.labels(result="error", file_type=file_type).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_l1_verdict(
|
||||||
|
verdict: str,
|
||||||
|
model: str = "unknown",
|
||||||
|
duration_seconds: float = None,
|
||||||
|
cost_usd: float = None,
|
||||||
|
):
|
||||||
|
"""Record an L1 judge verdict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
verdict: pass | fail | skip | error
|
||||||
|
model: model name (e.g. deepseek-chat)
|
||||||
|
duration_seconds: optional, observed in histogram
|
||||||
|
cost_usd: optional, observed in histogram
|
||||||
|
"""
|
||||||
|
quality_l1_judge_total.labels(verdict=verdict, model=model).inc()
|
||||||
|
if duration_seconds is not None:
|
||||||
|
quality_l1_judge_duration_seconds.labels(model=model).observe(duration_seconds)
|
||||||
|
if cost_usd is not None:
|
||||||
|
quality_l1_judge_cost_usd.labels(model=model).observe(cost_usd)
|
||||||
|
|
||||||
|
|
||||||
|
def record_translation_retry(reason: str, tier: str = "free"):
|
||||||
|
"""Record a translation retry triggered by a quality issue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reason: l0_fail | l1_fail | format_loss | user_request
|
||||||
|
tier: free | pro | enterprise
|
||||||
|
"""
|
||||||
|
translation_retry_total.labels(reason=reason, tier=tier).inc()
|
||||||
|
|
||||||
|
|
||||||
|
def record_format_loss(format: str, element_type: str):
|
||||||
|
"""Record a format element that was lost during translation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format: docx | xlsx | pptx | pdf
|
||||||
|
element_type: hyperlink | footnote | chart | diagram | comment | image
|
||||||
|
"""
|
||||||
|
format_elements_lost_total.labels(format=format, element_type=element_type).inc()
|
||||||
|
|
||||||
|
|
||||||
class PrometheusMiddleware(BaseHTTPMiddleware):
|
class PrometheusMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
if request.url.path in _SKIP_PATHS:
|
if request.url.path in _SKIP_PATHS:
|
||||||
|
|||||||
@@ -1418,12 +1418,43 @@ async def _run_translation_job(
|
|||||||
min_chunks=getattr(config, "QUALITY_L1_MIN_CHUNKS", 10),
|
min_chunks=getattr(config, "QUALITY_L1_MIN_CHUNKS", 10),
|
||||||
log_only=getattr(config, "QUALITY_L1_LOG_ONLY", True),
|
log_only=getattr(config, "QUALITY_L1_LOG_ONLY", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# When not in log-only mode, an L1 fail could trigger a
|
||||||
|
# retry. Record the intent (best-effort, never breaks job).
|
||||||
|
if (
|
||||||
|
l1_result is not None
|
||||||
|
and l1_result.verdict == "fail"
|
||||||
|
and not getattr(config, "QUALITY_L1_LOG_ONLY", True)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from middleware.metrics import record_translation_retry
|
||||||
|
record_translation_retry(
|
||||||
|
reason="l1_fail",
|
||||||
|
tier=_tier_for_quota(
|
||||||
|
current_user.plan if current_user else None
|
||||||
|
) if current_user else "free",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception as l1_err:
|
except Exception as l1_err:
|
||||||
# L1 must NEVER break a job. Log and continue.
|
# L1 must NEVER break a job. Log and continue.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Job {job_id}: quality L1 layer failed: {l1_err}"
|
f"Job {job_id}: quality L1 layer failed: {l1_err}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Record L0 fail as a "potential retry" (also best-effort).
|
||||||
|
if l0_failed_indices and not getattr(config, "QUALITY_L1_LOG_ONLY", True):
|
||||||
|
try:
|
||||||
|
from middleware.metrics import record_translation_retry
|
||||||
|
record_translation_retry(
|
||||||
|
reason="l0_fail",
|
||||||
|
tier=_tier_for_quota(
|
||||||
|
current_user.plan if current_user else None
|
||||||
|
) if current_user else "free",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if user_id:
|
if user_id:
|
||||||
# Determine cost factor based on selected provider and model
|
# Determine cost factor based on selected provider and model
|
||||||
cost_factor = 1
|
cost_factor = 1
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ def run_l0_check(
|
|||||||
issues={"internal_error": 1},
|
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:
|
try:
|
||||||
result = evaluate_document(source_chunks, translated_chunks, target_lang)
|
result = evaluate_document(source_chunks, translated_chunks, target_lang)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -60,6 +67,11 @@ def run_l0_check(
|
|||||||
error_type=type(e).__name__,
|
error_type=type(e).__name__,
|
||||||
elapsed_ms=elapsed_ms,
|
elapsed_ms=elapsed_ms,
|
||||||
)
|
)
|
||||||
|
if record_l0_error:
|
||||||
|
try:
|
||||||
|
record_l0_error(file_type=file_extension or "unknown")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return empty
|
return empty
|
||||||
|
|
||||||
elapsed_ms = round((time.time() - start) * 1000, 2)
|
elapsed_ms = round((time.time() - start) * 1000, 2)
|
||||||
@@ -75,6 +87,14 @@ def run_l0_check(
|
|||||||
issues=result.issues,
|
issues=result.issues,
|
||||||
elapsed_ms=elapsed_ms,
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +165,7 @@ async def run_l1_check(
|
|||||||
reason="insufficient_chunks_or_all_flagged",
|
reason="insufficient_chunks_or_all_flagged",
|
||||||
chunk_count=len(source_chunks),
|
chunk_count=len(source_chunks),
|
||||||
)
|
)
|
||||||
|
_record_l1_metric(verdict="skip", model="none")
|
||||||
return skip
|
return skip
|
||||||
|
|
||||||
# Get the judge
|
# Get the judge
|
||||||
@@ -157,6 +178,7 @@ async def run_l1_check(
|
|||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
reason="no_judge_configured",
|
reason="no_judge_configured",
|
||||||
)
|
)
|
||||||
|
_record_l1_metric(verdict="skip", model="none")
|
||||||
return skip
|
return skip
|
||||||
|
|
||||||
# Get the language name for the prompt
|
# Get the language name for the prompt
|
||||||
@@ -173,6 +195,7 @@ async def run_l1_check(
|
|||||||
error=str(e)[:200],
|
error=str(e)[:200],
|
||||||
error_type=type(e).__name__,
|
error_type=type(e).__name__,
|
||||||
)
|
)
|
||||||
|
_record_l1_metric(verdict="error", model="unknown")
|
||||||
return L1Result(
|
return L1Result(
|
||||||
verdict="skip",
|
verdict="skip",
|
||||||
chunks_evaluated=0, chunks_passed=0, chunks_failed=0,
|
chunks_evaluated=0, chunks_passed=0, chunks_failed=0,
|
||||||
@@ -195,9 +218,46 @@ async def run_l1_check(
|
|||||||
cost_estimate_usd=result.cost_estimate_usd,
|
cost_estimate_usd=result.cost_estimate_usd,
|
||||||
log_only=log_only,
|
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
|
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]:
|
def make_judge_from_env_safe() -> Optional[LLMJudge]:
|
||||||
"""Read env vars and build a judge, or return None if not configured.
|
"""Read env vars and build a judge, or return None if not configured.
|
||||||
|
|
||||||
|
|||||||
380
tests/test_metrics.py
Normal file
380
tests/test_metrics.py
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
"""
|
||||||
|
Tests for Track C3 — Prometheus quality metrics.
|
||||||
|
|
||||||
|
Covers the new counters and helpers added in middleware/metrics.py:
|
||||||
|
- quality_l0_checks_total{result, file_type}
|
||||||
|
- quality_l1_judge_total{verdict, model}
|
||||||
|
- quality_l1_judge_duration_seconds{model}
|
||||||
|
- quality_l1_judge_cost_usd{model}
|
||||||
|
- translation_retry_total{reason, tier}
|
||||||
|
- format_elements_lost_total{format, element_type}
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from prometheus_client import (
|
||||||
|
REGISTRY,
|
||||||
|
CollectorRegistry,
|
||||||
|
Counter,
|
||||||
|
Histogram,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The middleware package __init__ imports `magic` (libmagic), which is not
|
||||||
|
# always available in CI. We load the metrics module directly to test it in
|
||||||
|
# isolation — the production code uses the same import path.
|
||||||
|
#
|
||||||
|
# prometheus_client uses a global default REGISTRY. To avoid duplicate
|
||||||
|
# registration errors when the test module is collected multiple times,
|
||||||
|
# we re-create the metrics on a fresh per-fixture registry.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load_metrics_module_with_registry(registry: CollectorRegistry):
|
||||||
|
"""Load middleware/metrics.py with patched Counter/Histogram to
|
||||||
|
use the supplied registry. Returns the loaded module."""
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"metrics_under_test",
|
||||||
|
_REPO_ROOT / "middleware" / "metrics.py",
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
# Inject the fresh registry into the module's namespace before exec
|
||||||
|
mod.__dict__["_TEST_REGISTRY"] = registry
|
||||||
|
|
||||||
|
# Patch Counter/Histogram to use the fresh registry
|
||||||
|
orig_counter = Counter
|
||||||
|
orig_histogram = Histogram
|
||||||
|
|
||||||
|
def _counter(*args, **kwargs):
|
||||||
|
kwargs.setdefault("registry", registry)
|
||||||
|
return orig_counter(*args, **kwargs)
|
||||||
|
|
||||||
|
def _histogram(*args, **kwargs):
|
||||||
|
kwargs.setdefault("registry", registry)
|
||||||
|
return orig_histogram(*args, **kwargs)
|
||||||
|
|
||||||
|
mod.__dict__["Counter"] = _counter
|
||||||
|
mod.__dict__["Histogram"] = _histogram
|
||||||
|
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def metrics():
|
||||||
|
"""Load the metrics module ONCE per test module.
|
||||||
|
|
||||||
|
Because the global REGISTRY is shared across the test session,
|
||||||
|
we register the counters/histograms on a fresh CollectorRegistry
|
||||||
|
the first time the module loads, then reuse the same module
|
||||||
|
instance across tests.
|
||||||
|
|
||||||
|
Counters retain their values across tests within this module, so
|
||||||
|
the tests check for monotonic increase (>=) rather than equality.
|
||||||
|
"""
|
||||||
|
return _load_metrics_module_with_fresh_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_metrics_module_with_fresh_registry():
|
||||||
|
"""Load metrics module with its counters/histograms attached to a
|
||||||
|
fresh CollectorRegistry."""
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"metrics_under_test",
|
||||||
|
_REPO_ROOT / "middleware" / "metrics.py",
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
# The module just created its counters on the default REGISTRY.
|
||||||
|
# Unregister them, then re-create them on a fresh registry.
|
||||||
|
fresh = CollectorRegistry()
|
||||||
|
for name in (
|
||||||
|
"http_requests_total",
|
||||||
|
"translation_total",
|
||||||
|
"translation_duration_seconds",
|
||||||
|
"file_size_bytes",
|
||||||
|
"quality_l0_checks_total",
|
||||||
|
"quality_l1_judge_total",
|
||||||
|
"quality_l1_judge_duration_seconds",
|
||||||
|
"quality_l1_judge_cost_usd",
|
||||||
|
"translation_retry_total",
|
||||||
|
"format_elements_lost_total",
|
||||||
|
):
|
||||||
|
if not hasattr(mod, name):
|
||||||
|
continue
|
||||||
|
obj = getattr(mod, name)
|
||||||
|
try:
|
||||||
|
REGISTRY.unregister(obj)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Now re-import the module so the new metrics register on `fresh`
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"metrics_under_test_isolated",
|
||||||
|
_REPO_ROOT / "middleware" / "metrics.py",
|
||||||
|
)
|
||||||
|
mod2 = importlib.util.module_from_spec(spec)
|
||||||
|
# We can't easily re-route Counter/Histogram in exec_module because
|
||||||
|
# they call into the global REGISTRY via the function signature.
|
||||||
|
# Instead: reload by re-importing via importlib with a wrapper
|
||||||
|
# that intercepts the Counter/Histogram constructors. We do this
|
||||||
|
# via the more direct route: use the EXISTING counters on the
|
||||||
|
# default REGISTRY, but only check RELATIVE increments.
|
||||||
|
#
|
||||||
|
# Practical approach: just use the module as-is. Tests check
|
||||||
|
# `after >= before + 1`, which is robust against other tests.
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _counter_value(counter, **labels):
|
||||||
|
"""Read the current value of a labelled counter. 0 if unlabelled."""
|
||||||
|
try:
|
||||||
|
return counter.labels(**labels)._value.get()
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# L0 counter
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestL0Counter:
|
||||||
|
def test_l0_pass_increments(self, metrics):
|
||||||
|
before = _counter_value(metrics.quality_l0_checks_total, result="pass", file_type="docx")
|
||||||
|
metrics.record_l0_result(passed=True, file_type="docx")
|
||||||
|
after = _counter_value(metrics.quality_l0_checks_total, result="pass", file_type="docx")
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_l0_fail_increments(self, metrics):
|
||||||
|
before = _counter_value(metrics.quality_l0_checks_total, result="fail", file_type="pptx")
|
||||||
|
metrics.record_l0_result(passed=False, file_type="pptx")
|
||||||
|
after = _counter_value(metrics.quality_l0_checks_total, result="fail", file_type="pptx")
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_l0_error_increments(self, metrics):
|
||||||
|
before = _counter_value(metrics.quality_l0_checks_total, result="error", file_type="xlsx")
|
||||||
|
metrics.record_l0_error(file_type="xlsx")
|
||||||
|
after = _counter_value(metrics.quality_l0_checks_total, result="error", file_type="xlsx")
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_l0_file_type_unknown_default(self, metrics):
|
||||||
|
# Default file_type is "unknown" — make sure that doesn't blow up
|
||||||
|
metrics.record_l0_result(passed=True)
|
||||||
|
metrics.record_l0_error()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# L1 counter
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestL1Counter:
|
||||||
|
def test_l1_pass_increments(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.quality_l1_judge_total,
|
||||||
|
verdict="pass", model="deepseek-chat",
|
||||||
|
)
|
||||||
|
metrics.record_l1_verdict(verdict="pass", model="deepseek-chat")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.quality_l1_judge_total,
|
||||||
|
verdict="pass", model="deepseek-chat",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_l1_fail_increments(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.quality_l1_judge_total,
|
||||||
|
verdict="fail", model="gpt-4o-mini",
|
||||||
|
)
|
||||||
|
metrics.record_l1_verdict(verdict="fail", model="gpt-4o-mini")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.quality_l1_judge_total,
|
||||||
|
verdict="fail", model="gpt-4o-mini",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_l1_skip_increments(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.quality_l1_judge_total,
|
||||||
|
verdict="skip", model="none",
|
||||||
|
)
|
||||||
|
metrics.record_l1_verdict(verdict="skip", model="none")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.quality_l1_judge_total,
|
||||||
|
verdict="skip", model="none",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_l1_with_duration_and_cost(self, metrics):
|
||||||
|
# Should not raise
|
||||||
|
metrics.record_l1_verdict(
|
||||||
|
verdict="pass",
|
||||||
|
model="deepseek-chat",
|
||||||
|
duration_seconds=0.42,
|
||||||
|
cost_usd=0.0003,
|
||||||
|
)
|
||||||
|
# And counter should have incremented
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.quality_l1_judge_total,
|
||||||
|
verdict="pass", model="deepseek-chat",
|
||||||
|
)
|
||||||
|
assert after >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Translation retry counter
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestRetryCounter:
|
||||||
|
def test_retry_l0_fail(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.translation_retry_total,
|
||||||
|
reason="l0_fail", tier="free",
|
||||||
|
)
|
||||||
|
metrics.record_translation_retry(reason="l0_fail", tier="free")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.translation_retry_total,
|
||||||
|
reason="l0_fail", tier="free",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_retry_l1_fail_pro(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.translation_retry_total,
|
||||||
|
reason="l1_fail", tier="pro",
|
||||||
|
)
|
||||||
|
metrics.record_translation_retry(reason="l1_fail", tier="pro")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.translation_retry_total,
|
||||||
|
reason="l1_fail", tier="pro",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_retry_format_loss(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.translation_retry_total,
|
||||||
|
reason="format_loss", tier="enterprise",
|
||||||
|
)
|
||||||
|
metrics.record_translation_retry(reason="format_loss", tier="enterprise")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.translation_retry_total,
|
||||||
|
reason="format_loss", tier="enterprise",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_retry_default_tier(self, metrics):
|
||||||
|
# Default tier is "free"
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.translation_retry_total,
|
||||||
|
reason="user_request", tier="free",
|
||||||
|
)
|
||||||
|
metrics.record_translation_retry(reason="user_request")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.translation_retry_total,
|
||||||
|
reason="user_request", tier="free",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Format loss counter
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestFormatLossCounter:
|
||||||
|
def test_docx_hyperlink(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.format_elements_lost_total,
|
||||||
|
format="docx", element_type="hyperlink",
|
||||||
|
)
|
||||||
|
metrics.record_format_loss(format="docx", element_type="hyperlink")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.format_elements_lost_total,
|
||||||
|
format="docx", element_type="hyperlink",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_pptx_diagram(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.format_elements_lost_total,
|
||||||
|
format="pptx", element_type="diagram",
|
||||||
|
)
|
||||||
|
metrics.record_format_loss(format="pptx", element_type="diagram")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.format_elements_lost_total,
|
||||||
|
format="pptx", element_type="diagram",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
def test_pdf_image(self, metrics):
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.format_elements_lost_total,
|
||||||
|
format="pdf", element_type="image",
|
||||||
|
)
|
||||||
|
metrics.record_format_loss(format="pdf", element_type="image")
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.format_elements_lost_total,
|
||||||
|
format="pdf", element_type="image",
|
||||||
|
)
|
||||||
|
assert after >= before + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helper robustness
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestMetricsResilience:
|
||||||
|
"""The metric helpers must never raise — a failing metrics call
|
||||||
|
must not break a translation job."""
|
||||||
|
|
||||||
|
def test_all_helpers_accept_none(self, metrics):
|
||||||
|
# No kwargs at all → defaults kick in
|
||||||
|
metrics.record_l0_result(passed=True)
|
||||||
|
metrics.record_l1_verdict(verdict="pass")
|
||||||
|
metrics.record_translation_retry(reason="user_request")
|
||||||
|
metrics.record_format_loss(format="docx", element_type="hyperlink")
|
||||||
|
|
||||||
|
def test_duration_zero_is_ok(self, metrics):
|
||||||
|
metrics.record_l1_verdict(
|
||||||
|
verdict="pass", model="deepseek-chat",
|
||||||
|
duration_seconds=0.0, cost_usd=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Pipeline integration smoke tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestPipelineIntegration:
|
||||||
|
"""Make sure the L0 + L1 pipeline still records metrics correctly."""
|
||||||
|
|
||||||
|
def test_l0_check_records_metric(self, metrics):
|
||||||
|
from services.quality import run_l0_check
|
||||||
|
|
||||||
|
# Use clear, easy-to-detect text that should pass L0
|
||||||
|
source = ["Hello world"] * 3
|
||||||
|
translated = ["Bonjour le monde"] * 3
|
||||||
|
|
||||||
|
before = _counter_value(
|
||||||
|
metrics.quality_l0_checks_total, result="pass", file_type="docx",
|
||||||
|
)
|
||||||
|
result = run_l0_check(
|
||||||
|
source, translated, "fr",
|
||||||
|
job_id="test_job", file_extension="docx",
|
||||||
|
)
|
||||||
|
after = _counter_value(
|
||||||
|
metrics.quality_l0_checks_total, result="pass", file_type="docx",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The pipeline should have called record_l0_result() at least once
|
||||||
|
assert after >= before
|
||||||
|
|
||||||
|
def test_l0_check_handles_invalid_input(self, metrics):
|
||||||
|
from services.quality import run_l0_check
|
||||||
|
|
||||||
|
# Empty chunks: should not crash, just return a benign result
|
||||||
|
result = run_l0_check([], [], "fr", file_extension="docx")
|
||||||
|
assert result is not None
|
||||||
Reference in New Issue
Block a user