"""
================================================================================
PRODUCTION v4 — クロスリンガルICD-11コーディングにおけるLLMの一貫性(本番)
================================================================================
本番設定: 300件 × 6言語 × 6モデル × k=1 = 10,800コール（概算 ≈ ¥1,000 / 約1〜2時間）
  ・件数(N_ENTITIES)・反復(K_REPS)・言語(LANGUAGES)は下のCONFIGで変更可(レジューム対応)。

v3 からの追加:
  ・標本化の高速化 … 章の全子孫を include=descendant で一括取得し、並列で適格判定
    （取得できない場合は従来の木探索にフォールバック）。26分→数分を目標。
  ・章レベル一致を採点に追加 … 「同じICD-11章に入れられるか」を可視化
    （安価モデルは章すら外す/上位モデルは章は合う、の差を定量化）。
  ・陳腐化(モデル進化)対策 …
      - 各APIの応答が返す「完全版モデルID」(例 gpt-5.4-nano-2026-03-17, claude-haiku-4-5-20251001)
        と「実行日時」を全行に記録 → いつ・どの版で測ったかが永続的に残る。
      - 各モデルに能力ティア(budget/mid等)のタグを付与 → モデル名でなくティアで論じられる。
      - 標本(entities_prod.json)・プロンプト・採点コードがそのまま公開用ベンチマーク資産。
  ・レジューム/並列/進度/問題検知は v3 から継続。

使い方:
  pip install requests numpy krippendorff
  （5キーを set。Gemini は 200 が出た正しいキーを使う）
  python pilot_icd11_v4.py
  ・標本やり直し → entities_prod.json を削除   ・結果やり直し → results_prod.csv を削除
================================================================================
"""

import os
import re
import csv
import json
import math
import time
import random
import threading
from datetime import datetime
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests
import numpy as np

# ============================== CONFIG ==============================
ICD_CLIENT_ID     = os.environ.get("ICD_CLIENT_ID", "")
ICD_CLIENT_SECRET = os.environ.get("ICD_CLIENT_SECRET", "")
ICD_RELEASE       = "2025-01"
ICD_LINEARIZATION = "mms"

LANGUAGES   = ["en", "es", "fr", "zh", "ar", "ru"]
N_ENTITIES  = 300     # 本番の標本数（後から増やしてもレジュームで追加取得）
K_REPS      = 1       # 反復（言語間比較は k=1 で可。同一言語内のブレを見るなら 2 以上）
TEMPERATURE = 0.0
SEED        = 42

MODELS = [
    ("openai",     "gpt-5.4-nano"),
    ("openai",     "gpt-5.4"),
    ("google",     "gemini-2.5-flash-lite"),
    ("google",     "gemini-2.5-flash"),
    ("anthropic",  "claude-haiku-4-5"),
    ("anthropic",  "claude-sonnet-4-6"),
]
# 能力ティア（陳腐化対策: モデル名でなくティアで論じる）
TIER = {
    "gpt-5.4-nano": "budget",          "gpt-5.4": "mid",
    "gemini-2.5-flash-lite": "budget", "gemini-2.5-flash": "budget-mid",
    "claude-haiku-4-5": "budget-mid",  "claude-sonnet-4-6": "mid",
}

ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
OPENAI_API_KEY    = os.environ.get("OPENAI_API_KEY", "")
GOOGLE_API_KEY    = os.environ.get("GOOGLE_API_KEY", "")

PRICE = {
    "gpt-5.4-nano": (0.20, 1.25), "gpt-5.4": (2.50, 15.00),
    "gemini-2.5-flash-lite": (0.10, 0.40), "gemini-2.5-flash": (0.30, 2.50),
    "claude-haiku-4-5": (1.00, 5.00), "claude-sonnet-4-6": (3.00, 15.00),
}
JPY_PER_USD = 155.0

LLM_WORKERS      = 6
ICD_WORKERS      = 8
MAX_RETRIES      = 4
CONSEC_ERR_LIMIT = 8

