個人開発でアプリや資料を一人で回していると、長尺のドキュメントを機械にまとめさせたい場面は毎週のように訪れます。章ごと要約 → 全体再結合というパターン自体は、長尺 PDF に対してよく効きます。ただ「章に分けて並列で要約する」という構造そのものが、一章が静かに欠けても気づけない盲点を抱えています。ここでは、その取りこぼしを被覆率という一つの数字で捕まえ、消えた章だけを自動で再実行する監査つきパイプラインを、実装とあわせて整理します。
# pip install google-genai pypdf numpy# 環境変数: GOOGLE_API_KEY="YOUR_API_KEY"import asyncioimport osimport statisticsfrom dataclasses import dataclass, fieldimport numpy as npfrom pypdf import PdfReaderfrom google import genaiclient = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])SUMMARY_MODEL = "gemini-2.5-pro" # 章要約・全体再結合VERIFY_MODEL = "gemini-flash-latest" # 被覆判定(安く速く)EMBED_MODEL = "gemini-embedding-2" # 章⇄要約の意味的近さ@dataclassclass Chapter: idx: int title: str body: str summary: str = "" covered: bool = False notes: list[str] = field(default_factory=list)def extract_chapters(pdf_path: str) -> list[Chapter]: reader = PdfReader(pdf_path) top = [o for o in reader.outline if not isinstance(o, list)] chapters: list[Chapter] = [] for i, entry in enumerate(top): start = reader.get_destination_page_number(entry) end = (reader.get_destination_page_number(top[i + 1]) if i + 1 < len(top) else len(reader.pages)) body = "\n".join(reader.pages[p].extract_text() or "" for p in range(start, end)) chapters.append(Chapter(idx=i, title=entry.title, body=body)) return chaptersdef flag_thin_chapters(chapters: list[Chapter]) -> None: """空テキスト経路を入力段で弾く""" lengths = [len(c.body) for c in chapters if c.body.strip()] if not lengths: return floor = statistics.median(lengths) * 0.1 for c in chapters: if len(c.body.strip()) < floor: c.notes.append(f"本文が短すぎます({len(c.body.strip())}字)。抽出漏れの疑い")
async def summarize_pdf(pdf_path: str, max_retries: int = 2) -> dict: chapters = extract_chapters(pdf_path) flag_thin_chapters(chapters) sem = asyncio.Semaphore(4) # 無料枠の429を避ける保守的な同時実行数 async def bounded(c: Chapter): async with sem: await summarize_chapter(c) await asyncio.gather(*(bounded(c) for c in chapters if c.body.strip())) final = await merge_summaries(chapters) coverage = audit_coverage(chapters, final) attempt = 0 while coverage < 1.0 and attempt < max_retries: dropped = [c for c in chapters if c.summary and not c.covered] emphasis = "、".join(f"第{c.idx}章「{c.title}」" for c in dropped) final = await merge_summaries(chapters) + ( f"\n\n(補足指示: {emphasis} の主張を必ず本文に含めること)") coverage = audit_coverage(chapters, final) attempt += 1 return { "coverage": coverage, "dropped": [c.title for c in chapters if c.summary and not c.covered], "thin": [c.title for c in chapters if any("短すぎ" in n for n in c.notes)], "summary": final, }if __name__ == "__main__": report = asyncio.run(summarize_pdf("./spec.pdf")) print(f"被覆率: {report['coverage']:.0%}") if report["dropped"]: print("再結合から落ちた章:", report["dropped"]) if report["thin"]: print("抽出が怪しい章:", report["thin"]) print(report["summary"])