====================================================================== bench/gen_v1.py ====================================================================== #!/usr/bin/env python3 """SHITATE-JP v1 の問題を生成し、独立2モデルで事実検証する。 方針(v0の反省: 上位2モデルが満点=易しすぎた): - 起案: Claude Fable 5。v0項目を「これより難しく」の基準として与える - 検証: GPT-5.5 Pro と Gemini 3.1 Pro が独立に「解答が事実として正しいか/設問が一意に定まるか」を判定 - 採用: 両者が valid と答えた項目のみ。落ちた項目は理由付きで rejected.json に残す(捨てない) 実行: python3 bench/gen_v1.py [目標数(既定300)] """ import concurrent.futures as cf import json import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from run_bench import chat # noqa: E402 ROOT = Path(__file__).resolve().parent.parent AUTHOR = ("openrouter", "anthropic/claude-fable-5") AUTHOR_FALLBACK = ("openrouter", "anthropic/claude-opus-5") # 起案が空/502のときの予備経路 # 検証は「非Anthropic系の独立2モデル」で行う(作問がFable 5のため、判定バイアスを避ける) VERIFIERS = [("openrouter", "openai/gpt-5.5-pro"), ("openrouter", "google/gemini-3.1-pro-preview")] CATEGORIES = { "敬語・待遇表現": ("Honorifics & register", 45, "ウチソト、二重敬語、謙譲語I/II、社内外での立て方、クッション表現、上司/顧客/取引先の三者関係。" "文法的には正しいが社会的に失礼になる例を優先する"), "商習慣": ("Business customs", 45, "締め支払いサイト、手形/でんさい、検収条件、下請法の支払期日、相見積、値引き交渉の型、" "見積の有効期限、掛売与信、印紙、請求書の締日調整。実際の計算を伴う設問を優先する"), "法務・契約": ("Legal & contracts", 45, "改正民法(契約不適合責任)、独禁法/下請法、秘密保持の期間と例外、知財の帰属、" "損害賠償の上限と除外、解除条項、準委任と請負の違い、フリーランス新法、労働者派遣。" "受託側/委託側どちらの立場で読むかが答えを変える設問を優先する"), "行政・制度": ("Government & systems", 40, "法人番号/インボイス、社会保険の資格取得、登記事項、電子帳簿保存法、" "36協定、雇用契約と業務委託の区分、補助金の交付決定と精算、マイナンバーの取扱区分"), "現場語彙": ("Field vocabulary", 40, "建設/製造/医療/物流/小売/IT運用の現場スラングと業界固有語。" "辞書的意味と現場語義が食い違う語を優先する"), "数字・日付": ("Numbers & dates", 40, "和暦と年度、期またぎ、営業日計算、億兆↔billion、消費税の端数処理、" "月末日の扱い、締日が休日の場合、うるう年、時差と納期"), "文書の型": ("Document conventions", 45, "稟議書、議事録、送付状、始末書、通知書、社内規程、契約書の前文と後文、" "メールの件名規則、社外文書の頭語結語、押印と電子署名の使い分け"), } AUTHOR_PROMPT = """あなたは日本語業務ベンチマーク SHITATE-JP v1 の作問者です。 # カテゴリ {cat}(観点: {hint}) # 目的 「日本語が流暢なAI」ではなく「日本の職場で働けるAI」を選別する。v0では最上位モデルが満点だったため、v1は明確に難化させる。 # 難易度の基準 - 単なる用語の暗記は不可。**判断・計算・立場の切替**を伴わせる - 実務者なら答えられるが、汎用AIは高確率で外す論点 # 一意性の絶対条件(独立2モデルの検証を通すために必須) 設問は「難しい」だけでなく「答えが1つに定まる」必要がある。次を必ず守ること: - 計算に必要な前提(資本金・締日・税率・端数処理・営業日/暦日の別・年度)は**すべて設問文に書く** - 軽減措置・特例・経過措置が影響する論点は、**適用の有無を設問文で明示**する - 「どちらの立場か(委託者/受託者、社内/社外)」を明示する - 時点依存の論点は基準日を書く(例: 2026年4月1日時点の制度で) - 模範解答は結論を先に一文で書き、根拠を添える。複数解釈の余地がある表現を避ける # 既出(重複禁止・これらより難しくすること) {existing} # 直前ラウンドで検証者に却下された理由(同じ轍を踏まないこと) {feedback} # 出力 以下のJSON配列のみを出力(説明文なし)。{n}件。 [{{"prompt":"設問(日本語)","prompt_en":"英訳","answer":"模範解答(採点基準として一意に判定できる形で)","answer_en":"英訳","why":"なぜモデルが落ちるか(60字以内)","why_en":"英訳"}}]""" VERIFY_PROMPT = """日本の実務に精通した専門家として、以下のベンチマーク項目を検証してください。 設問: {prompt} 模範解答: {answer} 次の3点すべてを満たすか判定: 1. 模範解答が事実として正しい(法令・制度・商習慣の観点で誤りがない) 2. 設問の答えが一意に定まる(複数の妥当な解釈で答えが割れない) 3. 設問と模範解答が対応している JSONのみ出力: {{"valid": true|false, "reason": "30字以内。falseなら何が誤り/曖昧か"}}""" def parse_json_array(raw: str) -> list: m = re.search(r'\[.*\]', raw, re.S) if not m: return [] try: return json.loads(m.group(0)) except json.JSONDecodeError: return [] def parse_json_obj(raw: str) -> dict: m = re.search(r'\{.*\}', raw, re.S) try: return json.loads(m.group(0)) if m else {} except json.JSONDecodeError: return {} def author_batch(cat: str, hint: str, existing: list, n: int, feedback: list | None = None) -> list: prompt = AUTHOR_PROMPT.format(cat=cat, hint=hint, n=n, existing="\n".join(f"- {e}" for e in existing[-25:]) or "(なし)", feedback="\n".join(f"- {f}" for f in (feedback or [])[-10:]) or "(なし)") last = None for model in (AUTHOR, AUTHOR_FALLBACK): try: items = parse_json_array(chat(model, "作問者。JSON配列のみ出力。", prompt, max_tokens=32000)) if items: return items last = "空応答" except Exception as e: # noqa: BLE001 last = e raise RuntimeError(f"起案不能: {last}") REPAIR_PROMPT = """次のベンチマーク設問は、検証者に却下されました。**設問の狙い(測ろうとしている論点)は変えずに**、却下理由を潰した改訂版を作ってください。 # 却下された設問 {prompt} # 模範解答 {answer} # 却下理由 {reason} # 改訂の指針 - 不足している前提(金額・締日・税率・端数処理・営業日/暦日・年度・基準日・立場・特例の適用有無など)を設問文に**明記**して、答えが一意に定まるようにする - 論点を易しくしない。前提を足すことで曖昧さだけを消す - 模範解答も、結論が一文で判定できる形に整える # 出力 JSONオブジェクトのみ: {{"prompt":"改訂設問","prompt_en":"英訳","answer":"改訂模範解答","answer_en":"英訳","why":"なぜモデルが落ちるか(60字以内)","why_en":"英訳"}}""" def repair(item: dict, reason: str) -> dict | None: """却下された設問を、狙いを保ったまま一意になるよう改訂する。""" for model in (AUTHOR, AUTHOR_FALLBACK): try: r = parse_json_obj(chat(model, "作問者。JSONオブジェクトのみ出力。", REPAIR_PROMPT.format(prompt=item["prompt"], answer=item["answer"], reason=reason), max_tokens=16000)) if r.get("prompt") and r.get("answer"): return {**item, **r} except Exception: # noqa: BLE001 continue return None def verify(item: dict) -> tuple[bool, list]: """独立2モデルで検証。判定不能(空応答)は2回まで再試行し、それでも駄目なら却下側に倒す。""" reasons = [] for v in VERIFIERS: r = {} for _ in range(2): try: # reasoningモデルは思考でトークンを消費するため枠を厚めに取る r = parse_json_obj(chat(v, "検証者。JSONのみ。", VERIFY_PROMPT.format(prompt=item["prompt"], answer=item["answer"]), max_tokens=2000)) except Exception as e: # noqa: BLE001 r = {"valid": False, "reason": f"検証API失敗: {e}"} if "valid" in r: break if "valid" not in r: r = {"valid": False, "reason": "検証者が判定不能"} reasons.append({"verifier": v[1], **r}) if not r.get("valid"): return False, reasons return True, reasons def gen_category(cat: str, cat_en: str, quota: int, hint: str, v0: list) -> tuple[list, list]: """1カテゴリ分を生成。ラウンド内は並列検証、カテゴリ間はmain側で並列化する。""" existing = [i["prompt"] for i in v0 if i["category"] == cat] got, rejected, feedback = [], [], [] # 採用分は逐次ディスクに残す。API障害で落ちても積み上げを失わない ckpt = ROOT / "bench" / "accepted" / f"{cat}.jsonl" ckpt.parent.mkdir(parents=True, exist_ok=True) if ckpt.exists(): got = [json.loads(l) for l in ckpt.read_text().splitlines() if l.strip()][:quota] print(f" {cat}: チェックポイント復帰 {len(got)}問", flush=True) for round_no in range(14): if len(got) >= quota: break need = min(6, quota - len(got)) try: drafts = author_batch(cat, hint, existing + [g["prompt"] for g in got], need, feedback) except Exception as e: # noqa: BLE001 print(f" 起案失敗({cat} r{round_no}): {e}", flush=True) continue with cf.ThreadPoolExecutor(max_workers=8) as ex: verdicts = list(ex.map(verify, drafts)) # 1回目で落ちた設問は捨てずに「狙いを保ったまま前提を足す」修復を1度だけ試す failed = [(d, r) for d, (ok, r) in zip(drafts, verdicts) if not ok] repaired = [] if failed: with cf.ThreadPoolExecutor(max_workers=8) as ex: fixes = list(ex.map(lambda p: repair(p[0], p[1][-1].get("reason", "")), failed)) with cf.ThreadPoolExecutor(max_workers=8) as ex: revalid = list(ex.map(lambda f: verify(f) if f else (False, []), [f for f in fixes])) for (orig, r0), fixed, (ok2, r2) in zip(failed, fixes, revalid): if fixed and ok2: repaired.append(fixed) else: rejected.append({**orig, "reasons": r0, "repair_failed": True, "repair_reasons": r2}) if r0 and r0[-1].get("reason"): feedback.append(r0[-1]["reason"]) with ckpt.open("a") as fh: for d in [d for d, (ok, _) in zip(drafts, verdicts) if ok] + repaired: d.update(category=cat, category_en=cat_en) if len(got) < quota: got.append(d) fh.write(json.dumps(d, ensure_ascii=False) + "\n") print(f" {cat}: {len(got)}/{quota} (round {round_no + 1}, 却下 {len(rejected)}, 修復{len(repaired)})", flush=True) return got, rejected def main() -> None: target = int(sys.argv[1]) if len(sys.argv) > 1 else 300 v0 = json.loads((ROOT / "bench.json").read_text())["items"] accepted, rejected = [], [] with cf.ThreadPoolExecutor(max_workers=len(CATEGORIES)) as ex: futs = {ex.submit(gen_category, cat, en, round(q * target / 300), hint, v0): cat for cat, (en, q, hint) in CATEGORIES.items()} for f in cf.as_completed(futs): got, rej = f.result() accepted.extend(got) rejected.extend(rej) print(f"✔ {futs[f]} 完了: 採用{len(got)} / 累計{len(accepted)}", flush=True) for n, it in enumerate(accepted, 1): cat_key = {c: c for c in CATEGORIES}[it["category"]] it["id"] = f"v1-{list(CATEGORIES).index(cat_key) + 1:d}-{n:03d}" out = { "version": "v1", "updated": __import__("datetime").date.today().isoformat(), "note": (f"SHITATE-JP v1: {len(accepted)}問。起案=Claude Fable 5 / " f"検証=GPT-5.5 Pro+Gemini 3.1 Proの独立2モデル(両者合格のみ採用・却下{len(rejected)}件)。" "項目・採点基準・検証ログは全公開。"), "items": accepted, } (ROOT / "bench-v1.json").write_text(json.dumps(out, ensure_ascii=False, indent=1)) (ROOT / "bench" / "rejected-v1.json").write_text(json.dumps(rejected, ensure_ascii=False, indent=1)) print(f"\n採用 {len(accepted)}問 / 却下 {len(rejected)}件 → bench-v1.json") if __name__ == "__main__": main() ====================================================================== bench/run_bench.py ====================================================================== #!/usr/bin/env python3 """SHITATE-JP v0 実測ハーネス。 - 全モデルを teai.io ゲートウェイ(OpenAI互換 /v1/chat/completions)経由で測定 - 判定: claude-opus-5 (LLM-judge・判定プロンプトは本ファイルで全公開) - 生ログ: bench/results/raw-.json / サイト公開用: scoreboard.json - 誇張禁止: n=13 のプレビュー実測。判定モデルが Anthropic 系である旨は必ず併記する """ import concurrent.futures as cf import json import re import sys import time import urllib.request from datetime import date from pathlib import Path ROOT = Path(__file__).resolve().parent.parent CRED = Path.home() / ".config/teai/credentials" ENVF = Path.home() / ".claud.env" PROVIDERS = { # provider: (chat/completions URL, APIキーの取り方) "teai": ("https://api.teai.io/v1/chat/completions", "teai"), "groq": ("https://api.groq.com/openai/v1/chat/completions", "GROQ_API_KEY"), "openai": ("https://api.openai.com/v1/chat/completions", "OPENAI_API_KEY"), "openrouter": ("https://openrouter.ai/api/v1/chat/completions", "openrouter"), } ORCRED = Path.home() / ".config/openrouter/credentials" MODELS = { # scoreboard表示名: (provider, model id) "Claude Fable 5": ("openrouter", "anthropic/claude-fable-5"), "GPT-5.5 Pro": ("openrouter", "openai/gpt-5.5-pro"), "Gemini 3.1 Pro": ("openrouter", "google/gemini-3.1-pro-preview"), "Grok 4.5": ("openrouter", "x-ai/grok-4.5"), "Kimi K3": ("openrouter", "moonshotai/kimi-k3"), "Qwen3.7 Max": ("openrouter", "qwen/qwen3.7-max"), # 国産(Sakana AI)と、Thinking Machines Lab 本体のオープンウェイトモデル。 # 日本語ベンチに国産が1本も載っていないのは説得力を欠くため v1 から追加。 "Sakana Fugu Ultra": ("openrouter", "sakana/fugu-ultra"), "TML Inkling": ("openrouter", "thinkingmachines/inkling"), # 日本語トラフィック実測で首位(27.4%)のモデルが v1 に無いのは穴なので追加。 # 併せて中国主要勢と、廉価帯の西側モデルも同条件で測る。 "DeepSeek V4 Flash": ("openrouter", "deepseek/deepseek-v4-flash"), "DeepSeek V4 Pro": ("openrouter", "deepseek/deepseek-v4-pro"), "GLM-5": ("openrouter", "z-ai/glm-5"), "MiniMax M3": ("openrouter", "minimax/minimax-m3"), "Hunyuan HY3": ("openrouter", "tencent/hy3"), "Claude Sonnet 5": ("openrouter", "anthropic/claude-sonnet-5"), } JUDGE = ("teai", "anthropic/claude-opus-5") ANSWER_SYSTEM = ( "あなたは日本企業で働くアシスタントです。以下の設問に日本語で簡潔に答えてください。" "根拠は1〜2文で十分です。" ) JUDGE_PROMPT = """あなたは日本語業務ベンチマーク SHITATE-JP の採点者です。以下の設問・模範解答・受験モデルの回答を読み、採点してください。 # 設問 {prompt} # 模範解答(採点基準) {answer} # 受験モデルの回答 {response} # 採点基準 - correct: 模範解答の核心(事実・数値・判断)と一致。表現の違いは許容 - partial: 核心の一部のみ正しい/重要な要素の欠落や余計な誤りを含む - incorrect: 核心が誤り・無回答・設問の取り違え JSONのみで出力: {{"verdict": "correct|partial|incorrect", "reason": "20字程度"}}""" def _envfile(path: Path) -> dict: env = {} if path.exists(): for line in path.read_text().splitlines(): if "=" in line and not line.strip().startswith("#"): k, _, v = line.partition("=") env[k.strip().replace("export ", "")] = v.strip().strip('"') return env def token(provider: str = "teai") -> str: if provider == "teai": env = _envfile(CRED) return env.get("TEAI_API_KEY") or env.get("ANTHROPIC_AUTH_TOKEN") or "" if provider == "openrouter": return _envfile(ORCRED).get("OPENROUTER_API_KEY", "") return _envfile(ENVF).get(PROVIDERS[provider][1], "") def chat(target, system: str, user: str, max_tokens: int = 700, retries: int = 3) -> str: provider, model = target if isinstance(target, tuple) else ("teai", target) base = PROVIDERS[provider][0] def build(budget: int) -> bytes: return json.dumps({ "model": model, "max_tokens": budget, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], }).encode() last = None for i in range(retries): # thinking で予算を使い切って空応答になる場合があるので、リトライごとに倍増させる payload = build(max_tokens * (2 ** i)) try: req = urllib.request.Request(base, data=payload, headers={ "Authorization": f"Bearer {token(provider)}", "Content-Type": "application/json", "User-Agent": "shitate-bench/0.1 (curl-compatible)", }) with urllib.request.urlopen(req, timeout=180) as r: d = json.loads(r.read()) ch = d["choices"][0] content = (ch["message"].get("content") or "").strip() if not content: # 推論モデルは thinking で max_tokens を使い切ると content が空のまま 200 を返す。 # 黙って "" を返すと採点側が「不正解」と数えてしまうので、必ずエラーとして扱う。 raise RuntimeError( f"空応答 (finish_reason={ch.get('finish_reason')}, " f"max_tokens={max_tokens} が thinking で尽きた可能性)") return content except Exception as e: # noqa: BLE001 - リトライして最後に報告 last = e time.sleep(3 * (i + 1)) raise RuntimeError(f"{model}: {last}") def parse_verdict(raw: str) -> dict: m = re.search(r'\{.*\}', raw, re.S) try: v = json.loads(m.group(0)) if m else {} except Exception: # noqa: BLE001 v = {} if v.get("verdict") not in ("correct", "partial", "incorrect"): v = {"verdict": "incorrect", "reason": f"judge出力不正: {raw[:40]}"} return v def judge(item: dict, response: str) -> dict: return parse_verdict(chat(JUDGE, "採点者として厳密に。JSONのみ出力。", JUDGE_PROMPT.format(prompt=item["prompt"], answer=item["answer"], response=response), max_tokens=1500)) def main() -> None: bench = json.loads((ROOT / "bench.json").read_text()) items = bench["items"] today = date.today().isoformat() results = [] def run_one(display: str, target: tuple, item: dict) -> dict: t0 = time.time() try: resp = chat(target, ANSWER_SYSTEM, item["prompt"]) verdict = judge(item, resp) except Exception as e: # noqa: BLE001 resp, verdict = f"[ERROR] {e}", {"verdict": "incorrect", "reason": "API失敗"} return {"model": display, "model_id": f"{target[0]}:{target[1]}", "item": item["id"], "category": item["category"], "response": resp, "verdict": verdict["verdict"], "judge_reason": verdict.get("reason", ""), "latency_s": round(time.time() - t0, 1)} jobs = [(d, mid, it) for d, mid in MODELS.items() for it in items] with cf.ThreadPoolExecutor(max_workers=6) as ex: futs = {ex.submit(run_one, *j): j for j in jobs} for n, f in enumerate(cf.as_completed(futs), 1): r = f.result() results.append(r) print(f"[{n}/{len(jobs)}] {r['model']} × {r['item']} → {r['verdict']}", flush=True) outdir = ROOT / "bench" / "results" outdir.mkdir(parents=True, exist_ok=True) (outdir / f"raw-{today}.json").write_text( json.dumps(results, ensure_ascii=False, indent=1)) score = {} for d in MODELS: rs = [r for r in results if r["model"] == d] pts = sum({"correct": 1.0, "partial": 0.5, "incorrect": 0.0}[r["verdict"]] for r in rs) score[d] = { "score": round(pts / len(items) * 100, 1), "correct": sum(r["verdict"] == "correct" for r in rs), "partial": sum(r["verdict"] == "partial" for r in rs), "incorrect": sum(r["verdict"] == "incorrect" for r in rs), "hardest": [r["item"] for r in rs if r["verdict"] == "incorrect"], } board = { "version": "v0-preview", "date": today, "n_items": len(items), "judge": "claude-opus-5 (LLM-judge・判定プロンプトはリポジトリで公開)", "caveats": [ f"n={len(items)}のプレビュー実測。統計的有意性はv1(300問)で担保予定", "判定モデルはAnthropic系(Claude Opus 5)。受験側にAnthropicモデルを含むためバイアスの可能性あり。v1で複数社判定を併用予定", "同一プロンプトで測定。測定経路は生ログのmodel_idに記載(teai.io/OpenRouter)", ], "scores": dict(sorted(score.items(), key=lambda kv: -kv[1]["score"])), } (ROOT / "scoreboard.json").write_text(json.dumps(board, ensure_ascii=False, indent=1)) print(json.dumps(board["scores"], ensure_ascii=False, indent=1)) if __name__ == "__main__": sys.exit(main()) ====================================================================== bench/run_v1.py ====================================================================== #!/usr/bin/env python3 """SHITATE-JP v1 実測ランナー。 v0からの変更: - 問題数 13 → 300 - 判定者を複層化: 主判定=Claude Opus 5、層化20%サンプルを GPT-5.5 Pro でも判定し **判定者間一致率(Cohen's kappa相当の素の一致率)を公開**する。判定の信頼性を数字で開示するのが目的 - 途中経過をチェックポイントに保存し、中断しても再開できる(APIエラーを黙って0点にしない) 実行: python3 bench/run_v1.py """ import concurrent.futures as cf import json import random import sys import time from datetime import date from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from run_bench import ANSWER_SYSTEM, JUDGE_PROMPT, MODELS, chat, parse_verdict # noqa: E402 ROOT = Path(__file__).resolve().parent.parent CKPT = ROOT / "bench" / "results" / "v1-checkpoint.jsonl" # 主判定は安価で有能なモデルに(自社実測: DeepSeek V4 Pro 88.5点・$0.435/$0.87 = Opus 5の約1/10のコスト) JUDGE_MAIN = ("openrouter", "deepseek/deepseek-v4-pro") # 交差判定だけ高級モデルを残し、一致率で主判定の妥当性を担保する(20%のみ = コストは1/5) JUDGE_CROSS = ("openrouter", "anthropic/claude-opus-5") CROSS_RATE = 0.2 # 層化20%を二重判定 WORKERS = 24 # スコアだけでは誤読される情報を明示する。特に Fugu は単一モデルではないため、 # 「国産モデルが1位」と読ませないことが公開側の責任になる。 MODEL_NOTES = { "Sakana Fugu Ultra": { "origin": "🇯🇵 日本 (Sakana AI)", "kind": "オーケストレーション", "note": "単一モデルではなく、複数のフロンティアモデルへ動的にルーティングする" "学習済みオーケストレーション系(Sakana AI 公式説明)。" "内部で使われるモデルは非公開のため、単体モデルとは同列に比較できない。"}, "TML Inkling": { "origin": "🌏 米国 (Thinking Machines Lab)", "kind": "オープンウェイト", "note": "総975B / 活性41B のMoE。当ラボが「日本版」を掲げている当の企業のモデル。"}, "Claude Fable 5": {"origin": "🌏 米国 (Anthropic)", "kind": "単体", "note": "本ベンチの作問モデル。被評価者と作問者が同一である点は中立ではない。"}, "GPT-5.5 Pro": {"origin": "🌏 米国 (OpenAI)", "kind": "単体", "note": "本ベンチの事実検証モデルの1つ。"}, "Gemini 3.1 Pro": {"origin": "🌏 米国 (Google)", "kind": "単体", "note": "本ベンチの事実検証モデルの1つ。"}, "Grok 4.5": {"origin": "🌏 米国 (xAI)", "kind": "単体", "note": ""}, "Kimi K3": {"origin": "🇨🇳 中国 (Moonshot AI)", "kind": "オープンウェイト", "note": "総2.78T / 活性104.2B。技術報告書に日本語向け最適化の記載はない。"}, "Qwen3.7 Max": {"origin": "🇨🇳 中国 (Alibaba)", "kind": "単体", "note": ""}, "DeepSeek V4 Flash": { "origin": "🇨🇳 中国 (DeepSeek)", "kind": "オープンウェイト", "note": "OpenRouter実測で日本語トークンの27.4%を占める最頻用モデル。" "「日本語で最も使われているAI」の実力がここに出る。"}, "DeepSeek V4 Pro": {"origin": "🇨🇳 中国 (DeepSeek)", "kind": "オープンウェイト", "note": "本ベンチの主判定モデル。被評価者と判定者を兼ねている。"}, "GLM-5": {"origin": "🇨🇳 中国 (Zhipu AI)", "kind": "オープンウェイト", "note": ""}, "MiniMax M3": {"origin": "🇨🇳 中国 (MiniMax)", "kind": "オープンウェイト", "note": ""}, "Hunyuan HY3": {"origin": "🇨🇳 中国 (Tencent)", "kind": "オープンウェイト", "note": "解答能力は高いが採点者としては誤判定が多く、判定者候補から外した実測がある。"}, "Claude Sonnet 5": {"origin": "🌏 米国 (Anthropic)", "kind": "単体", "note": "上位機との価格差が日本語業務でどこに出るかを見るための廉価帯対照。"}, } def judge_with(model, item: dict, response: str) -> dict: raw = chat(model, "採点者として厳密に。JSONのみ出力。", JUDGE_PROMPT.format(prompt=item["prompt"], answer=item["answer"], response=response), max_tokens=1500) return parse_verdict(raw) def main() -> None: bench = json.loads((ROOT / "bench-v1.json").read_text()) items = {i["id"]: i for i in bench["items"]} today = date.today().isoformat() done = set() results = [] if CKPT.exists(): for line in CKPT.read_text().splitlines(): if line.strip(): r = json.loads(line) if not r["response"].startswith("[ERROR]"): results.append(r) done.add((r["model"], r["item"])) print(f"チェックポイントから復帰: {len(results)}件", flush=True) rng = random.Random(20260730) cross_ids = {} for cat in {i["category"] for i in bench["items"]}: ids = sorted(i["id"] for i in bench["items"] if i["category"] == cat) cross_ids[cat] = set(rng.sample(ids, max(1, round(len(ids) * CROSS_RATE)))) jobs = [(d, t, iid) for d, t in MODELS.items() for iid in items if (d, iid) not in done] print(f"残ジョブ: {len(jobs)}", flush=True) def run_one(display: str, target: tuple, iid: str) -> dict: it = items[iid] t0 = time.time() try: # 推論モデル(GPT-5.5 Pro / Kimi K3 等)は thinking を max_tokens から消費する。 # 1500 では思考だけで尽きて空応答になったため、思考分を上乗せする。 resp = chat(target, ANSWER_SYSTEM, it["prompt"], max_tokens=8000) v = judge_with(JUDGE_MAIN, it, resp) except Exception as e: # noqa: BLE001 return {"model": display, "model_id": f"{target[0]}:{target[1]}", "item": iid, "category": it["category"], "response": f"[ERROR] {e}", "verdict": "incorrect", "judge_reason": "API失敗", "latency_s": round(time.time() - t0, 1)} rec = {"model": display, "model_id": f"{target[0]}:{target[1]}", "item": iid, "category": it["category"], "response": resp, "verdict": v["verdict"], "judge_reason": v.get("reason", ""), "latency_s": round(time.time() - t0, 1)} if iid in cross_ids.get(it["category"], set()): try: rec["verdict_cross"] = judge_with(JUDGE_CROSS, it, resp)["verdict"] except Exception: # noqa: BLE001 pass return rec CKPT.parent.mkdir(parents=True, exist_ok=True) with CKPT.open("a") as ck, cf.ThreadPoolExecutor(max_workers=WORKERS) as ex: futs = [ex.submit(run_one, *j) for j in jobs] for n, f in enumerate(cf.as_completed(futs), 1): r = f.result() results.append(r) ck.write(json.dumps(r, ensure_ascii=False) + "\n") ck.flush() if n % 25 == 0 or n == len(futs): errs = sum(x["response"].startswith("[ERROR]") for x in results) print(f"[{n}/{len(futs)}] err={errs}", flush=True) errs = [r for r in results if r["response"].startswith("[ERROR]")] print(f"APIエラー: {len(errs)}件(再実行で自動リトライされます)", flush=True) if errs: print("→ エラーが残っているため集計しません。もう一度このスクリプトを実行してください。") return n_items = len(items) score = {} for d in MODELS: rs = [r for r in results if r["model"] == d] pts = sum({"correct": 1.0, "partial": 0.5, "incorrect": 0.0}[r["verdict"]] for r in rs) by_cat = {} for cat in sorted({r["category"] for r in rs}): crs = [r for r in rs if r["category"] == cat] cpts = sum({"correct": 1.0, "partial": 0.5, "incorrect": 0.0}[r["verdict"]] for r in crs) by_cat[cat] = round(cpts / len(crs) * 100, 1) score[d] = {"score": round(pts / n_items * 100, 1), **MODEL_NOTES.get(d, {}), "correct": sum(r["verdict"] == "correct" for r in rs), "partial": sum(r["verdict"] == "partial" for r in rs), "incorrect": sum(r["verdict"] == "incorrect" for r in rs), "by_category": by_cat, "weakest": min(by_cat, key=by_cat.get) if by_cat else None} crossed = [r for r in results if "verdict_cross" in r] agree = sum(r["verdict"] == r["verdict_cross"] for r in crossed) agreement = round(agree / len(crossed) * 100, 1) if crossed else None board = { "version": "v1", "date": today, "n_items": n_items, "judge": "DeepSeek V4 Pro (主判定) / Claude Opus 5 (層化20%で二重判定)", "judge_agreement_pct": agreement, "judge_cross_n": len(crossed), "caveats": [ f"n={n_items}。問題は Claude Fable 5 が起案し、GPT-5.5 Pro と Gemini 3.1 Pro の独立2モデルが" "事実検証して両者合格したもののみ採用", f"主判定はDeepSeek V4 Pro(安価だが自社実測88.5点)。判定の妥当性を開示するため層化{int(CROSS_RATE * 100)}%" f"({len(crossed)}件)をClaude Opus 5でも判定し、一致率{agreement}%", "作問はClaude Fable 5、主判定はDeepSeek V4 Pro、交差判定はClaude Opus 5。系列を分散させたがバイアスを完全に排除できたとは主張しない。一致率で判断材料を提示する", "同一プロンプトで測定。全モデルの生回答・判定理由は生ログで公開", ], "scores": dict(sorted(score.items(), key=lambda kv: -kv[1]["score"])), } (ROOT / "scoreboard.json").write_text(json.dumps(board, ensure_ascii=False, indent=1)) (ROOT / "bench-raw-v1.json").write_text(json.dumps(results, ensure_ascii=False, indent=1)) for m, s in board["scores"].items(): print(f"{s['score']:5.1f} {m} (○{s['correct']} △{s['partial']} ×{s['incorrect']}) 最弱={s['weakest']}") print(f"判定者間一致率: {agreement}% (n={len(crossed)})") if __name__ == "__main__": main() ====================================================================== bench/run_china.py ====================================================================== #!/usr/bin/env python3 """中国発モデルを SHITATE-JP v0 と同一条件で実測する(西側モデルと直接比較するため)。 既公開の v0 スコアボードと同じ13問・同じ判定プロンプト・同じ判定モデル(Opus 5)を使う。 比較対象として西側の代表も同じ表に載せる(すでに測ってあるものは v0 の生ログから流用)。 実行: python3 bench/run_china.py """ import concurrent.futures as cf import glob import json import sys import time from datetime import date from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from run_bench import ANSWER_SYSTEM, JUDGE_PROMPT, chat, parse_verdict # noqa: E402 ROOT = Path(__file__).resolve().parent.parent CKPT = ROOT / "bench" / "results" / "china-checkpoint.jsonl" JUDGE = ("openrouter", "anthropic/claude-opus-5") WORKERS = 16 # 中国発モデル(OpenRouterで実在確認済み・2026-07-30) CHINA = { "DeepSeek V4 Pro": "deepseek/deepseek-v4-pro", "DeepSeek V4 Flash": "deepseek/deepseek-v4-flash", "Kimi K3": "moonshotai/kimi-k3", "Kimi K2.7 Code": "moonshotai/kimi-k2.7-code", "MiniMax M3": "minimax/minimax-m3", "MiniMax M2.7": "minimax/minimax-m2.7", "GLM 5.2": "z-ai/glm-5.2", "GLM 5.1": "z-ai/glm-5.1", "Qwen3.7 Max": "qwen/qwen3.7-max", "Qwen3.7 Plus": "qwen/qwen3.7-plus", "Hunyuan HY3": "tencent/hy3", "Ling 2.6 1T": "inclusionai/ling-2.6-1t", "Step 3.7 Flash": "stepfun/step-3.7-flash", "ERNIE 4.5 VL": "baidu/ernie-4.5-vl-424b-a47b", } # 西側の比較基準(同じ13問で測る) WEST = { "Claude Fable 5": "anthropic/claude-fable-5", "GPT-5.5 Pro": "openai/gpt-5.5-pro", "Gemini 3.1 Pro": "google/gemini-3.1-pro-preview", } ORIGIN = {**{k: "🇨🇳 中国" for k in CHINA}, **{k: "🌏 西側" for k in WEST}} ALL = {**CHINA, **WEST} def main() -> None: items = json.loads((ROOT / "bench.json").read_text())["items"] done, results = set(), [] if CKPT.exists(): for line in CKPT.read_text().splitlines(): if line.strip(): r = json.loads(line) if not r["response"].startswith("[ERROR]"): results.append(r) done.add((r["model"], r["item"])) print(f"復帰: {len(results)}件", flush=True) jobs = [(d, mid, it) for d, mid in ALL.items() for it in items if (d, it["id"]) not in done] print(f"残ジョブ: {len(jobs)} ({len(ALL)}モデル×{len(items)}問)", flush=True) def run_one(display: str, mid: str, it: dict) -> dict: t0 = time.time() try: resp = chat(("openrouter", mid), ANSWER_SYSTEM, it["prompt"], max_tokens=1500) v = parse_verdict(chat(JUDGE, "採点者として厳密に。JSONのみ出力。", JUDGE_PROMPT.format(prompt=it["prompt"], answer=it["answer"], response=resp), max_tokens=1500)) except Exception as e: # noqa: BLE001 resp, v = f"[ERROR] {e}", {"verdict": "incorrect", "reason": "API失敗"} return {"model": display, "model_id": mid, "origin": ORIGIN[display], "item": it["id"], "category": it["category"], "response": resp, "verdict": v["verdict"], "judge_reason": v.get("reason", ""), "latency_s": round(time.time() - t0, 1)} CKPT.parent.mkdir(parents=True, exist_ok=True) with CKPT.open("a") as ck, cf.ThreadPoolExecutor(max_workers=WORKERS) as ex: futs = [ex.submit(run_one, *j) for j in jobs] for n, f in enumerate(cf.as_completed(futs), 1): r = f.result() results.append(r) ck.write(json.dumps(r, ensure_ascii=False) + "\n") ck.flush() if n % 20 == 0 or n == len(futs): print(f"[{n}/{len(futs)}] err={sum(x['response'].startswith('[ERROR]') for x in results)}", flush=True) errs = [r for r in results if r["response"].startswith("[ERROR]")] print(f"APIエラー: {len(errs)}件", flush=True) ok_models = {} for d in ALL: rs = [r for r in results if r["model"] == d and not r["response"].startswith("[ERROR]")] if len(rs) < len(items): print(f" ※ {d}: {len(rs)}/{len(items)}件のみ → 表から除外(不完全な測定は載せない)") continue pts = sum({"correct": 1.0, "partial": 0.5, "incorrect": 0.0}[r["verdict"]] for r in rs) by_cat = {} for cat in sorted({r["category"] for r in rs}): crs = [r for r in rs if r["category"] == cat] by_cat[cat] = round(sum({"correct": 1.0, "partial": 0.5, "incorrect": 0.0}[r["verdict"]] for r in crs) / len(crs) * 100, 1) ok_models[d] = { "origin": ORIGIN[d], "model_id": ALL[d], "score": round(pts / len(items) * 100, 1), "correct": sum(r["verdict"] == "correct" for r in rs), "partial": sum(r["verdict"] == "partial" for r in rs), "incorrect": sum(r["verdict"] == "incorrect" for r in rs), "by_category": by_cat, "weakest": min(by_cat, key=by_cat.get) if by_cat else None, "median_latency_s": sorted(r["latency_s"] for r in rs)[len(rs) // 2], } out = { "version": "china-panel-v0", "date": date.today().isoformat(), "n_items": len(items), "judge": "claude-opus-5", "caveats": [ f"SHITATE-JP v0({len(items)}問)と同一の設問・判定プロンプト・判定モデルで測定した、" "中国発モデルと西側モデルの横並び比較", "n=13のプレビュー規模。順位は誤差を含む。v1(300問)で再測定予定", "測定は全モデルOpenRouter経由。判定はClaude Opus 5(Anthropic系)であり、その方向のバイアスは排除できない", "全問完走しなかったモデルは表から除外している(不完全な測定は載せない)", ], "scores": dict(sorted(ok_models.items(), key=lambda kv: -kv[1]["score"])), } (ROOT / "china-panel.json").write_text(json.dumps(out, ensure_ascii=False, indent=1)) (ROOT / "bench-raw-china.json").write_text(json.dumps(results, ensure_ascii=False, indent=1)) for m, s in out["scores"].items(): print(f"{s['score']:5.1f} {s['origin']} {m:20s} 最弱={s['weakest']} 中央値{s['median_latency_s']}s") if __name__ == "__main__": main()