ENTITIES_CACHE = "entities_prod.json"
RESULTS_CSV    = "results_prod.csv"

MIN_DEF_LEN = 60
SKIP_CHAPTER_KEYWORDS = ["external cause", "extension", "supplementary",
                         "provisional", "functioning"]
RESIDUAL_TITLE_KEYWORDS = ["unspecified", "other specified", "not elsewhere",
                           "without specification", "other forms", "site unspecified"]

CODE_RE = re.compile(r'\b[0-9A-Z]{1,2}[0-9A-Z][0-9A-Z](?:\.[0-9A-Z]+)?\b')

INSTRUCTION_BY_LANG = {
    "en": ('You are a clinical coder. Read the clinical description and return the single '
           'best ICD-11 MMS code for the condition it describes. Respond ONLY as JSON: '
           '{"code":"XXXX"}. No explanation.\n\nDescription: {DESCRIPTION}'),
    "es": ('Eres un codificador clínico. Lee la descripción clínica y devuelve el único '
           'mejor código ICD-11 MMS de la afección descrita. Responde SOLO como JSON: '
           '{"code":"XXXX"}. Sin explicación.\n\nDescripción: {DESCRIPTION}'),
    "fr": ('Vous êtes un codeur clinique. Lisez la description clinique et renvoyez le '
           'meilleur code ICD-11 MMS unique de l’affection décrite. Répondez UNIQUEMENT '
           'en JSON : {"code":"XXXX"}. Aucune explication.\n\nDescription : {DESCRIPTION}'),
}

RUN_TS = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")

# ============================== ICD-API ==============================
def get_icd_token():
    r = requests.post("https://icdaccessmanagement.who.int/connect/token",
        data={"client_id": ICD_CLIENT_ID, "client_secret": ICD_CLIENT_SECRET,
              "scope": "icdapi_access", "grant_type": "client_credentials"},
        timeout=30, verify=True)
    r.raise_for_status()
    return r.json()["access_token"]

def icd_get(url, token, lang):
    url = url.replace("http://", "https://")  # http→https 直叩きでリダイレクト回避(高速化)
    headers = {"Authorization": "Bearer " + token, "Accept": "application/json",
               "Accept-Language": lang, "API-Version": "v2"}
    r = requests.get(url, headers=headers, timeout=30, verify=True)
    r.raise_for_status()
    return r.json()

def _title(e):      return (e.get("title") or {}).get("@value", "") or ""
def _definition(e): return (e.get("definition") or {}).get("@value", "") or ""

def is_eligible(e):
    if not e or not e.get("code"):                         return False
    if e.get("child"):                                     return False
    if len(_definition(e)) < MIN_DEF_LEN:                  return False
    if any(k in _title(e).lower() for k in RESIDUAL_TITLE_KEYWORDS): return False
    return True

def fetch_entities_parallel(urls, token, lang="en", workers=ICD_WORKERS):
    out = {}
    def f(u):
        try:    return u, icd_get(u, token, lang)
        except Exception: return u, None
    with ThreadPoolExecutor(max_workers=workers) as ex:
        for u, e in ex.map(f, urls):
            out[u] = e
    return out

def _treewalk_chapter(ch_url, per, token):
    """フォールバック: 章を木探索して適格葉を集める(低速)。"""
    leaves, stack, tries = [], [ch_url], 0
    while stack and len(leaves) < per * 3 and tries < 400:
        tries += 1
        u = stack.pop()
        try:    e = icd_get(u, token, "en")
        except Exception: continue
        if is_eligible(e):
            leaves.append({"url": u, "code": e["code"], "title_en": _title(e)})
        for c in e.get("child", [])[:20]:
            stack.append(c)
    return leaves

