"""統合 eval set のビルダー。 2つのソースを1つの統一スキーマにまとめる: 1. data/eval/questions.json — 手作りsilver 20問(単一記事・記事内セクション統合型) 2. data/eval/forum_review.json — フォーラム由来。review_status=="reviewed" かつ answerable_standalone==True のものだけ採用 統一スキーマ(1問1dict): id : 一意なID(old_NN / forum_) question : 質問文 ground_truth : 参照回答 expected_source: list[str] ← 常にlist。正解記事のslug(拡張子なし) match_type : "single" | "and" | "or" single = 正解が1記事(旧セット) and = top-kに全記事が揃って初めて正解(比較・統合質問) or = 候補のどれか1つが取れれば正解 difficulty : easy/medium/hard source : "silver_manual" | "forum" reviewed : bool ← 正解ラベルが人手検証済みか。False=LLM機械推定のまま(未検証)。 スコア解釈時に必ず区別すること(未レビュー分はラベルノイズを含む)。 topic : 旧セットのみ(chord/melody/rhythm/prerequisite) 出力: data/eval/eval_set_merged.json デフォルトは reviewed 済みのみ。--include-pending で未レビュー(pending)の answerable も LLM推定ラベルのまま投入する(reviewed=False で明示タグ付け)。 forum側のレビューが進むたびに再実行すれば、reviewedになった分が自動で増える。 """ from __future__ import annotations import argparse import json from music_rag import config OLD_PATH = config.EVAL_DIR / "questions.json" FORUM_PATH = config.EVAL_DIR / "forum_review.json" OUT_PATH = config.EVAL_DIR / "eval_set_merged.json" def build(include_pending: bool = False) -> list[dict]: rows: list[dict] = [] # --- 1. 旧silver 20問(すべて人手作成=reviewed)--- old = json.loads(OLD_PATH.read_text(encoding="utf-8")) for i, q in enumerate(old, start=1): rows.append({ "id": f"old_{i:02d}", "question": q["question"], "ground_truth": q.get("ground_truth", ""), "expected_source": [q["expected_source"]], # 単一→listに正規化 "match_type": "single", "difficulty": q.get("difficulty"), "source": "silver_manual", "reviewed": True, "topic": q.get("topic"), }) # --- 2. forum --- # 既定: reviewed かつ answerable のみ。 # include_pending: 未レビュー(pending)の answerable も LLM推定ラベルのまま投入し、 # reviewed=False で明示タグ付けする(正解ラベルは未検証)。 forum = json.loads(FORUM_PATH.read_text(encoding="utf-8")) for tid, v in forum.items(): if not v.get("answerable_standalone"): continue is_reviewed = v.get("review_status") == "reviewed" if not is_reviewed and not include_pending: continue rows.append({ "id": f"forum_{tid}", "question": v["question"], "ground_truth": v.get("ground_truth", ""), "expected_source": v["expected_source"], "match_type": v.get("match_type") or "or", "difficulty": v.get("difficulty"), "source": "forum", "reviewed": is_reviewed, # False = LLM推定ラベルのまま(未検証) "topic": None, }) return rows def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--include-pending", action="store_true", help="未レビュー(pending)のforum answerableもLLM推定ラベルのまま投入(reviewed=Falseでタグ付け)", ) args = parser.parse_args() rows = build(include_pending=args.include_pending) OUT_PATH.write_text( json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8" ) from collections import Counter print(f"wrote {len(rows)} questions → {OUT_PATH}") print(" source:", dict(Counter(r["source"] for r in rows))) print(" reviewed:", dict(Counter(r["reviewed"] for r in rows))) print(" match_type:", dict(Counter(r["match_type"] for r in rows))) print(" difficulty:", dict(Counter(r["difficulty"] for r in rows))) multi = [r for r in rows if len(r["expected_source"]) > 1] print(f" multi-source questions: {len(multi)}") n_pending = sum(1 for r in rows if not r["reviewed"]) if n_pending: print(f" ⚠️ 未レビュー(reviewed=False)を {n_pending} 問含む: 正解ラベルはLLM推定のまま") if __name__ == "__main__": main()