""" Post-translation QA report (no external API — pure heuristics). Answers three user-facing questions about a finished job: - Are numbers preserved? (digit-token multiset source vs translation) - Did everything actually get translated? (untranslated-ratio heuristic) - A 0-100 confidence score combining both. Never blocks a job: every failure degrades to "skipped". """ import re from pathlib import Path from typing import Dict, List, Optional from core.logging import get_logger logger = get_logger(__name__) # Tokens that are never counted as "content words" for the untranslated ratio _PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) _WORD_RE = re.compile(r"[\w']+", re.UNICODE) _NUM_RE = re.compile(r"\d+(?:[.,]\d+)*", re.UNICODE) # Latin-script vs non-Latin word detection for language confusion heuristics _LATIN_RE = re.compile(r"[a-zA-Z]") def _extract_text_pairs(source_path: Path, output_path: Path, file_extension: str): """Extract (source_text, translated_text) full-document strings. Reuses the quality layer's file extractor so every format is read the same way the L0 check reads it. Applied to the INPUT file the same extractor yields the SOURCE text (the "translated" field simply holds whatever text lives in the file). """ from services.quality.file_extractor import extract_sample src_chunks = extract_sample(Path(source_path), file_extension, max_samples=10_000) out_chunks = extract_sample(Path(output_path), file_extension, max_samples=10_000) src = "\n".join(c["translated"] for c in src_chunks) out = "\n".join(c["translated"] for c in out_chunks) return src, out def _number_multiset(text: str) -> List[str]: """Digit tokens with the decimal separator normalized (12,50 == 12.50). French/English differ on ',' vs '.'; a real translation keeps the value. """ return sorted(n.replace(",", ".") for n in _NUM_RE.findall(text)) def _number_fidelity(source: str, translated: str) -> Optional[dict]: """Compare digit tokens: how many source numbers survived (order-insensitive).""" src_nums = _number_multiset(source) if not src_nums: return None out_nums = _number_multiset(translated) # multiset intersection from collections import Counter src_count = Counter(src_nums) out_count = Counter(out_nums) kept = sum((src_count & out_count).values()) return { "source_numbers": len(src_nums), "preserved": kept, "fidelity": round(kept / len(src_nums), 3), } def _untranslated_ratio(source: str, translated: str) -> Optional[float]: """Heuristic: share of source content-words still present verbatim in the output. ~0 for a real translation, ~1 when nothing was translated. """ src_words = [w.lower() for w in _WORD_RE.findall(source) if len(w) > 3] if len(src_words) < 10: return None out_lower = translated.lower() hits = sum(1 for w in set(src_words) if w in out_lower) return round(hits / len(set(src_words)), 3) def run_qa_report( source_path: Path, output_path: Path, target_lang: str, file_extension: str ) -> Optional[Dict]: """Compute the QA report for a finished translation job. Returns a dict with numbers fidelity, untranslated ratio and a 0-100 score, or None if the report could not be computed. """ try: source, translated = _extract_text_pairs( Path(source_path), Path(output_path), file_extension ) except Exception as e: logger.warning("qa_report_extract_failed", error=str(e)) return None if not source.strip() or not translated.strip(): return None numbers = _number_fidelity(source, translated) untranslated = _untranslated_ratio(source, translated) score = 100.0 if numbers: score *= 0.5 + 0.5 * numbers["fidelity"] if untranslated is not None and untranslated > 0: score *= max(0.0, 1.0 - untranslated) report = { "score": int(round(score)), "numbers": numbers, "untranslated_ratio": untranslated, } logger.info("qa_report_computed", **{k: v for k, v in report.items() if v is not None}) return report