def sample_entities(token, n, seed):
    random.seed(seed)
    root_url = f"https://id.who.int/icd/release/11/{ICD_RELEASE}/{ICD_LINEARIZATION}"
    root = icd_get(root_url, token, "en")
    chapters = root.get("child", [])
    if not chapters:
        raise RuntimeError("章が取得できません。ICD_RELEASE / 認証 を確認してください。")
    print("  章を確認中 ...", flush=True)
    usable = []
    for ch_url in chapters:
        try:    ch = icd_get(ch_url, token, "en")
        except Exception: continue
        if any(k in _title(ch).lower() for k in SKIP_CHAPTER_KEYWORDS):
            print(f"    skip章: {_title(ch)[:40]}", flush=True); continue
        usable.append((ch_url, _title(ch)))
    print(f"  対象章 {len(usable)} / 全{len(chapters)}章", flush=True)
    per = math.ceil(n / max(1, len(usable)))
    picked = []
    for idx, (ch_url, ch_title) in enumerate(usable, 1):
        print(f"  [{idx}/{len(usable)}] {ch_title[:26]} ...", end="", flush=True)
        # 高速: 章の全子孫URIを一括取得
        try:
            chd = icd_get(ch_url + "?include=descendant", token, "en")
            desc = [u.replace("http://", "https://") for u in (chd.get("descendant") or [])]
        except Exception:
            desc = []
        found = []
        if desc:
            random.shuffle(desc)
            cap, i, batch = 400, 0, ICD_WORKERS * 3
            while len(found) < per and i < min(len(desc), cap):
                chunk = desc[i:i + batch]; i += len(chunk)
                ents = fetch_entities_parallel(chunk, token, "en")
                for u in chunk:
                    e = ents.get(u)
                    if is_eligible(e):
                        found.append({"url": u, "code": e["code"], "title_en": _title(e)})
                        if len(found) >= per: break
            tag = "高速"
        else:
            found = _treewalk_chapter(ch_url, per, token); tag = "木探索"
        random.shuffle(found)
        picked.extend(found[:per])
        print(f" 適格{len(found)}件({tag}) → 累計{len(picked)}件", flush=True)
        if len(picked) >= n * 1.2:
            break
    random.shuffle(picked)
    return picked[:n]

def fetch_all_localized(entities, token):
    pairs = [(ent, lang) for ent in entities for lang in LANGUAGES]
    total, done, lock = len(pairs), [0], threading.Lock()
    loc_map = {}
    def fetch(p):
        ent, lang = p
        try:
            e = icd_get(ent["url"], token, lang); t, d = _title(e), _definition(e)
        except Exception:
            t, d = "", ""
        with lock:
            done[0] += 1
            print(f"\r  取得 {done[0]}/{total}", end="", flush=True)
        return (ent["url"], lang, t, d)
    with ThreadPoolExecutor(max_workers=ICD_WORKERS) as ex:
        for url, lang, t, d in ex.map(fetch, pairs):
            loc_map[(url, lang)] = {"title": t, "definition": d}
    print(flush=True)
    for ent in entities:
        ent["loc"] = {lang: loc_map[(ent["url"], lang)] for lang in LANGUAGES}
    return entities

def load_or_build_entities(token):
    if os.path.exists(ENTITIES_CACHE):
        try:
            data = json.load(open(ENTITIES_CACHE, encoding="utf-8"))
            if (data.get("release") == ICD_RELEASE and data.get("languages") == LANGUAGES
                    and data.get("n") == N_ENTITIES and data.get("seed") == SEED
                    and data.get("entities")):
                print(f"標本キャッシュを読込: {ENTITIES_CACHE}（{len(data['entities'])}件、再取得なし）", flush=True)
                return data["entities"]
            print("標本キャッシュが設定と不一致 → 作り直し", flush=True)
        except Exception:
            print("標本キャッシュ読込失敗 → 作り直し", flush=True)
    print(f"標本化中(目標 {N_ENTITIES}、外因/拡張章を除外・定義のある葉のみ、高速モード)...", flush=True)
    t0 = time.time()
    entities = sample_entities(token, N_ENTITIES, SEED)
    print(f"  標本 {len(entities)} 件  [標本化 {time.time()-t0:.0f}秒]", flush=True)
    print(f"多言語フィールド取得中 (全 {len(entities)*len(LANGUAGES)} 件, 並列{ICD_WORKERS})...", flush=True)
    t0 = time.time()
    entities = fetch_all_localized(entities, token)
    print(f"  [多言語取得 {time.time()-t0:.0f}秒]", flush=True)
    json.dump({"release": ICD_RELEASE, "languages": LANGUAGES, "n": N_ENTITIES,
               "seed": SEED, "entities": entities},
              open(ENTITIES_CACHE, "w", encoding="utf-8"), ensure_ascii=False)
    print(f"  標本を {ENTITIES_CACHE} に保存(公開用ベンチマーク資産)", flush=True)
    return entities

