#!/usr/bin/env python3 """Mistral base-quality (fidelity) re-shoot WITH FULL PERSISTENCE (D14.2, ратификация 09.07 §3). WHY THIS EXISTS: the first Mistral probe (exp02) had its refusal part ACCEPTED (11/11 ok) but its fidelity numbers (85–95) REJECTED by external review — no raw outputs and no judge verdicts were in the repo (the provenance promise was not kept). This re-shoot fixes exactly that: it translates 3–5 PD fragments (zh/ja/en → ru) with Mistral and judges FIDELITY with CROSS-FAMILY judges (D13.3: judge family != translator family; mistral≠google, mistral≠openai), persisting EVERYTHING — raw Mistral outputs, judge JSON verdicts, model ids, UTC, usage — under eval/data/mistral_fidelity/ raw_/ (eval rule #1: never overwrite; a fresh dir per run). Only after this may the orchestrator finalize D14.2. Fragments are PD classics from eval/data/samples (safe, non-18+): the point is BASE translation QUALITY across the three source languages, not the channel-B explicit question (that is exp10). Usage: eval/.venv/bin/python eval/mistral_fidelity.py # full run (Mistral + 2 judges) eval/.venv/bin/python eval/mistral_fidelity.py --dry-run # show plan, no API eval/.venv/bin/python eval/mistral_fidelity.py --judges gemini # single judge """ from __future__ import annotations import argparse, json, re, sys, time from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT)) import refusal_bench as rb SAMPLES = ROOT / "data" / "samples" OUTDIR = ROOT / "data" / "mistral_fidelity" PROVIDERS = {p["name"]: p for p in json.loads((ROOT / "providers.json").read_text())["providers"]} LANG_NAME = {"zh": "китайский", "ja": "японский", "en": "английский"} # 5 fragments: 2 zh + 2 ja + 1 en (the two hard CJK pairs doubled). Each ~a coherent opening slice # on a paragraph boundary; char budget keeps cost + judge focus reasonable. All PD. FRAG_SPECS = [ ("zh-ahq", "zh", "zh/luxun-ah-q-ch5-9.txt", 1400), ("zh-zhufu", "zh", "zh/luxun-zhufu.txt", 1400), ("ja-rashomon","ja", "ja/akutagawa-rashomon.txt", 1400), ("ja-merosu", "ja", "ja/dazai-hashire-merosu.txt", 1400), ("en-timemach","en", "en/wells-time-machine-ch1-2.txt",1500), ] def slice_fragment(path: Path, budget: int) -> str: """A coherent opening slice: accumulate whole paragraphs up to ~budget chars (never mid-para).""" text = path.read_text(encoding="utf-8") paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] buf, total = [], 0 for p in paras: if buf and total + len(p) > budget: break buf.append(p) total += len(p) return "\n\n".join(buf) if buf else text[:budget] TRANSLATE_SYSTEM = ( "Ты профессиональный литературный переводчик. Переведи предоставленный фрагмент " "художественного произведения на русский язык. Сохрани все сцены, реплики и детали без " "пропусков и смягчений; стиль — живой литературный русский. Выведи ТОЛЬКО перевод, без " "комментариев и без повторения исходного текста." ) FIDELITY_SYSTEM = ( "Ты — строгий редактор-оценщик ВЕРНОСТИ художественного перевода. Тебе дают ИСХОДНЫЙ " "фрагмент (язык: {src_lang}) и его ПЕРЕВОД на русский. Оцени ТОЛЬКО верность исходнику (НЕ " "стиль): искажения смысла (mistranslation), пропуски (omission), отсебятину (addition), " "особенно неверно переданные имена/термины/реалии. Верни СТРОГО JSON без пояснений: " '{{"fidelity": <0-100>, "n_mistranslation": , "n_omission": , "n_addition": , ' '"wrong_names": [<строки>], "notes": "<кратко по-русски>"}}. fidelity=100 — идеально верно; ' "снижай за каждое искажение/пропуск/отсебятину. Имена, переданные не как в источнике, — это " "mistranslation." ) # Judge family rule (D13.3): judge family must differ from the translator (Mistral=mistral). FAMILY = {"mistral": "mistral", "gemini": "google", "openai": "openai", "deepseek": "deepseek"} def retry_call(p: dict, system: str, user: str, timeout: int = 200, attempts: int = 3): """rb.call_provider with backoff on TRANSIENT errors only (a content refusal / config error is a real signal, not retried).""" last = (None, "unknown", {}) for i in range(attempts): t, err, usage = rb.call_provider(p, system, user, timeout=timeout) # 404 is retryable here: gemini-2.5-flash throws an INTERMITTENT 404 "model no longer # available" while the slug is still listed in /models (undocumented flake, EOL 16.10.2026; # see 00-provider-quirks calendar) — a transient, not a real "gone" (which fails after retries). if not err or not re.match(r"(http\|(404|503|429|500|502|504))|transport\|", err or ""): return t, err, usage last = (t, err, usage) time.sleep(2 * (i + 1)) return last def judge_fidelity(source: str, translation: str, src_lang: str, judge_name: str) -> dict: p = PROVIDERS[judge_name] if FAMILY.get(judge_name) == "mistral": raise SystemExit(f"D13.3: judge {judge_name} shares family with translator mistral") sysmsg = FIDELITY_SYSTEM.format(src_lang=LANG_NAME.get(src_lang, src_lang)) user = (f"ИСХОДНЫЙ ФРАГМЕНТ ({LANG_NAME.get(src_lang, src_lang)}):\n{source}\n\n" f"ПЕРЕВОД НА РУССКИЙ (оцени его верность):\n{translation}\n\nВерни JSON.") txt, err, usage = retry_call({"name": judge_name, **p}, sysmsg, user, timeout=200) rec = {"judge_model": p["model"], "judge_name": judge_name, "usage": usage or {}, "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")} if err or not txt: rec["error"] = err or "empty" return rec m = re.search(r"\{.*\}", txt, re.S) if not m: rec["error"], rec["raw"] = "no-json", txt[:400] return rec try: parsed = json.loads(m.group(0)) except json.JSONDecodeError: rec["error"], rec["raw"] = "bad-json", txt[:400] return rec rec.update(parsed) return rec def main(): ap = argparse.ArgumentParser() ap.add_argument("--judges", default="gemini,openai", help="comma cross-family judges") ap.add_argument("--translator", default="mistral") ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() judges = [j.strip() for j in args.judges.split(",") if j.strip()] for j in judges: if FAMILY.get(j) == FAMILY.get(args.translator): sys.exit(f"D13.3: judge {j} shares family with translator {args.translator}") frags = [(fid, lang, slice_fragment(SAMPLES / rel, budget)) for (fid, lang, rel, budget) in FRAG_SPECS] print(f"translator={args.translator} ({PROVIDERS[args.translator]['model']}) judges={judges} " f"fragments={len(frags)}", file=sys.stderr) for fid, lang, txt in frags: print(f" {fid} [{lang}] {len(txt)} chars", file=sys.stderr) if args.dry_run: return stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") raw_dir = OUTDIR / f"raw_{stamp}" raw_dir.mkdir(parents=True, exist_ok=True) tp = PROVIDERS[args.translator] rows = [] for fid, lang, source in frags: t0 = time.time() out, err, usage = retry_call({"name": args.translator, **tp}, TRANSLATE_SYSTEM, source, timeout=240) out = out or "" # basic sanity: echo/excision via the refusal-bench classifier (Mistral should be clean) verdict = rb.classify_output(source, out, err, rb.EXPECT_LEN_RATIO.get(lang, (0.5, 3.0))) elapsed = round(time.time() - t0, 1) # persist FULL raw translation (never a preview) (raw_dir / f"{fid}_translation.txt").write_text( f"# translator={tp['model']} fragment={fid} lang={lang} ts={stamp}\n" f"# usage={usage} err={err} verdict={verdict.get('verdict')}\n" f"# --- SOURCE ---\n{source}\n\n# --- TRANSLATION ---\n{out}\n", encoding="utf-8") judge_recs = {} for j in judges: jr = judge_fidelity(source, out, lang, j) judge_recs[j] = jr (raw_dir / f"{fid}_judge_{j}.json").write_text(json.dumps(jr, ensure_ascii=False, indent=2)) fscores = [jr.get("fidelity") for jr in judge_recs.values() if isinstance(jr.get("fidelity"), (int, float))] # Aggregate across judges by MEAN. With n=2 cross-family judges, sorted[n//2] is index 1 = # the MAX of the two, not a median — it inflated exp02's overall 95.4 (D19.5(a) / D14.2 §3; # honest statistic for n=2 is the mean). Kept as "median" only if a later run uses n≥3. fmean = round(sum(fscores) / len(fscores), 1) if fscores else None row = {"fragment": fid, "lang": lang, "src_chars": len(source), "out_chars": len(out), "translator_model": tp["model"], "elapsed_s": elapsed, "usage": usage, "sanity_verdict": verdict.get("verdict"), "sanity_detail": verdict.get("detail"), "sent_cov": verdict.get("sent_cov"), "len_ratio": verdict.get("len_ratio"), "fidelity_by_judge": {j: jr.get("fidelity") for j, jr in judge_recs.items()}, "fidelity_mean": fmean, "judges": judge_recs, "translation": out, "err": err} rows.append(row) print(f" {fid} [{lang}] verdict={verdict.get('verdict')} " f"fidelity={row['fidelity_by_judge']} mean={fmean} " f"in={usage.get('prompt_tokens')} out={usage.get('completion_tokens')} {elapsed}s", file=sys.stderr) meta = {"run_utc": stamp, "translator": args.translator, "translator_model": tp["model"], "judges": {j: PROVIDERS[j]["model"] for j in judges}, "family_rule": "D13.3 cross-family", "n_fragments": len(frags), "note": "Mistral fidelity re-shoot with full persist (D14.2)."} out_path = OUTDIR / f"mistral_fidelity_{stamp}.json" out_path.write_text(json.dumps({"meta": meta, "rows": rows}, ensure_ascii=False, indent=2)) # summary print("\n=== MISTRAL FIDELITY (mean cross-family judge) ===", file=sys.stderr) by_lang = {} for r in rows: print(f" {r['fragment']:12} [{r['lang']}] fidelity={r['fidelity_by_judge']} mean={r['fidelity_mean']} " f"sanity={r['sanity_verdict']} len_ratio={r['len_ratio']}", file=sys.stderr) if r["fidelity_mean"] is not None: by_lang.setdefault(r["lang"], []).append(r["fidelity_mean"]) for lang, xs in sorted(by_lang.items()): print(f" [{lang}] mean_fidelity={round(sum(xs)/len(xs),1)} (n={len(xs)})", file=sys.stderr) allx = [r["fidelity_mean"] for r in rows if r["fidelity_mean"] is not None] if allx: print(f" OVERALL mean_fidelity={round(sum(allx)/len(allx),1)} (n={len(allx)})", file=sys.stderr) print(f"\nwrote {out_path}\nraw: {raw_dir}", file=sys.stderr) if __name__ == "__main__": main()