#!/usr/bin/env python3"""audit_model_ids.py — 資産中のモデルIDをカタログと突き合わせて三段に仕分ける使い方: ./model_catalog.sh > catalog.txt python3 audit_model_ids.py catalog.txt ./content出力: A 一致 … カタログにあるID。触らない B 日付不一致 … 家族と世代は既知だが、日付サフィックスがカタログにない C 要修正 … 書式が壊れている、または未知の家族・世代"""import collectionsimport osimport reimport sys# 素のID形(プロバイダー接頭辞・サフィックスなし)SHAPE = re.compile(r"^claude-(opus|sonnet|haiku|fable)-\d+(-\d+)?(-20\d{6})?$")# 本文中からIDを拾う。前後が語の一部なら拾わないIN_TEXT = re.compile( r"(?<![\w-])claude-(?:opus|sonnet|haiku|fable)-\d+(?:-\d+)?(?:-20\d{6})?(?![\w-])")# ハイフン抜け(claude-sonnet-46 のような綴り誤り)を別枠で拾うMALFORMED = re.compile(r"(?<![\w-])claude-(?:opus|sonnet|haiku|fable)-?\d{2,}(?![\w-])")# Markdown のリンク先はスラッグを含むので、集計前に落とすLINK = re.compile(r"\]\([^)]*\)")def load_catalog(path): """カタログを読み、実在しにくい綴りを落としてから ID 集合と世代集合を返す""" ids = set() for line in open(path, encoding="utf-8"): mid = line.strip() if not SHAPE.match(mid): continue parts = mid.split("-") # マイナー番号が二桁のものは連結由来の断片とみなす(claude-haiku-3-55 対策) if len(parts) >= 4 and not parts[3].startswith("20") and len(parts[3]) > 1: continue ids.add(mid) generations = set() for mid in ids: p = mid.split("-") has_minor = len(p) >= 4 and not p[3].startswith("20") generations.add("-".join(p[:4]) if has_minor else "-".join(p[:3])) return ids, generationsdef classify(mid, ids, generations): if mid in ids: return "A" if not SHAPE.match(mid): return "C" p = mid.split("-") has_minor = len(p) >= 4 and not p[3].startswith("20") base = "-".join(p[:4]) if has_minor else "-".join(p[:3]) return "B" if base in generations else "C"def main(catalog_path, root): ids, generations = load_catalog(catalog_path) buckets = {k: collections.Counter() for k in "ABC"} where = collections.defaultdict(set) for dirpath, _, filenames in os.walk(root): for name in filenames: if not name.endswith((".mdx", ".md", ".py", ".ts", ".json", ".yaml")): continue path = os.path.join(dirpath, name) text = LINK.sub("", open(path, encoding="utf-8", errors="replace").read()) found = set(IN_TEXT.findall(text)) for mid in IN_TEXT.findall(text): bucket = classify(mid, ids, generations) buckets[bucket][mid] += 1 if bucket != "A": where[mid].add(path) for mid in MALFORMED.findall(text): if mid not in found: # 正常なIDを二重計上しない buckets["C"][mid] += 1 where[mid].add(path) for key, label in (("A", "一致"), ("B", "日付不一致"), ("C", "要修正")): counter = buckets[key] print(f"{key} {label}: {len(counter)} 種 / 延べ {sum(counter.values())} 件") print() for key in ("B", "C"): if not buckets[key]: continue print(f"--- {key} ---") for mid, count in buckets[key].most_common(): print(f" {count:>4} {mid} ({len(where[mid])} ファイル)") # C が残っている間は失敗として返す。B は人が読む前提なので落とさない return 1 if buckets["C"] else 0if __name__ == "__main__": sys.exit(main(sys.argv[1], sys.argv[2]))