# ============================== LLM 呼び出し(REST) ==============================
def call_anthropic(model, prompt):
    r = requests.post("https://api.anthropic.com/v1/messages",
        headers={"x-api-key": ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01",
                 "content-type": "application/json"},
        json={"model": model, "max_tokens": 128, "temperature": TEMPERATURE,
              "messages": [{"role": "user", "content": prompt}]}, timeout=60)
    r.raise_for_status(); d = r.json()
    return d["content"][0]["text"], d.get("model", "")

def call_openai(model, prompt):
    r = requests.post("https://api.openai.com/v1/chat/completions",
        headers={"Authorization": "Bearer " + OPENAI_API_KEY, "content-type": "application/json"},
        json={"model": model, "temperature": TEMPERATURE,
              "messages": [{"role": "user", "content": prompt}]}, timeout=60)
    r.raise_for_status(); d = r.json()
    return d["choices"][0]["message"]["content"], d.get("model", "")

def call_google(model, prompt):
    r = requests.post(
        f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={GOOGLE_API_KEY}",
        headers={"content-type": "application/json"},
        json={"contents": [{"parts": [{"text": prompt}]}],
              "generationConfig": {"temperature": TEMPERATURE, "maxOutputTokens": 256,
                                   "thinkingConfig": {"thinkingBudget": 0}}}, timeout=60)
    r.raise_for_status(); d = r.json()
    cands = d.get("candidates", [])
    txt = ""
    if cands:
        parts = (cands[0].get("content") or {}).get("parts", [])
        txt = parts[0].get("text", "") if parts else ""
    return txt, d.get("modelVersion", "")

def call_llm(provider, model, prompt):
    if provider == "anthropic": return call_anthropic(model, prompt)
    if provider == "openai":    return call_openai(model, prompt)
    if provider == "google":    return call_google(model, prompt)
    raise ValueError("unknown provider: " + provider)

def llm_call_with_retry(provider, model, prompt):
    delay, last = 2.0, ""
    for _ in range(MAX_RETRIES + 1):
        try:
            txt, ver = call_llm(provider, model, prompt)
            return txt, ver, ""
        except requests.exceptions.HTTPError as e:
            code = getattr(e.response, "status_code", None)
            last = f"{code} {str(e)[:80]}"
            if code == 429 or (code and 500 <= code < 600):
                time.sleep(delay); delay *= 2; continue
            return "", "", last
        except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
            last = str(e)[:80]; time.sleep(delay); delay *= 2; continue
        except Exception as e:
            return "", "", str(e)[:110]
    return "", "", "retry尽きた: " + last

# ============================== パース・採点 ==============================
def normalize_code(c):
    if not c: return ""
    c = str(c).upper().strip().replace("ICD-11", "").replace("ICD11", "").replace("CODE", "")
    c = c.strip().strip('"').strip("'").strip().rstrip(".").strip()
    return c.split()[0] if c.split() else c

def parse_code(text):
    if not text: return ""
    try:
        m = re.search(r"\{.*?\}", text, re.S)
        if m:
            obj = json.loads(m.group(0))
            if obj.get("code"): return normalize_code(obj["code"])
    except Exception:
        pass
    m = CODE_RE.search(text.upper())
    return normalize_code(m.group(0)) if m else ""

def grade(pred, gold):
    p, g = normalize_code(pred), normalize_code(gold)
    if not p: return "none"
    if p == g: return "exact"
    ps, gs = p.split(".")[0], g.split(".")[0]
    if ps == gs: return "stem"
    if len(ps) >= 3 and len(gs) >= 3 and ps[:3] == gs[:3]: return "prefix3"
    if ps[:1] == gs[:1]: return "chapter"   # 同一ICD-11章
    return "miss"

# ============================== 一貫性 ==============================
def krippendorff_alpha(rows, model):
    try:
        import krippendorff
    except ImportError:
        return None
    by_ent, ids, nid = {}, {}, 0
    for r in rows:
        if r["model"] != model or r["rep"] != "0": continue
        code = normalize_code(r["pred_code"]) or None
        if code is not None and code not in ids:
            ids[code] = nid; nid += 1
        by_ent.setdefault(r["entity_url"], {})[r["lang"]] = code
    if not by_ent: return None
    data = [[ids[by_ent[u].get(lang)] if by_ent[u].get(lang) in ids
             and by_ent[u].get(lang) is not None else np.nan for u in by_ent]
            for lang in LANGUAGES]
    try:
        return krippendorff.alpha(reliability_data=np.array(data, dtype=float),
                                  level_of_measurement="nominal")
    except Exception:
        return None

def english_concordance(rows, model, level="exact"):
    by_ent = {}
    for r in rows:
        if r["model"] != model or r["rep"] != "0": continue
        by_ent.setdefault(r["entity_url"], {})[r["lang"]] = normalize_code(r["pred_code"])
    out = {}
    for lang in LANGUAGES:
        if lang == "en": continue
        m = t = 0
        for u, d in by_ent.items():
            en, ot = d.get("en", ""), d.get(lang, "")
            if en and ot:
                t += 1
                if level == "exact" and ot == en: m += 1
                elif level == "stem" and ot.split(".")[0] == en.split(".")[0]: m += 1
                elif level == "chapter" and ot[:1] == en[:1]: m += 1
        out[lang] = round(m / t, 3) if t else float("nan")
    return out

# ============================== レジューム入出力 ==============================
FIELDS = ["run_ts", "entity_url", "gold_code", "title_en", "lang", "provider",
          "model", "model_version", "tier", "rep", "pred_code", "grade",
          "raw_text", "error"]

def load_done_and_rows():
    done, rows = set(), []
    if os.path.exists(RESULTS_CSV):
        for r in csv.DictReader(open(RESULTS_CSV, encoding="utf-8")):
            rows.append(r)
            if not r.get("error"):
                done.add((r["entity_url"], r["lang"], r["model"], r["rep"]))
    return done, rows

def open_results_appender():
    new = not os.path.exists(RESULTS_CSV)
    f = open(RESULTS_CSV, "a", newline="", encoding="utf-8")
    w = csv.DictWriter(f, fieldnames=FIELDS)
    if new: w.writeheader()
    return f, w

# ============================== 問題検知: 実行前 ==============================
def preflight(models):
    print("\n--- 実行前チェック ---", flush=True)
    miss = []
    if not ICD_CLIENT_ID or not ICD_CLIENT_SECRET: miss.append("ICD_CLIENT_ID/SECRET")
    provs = {p for p, _ in models}
    if "openai" in provs and not OPENAI_API_KEY:       miss.append("OPENAI_API_KEY")
    if "google" in provs and not GOOGLE_API_KEY:       miss.append("GOOGLE_API_KEY")
    if "anthropic" in provs and not ANTHROPIC_API_KEY: miss.append("ANTHROPIC_API_KEY")
    if miss:
        print("⚠ 未設定のキー:", ", ".join(miss), "→ set で設定して再実行(引用符なし)", flush=True)
        return None
    ok, bad = [], []
    for (prov, model) in models:
        _, ver, err = llm_call_with_retry(prov, model, 'Return JSON {"code":"BA00"}')
        if err:
            print(f"  ⚠ {model}: 失敗 → {err}", flush=True); bad.append((prov, model))
        else:
            print(f"  ✓ {model}: OK  ({ver})", flush=True); ok.append((prov, model))
    if bad:
        print(f"\n⚠ {len(bad)}個のモデルが応答しません(キー/残高/モデルID/レート制限)。", flush=True)
        if not ok:
            print("動作モデル0個 → 中止。", flush=True); return None
        try:
            ans = input(f"動作する {len(ok)}モデルのみで続行? [y/N]: ").strip().lower()
        except Exception:
            ans = "y"
        if ans != "y":
            print("中止。", flush=True); return None
        return ok
    return ok

# ============================== メイン ==============================
def estimate_cost(rows):
    cost = 0.0
    cnt = defaultdict(int)
    for r in rows: cnt[r["model"]] += 1
    for model, n in cnt.items():
        pin, pout = PRICE.get(model, (0.0, 0.0))
        cost += n * (350 / 1e6 * pin + 30 / 1e6 * pout)
    return cost, cost * JPY_PER_USD

def main():
    t_start = time.time()
    print(f"=== 開始時刻: {RUN_TS} ===", flush=True)
    assert ICD_CLIENT_ID and ICD_CLIENT_SECRET, "ICD_CLIENT_ID / ICD_CLIENT_SECRET を設定してください"

    working = preflight(MODELS)
    if working is None:
        return

    print("\nICD-API トークン取得中 ...", flush=True)
    token = get_icd_token()

    entities = load_or_build_entities(token)
    if not entities:
        print("適格エンティティ0件。MIN_DEF_LEN を下げてください。", flush=True); return

    done, _ = load_done_and_rows()
    tasks = []
    for ent in entities:
        for lang in LANGUAGES:
            for (prov, model) in working:
                for rep in range(K_REPS):
                    if (ent["url"], lang, model, str(rep)) in done: continue
                    tasks.append((ent, lang, prov, model, rep))
    total_all = len(entities) * len(LANGUAGES) * len(working) * K_REPS
    print(f"\n残タスク {len(tasks)} / 全 {total_all}（成功済み {total_all-len(tasks)} スキップ）", flush=True)

    fout, writer = open_results_appender()
    counters = {"ok": 0, "err": 0, "empty": 0}
    consec = defaultdict(int); warned = set()
    t0 = time.time()

    def work(task):
        ent, lang, prov, model, rep = task
        loc = ent["loc"][lang]
        desc = (loc["definition"] or "").strip() or (loc["title"] or ent.get("title_en") or "").strip()
        instr = INSTRUCTION_BY_LANG.get(lang, INSTRUCTION_BY_LANG["en"])
        txt, ver, err = llm_call_with_retry(prov, model, instr.replace("{DESCRIPTION}", desc))
        pred = parse_code(txt) if not err else ""
        return task, txt, ver, pred, err

    if tasks:
        with ThreadPoolExecutor(max_workers=LLM_WORKERS) as ex:
            futs = {ex.submit(work, t): t for t in tasks}
            dn = 0
            for fut in as_completed(futs):
                task, txt, ver, pred, err = fut.result()
                ent, lang, prov, model, rep = task
                gold = str(ent["code"]).upper()
                writer.writerow({
                    "run_ts": RUN_TS, "entity_url": ent["url"], "gold_code": gold,
                    "title_en": ent.get("title_en", ""), "lang": lang, "provider": prov,
                    "model": model, "model_version": ver, "tier": TIER.get(model, ""),
                    "rep": rep, "pred_code": pred, "grade": grade(pred, gold),
                    "raw_text": (txt or "").replace("\n", " ")[:200], "error": err,
                })
                fout.flush(); dn += 1
                if err:    counters["err"] += 1;   consec[model] += 1
                elif pred: counters["ok"] += 1;    consec[model] = 0
                else:      counters["empty"] += 1; consec[model] = 0
                if err and consec[model] >= CONSEC_ERR_LIMIT and model not in warned:
                    print(f"\n⚠ {model} が {consec[model]}回連続失敗。キー/残高/モデルID/レート制限を確認。直近: {err}", flush=True)
                    warned.add(model)
                el = time.time() - t0
                rate = dn / el if el > 0 else 0
                eta = (len(tasks) - dn) / rate if rate > 0 else 0
                print(f"\r  {dn}/{len(tasks)} ({dn/len(tasks):.0%}) | 経過{el/60:.1f}分 "
                      f"| 残り推定{eta/60:.0f}分 | OK{counters['ok']} ERR{counters['err']} 空{counters['empty']}",
                      end="", flush=True)
        print(flush=True)
    fout.close()

    _, rows = load_done_and_rows()

    print("\n--- 健全性チェック ---", flush=True)
    problem = False
    for (_, model) in MODELS:
        sub = [r for r in rows if r["model"] == model]
        if not sub: continue
        nerr = sum(1 for r in sub if r["error"]); rr = 1 - nerr / len(sub)
        if rr == 0:
            print(f"  ⚠ {model}: 応答率0%（全{len(sub)}件失敗）→ 結果無効の可能性", flush=True); problem = True
        elif rr < 0.5:
            print(f"  ⚠ {model}: 応答率{rr:.0%}（{nerr}/{len(sub)}失敗）", flush=True); problem = True
    if not problem:
        print("  問題なし(全モデル応答良好)", flush=True)

    print("\n==================== サマリ ====================", flush=True)
    vers = sorted({(r["model"], r["model_version"]) for r in rows if r["model_version"]})
    print("測定したモデル版（陳腐化対策の記録）:", flush=True)
    for m, v in vers: print(f"  {m}  →  {v}", flush=True)
    print(f"測定日時: {RUN_TS}", flush=True)

    for (_, model) in MODELS:
        sub = [r for r in rows if r["model"] == model and r["rep"] == "0"]
        if not sub: continue
        errs = sum(1 for r in sub if r["error"])
        print(f"\n[{model} / tier={TIER.get(model,'')}]  (エラー {errs}/{len(sub)})", flush=True)
        for lang in LANGUAGES:
            ls = [r for r in sub if r["lang"] == lang]
            if not ls: continue
            ex  = sum(1 for r in ls if r["grade"] == "exact") / len(ls)
            st  = sum(1 for r in ls if r["grade"] in ("exact", "stem")) / len(ls)
            p3  = sum(1 for r in ls if r["grade"] in ("exact", "stem", "prefix3")) / len(ls)
            ch  = sum(1 for r in ls if r["grade"] in ("exact", "stem", "prefix3", "chapter")) / len(ls)
            print(f"  {lang}: 完全{ex:.1%} 幹{st:.1%} 3文字{p3:.1%} 章{ch:.1%} (n={len(ls)})", flush=True)
        a = krippendorff_alpha(rows, model)
        if a is not None: print(f"  言語間一貫性 Krippendorff α = {a:.3f}", flush=True)
        print("  英語一致(完全):", english_concordance(rows, model, "exact"), flush=True)
        print("  英語一致(章)  :", english_concordance(rows, model, "chapter"), flush=True)

    usd, jpy = estimate_cost(rows)
    el = time.time() - t_start
    print(f"\n概算コスト(累計{len(rows)}行): ≈ ${usd:.2f} ≈ ¥{jpy:,.0f}（★PRICE/レート要確認）", flush=True)
    print(f"総所要時間: {el:.0f}秒 ({el/60:.1f}分)   終了: {datetime.now().strftime('%H:%M:%S')}", flush=True)
    print("================================================", flush=True)

if __name__ == "__main__":
    main()
