関連 issue: #77 Wiki 同期で生成された要約がインデックスへ反映されない
work_publish 後の Wiki sync で、docs ページ本体と Wiki.js への保存処理は成功しているにもかかわらず、butler2 インデックスページの説明欄が空のままになる事象が起きた。
調査で分かった重要点は、index_page_update.ok=true が「要約がインデックスへ反映された」ことまでは示さない点である。現行実装では、要約生成が失敗・timeout・skipped・validation failed などになった場合でも、互換 API summarize_doc() が理由を捨てて空文字を返す。その空文字が updated_entries に入り、_update_index_page() が既存 description を空文字で上書きする。
つまり問題は単純な保存失敗ではなく、次の 2 つが重なったものである。
status / reason が summarize_doc() で空文字に畳まれ、evidence に残らない。追加検証で、さらに別の問題も確認された。現行の wiki_worker.py は all_entries を mapped_pages から作っているが、mapped_pages はその sync で変更対象になった docs だけである。一方、_update_index_page() は all_entries を「インデックスに残す全 docs」として扱い、all_entries に無い既存行を除外する。したがって、直近コミットで変更されていない docs の行が project index から落ちる。
実 Wiki.js では butler2/* 配下の実ページが 50 件超あるのに、butler2 インデックス本文は直近 sync 対象の 2 行だけになっていた。空 description 問題を直しても、all_entries が変更 docs だけのままでは「生き残った数行に説明が付く」だけで、インデックス全体は復旧しない。
index_page_update.ok=true だけでなく、ページごとの要約結果を Brain が説明できるsummarize_doc_result() を正本として使うbutler/maids/wiki_worker.py のインデックス更新経路では、互換 API summarize_doc() ではなく、butler/wiki_doc_summarizer.py の正本 API summarize_doc_result() を使う。
現行:
entry["description"] = summarize_doc(title, content)
updated_entries.append(entry)
改善後の考え方:
outcome = summarize_doc_result(title, content, outer_timeout_seconds=summary_timeout)
record_summary_outcome(...)
if outcome.status == "ok" and outcome.summary:
entry["description"] = outcome.summary
updated_entries.append(entry)
これにより、要約できなかった場合は updated_entries に空 description を入れない。
evidence["index_summaries"] のような配列を追加し、変更ページごとの要約結果を残す。
例:
{
"index_summaries": [
{
"wiki_path": "butler2/Butler利用ガイド",
"repo_file": "docs/Butler利用ガイド.md",
"title": "Butler 利用ガイド",
"status": "failed",
"reason": "validation_failed:too_long",
"retry_after_seconds": null,
"description_updated": false
},
{
"wiki_path": "butler2/検討用/77_wikiインデックス要約反映改善案",
"repo_file": "docs/検討用/77_wikiインデックス要約反映改善案.md",
"title": "Issue #77 改善案: Wiki インデックス要約の空上書き防止と診断性改善",
"status": "ok",
"reason": null,
"retry_after_seconds": null,
"description_updated": true,
"summary_length": 58
}
]
}
evidence に summary 本文を入れるかは別途判断する。本文を入れる場合も長さ上限を設ける。初期対応では summary_length と description_updated だけでも、診断性は大きく改善する。
呼び出し側で updated_entries に空 description を入れないことを第一防御とする。加えて、_update_index_page() 側にも防御を入れる。
候補:
entry.get("description") が空文字の場合は既存 description を保持する。clear_description: true を要求する。初期対応では 1 を採用するのが安全である。現状の主用途は「要約で説明を埋める」ことであり、Wiki sync が説明欄を意図的に削除する workflow は確認されていない。
ページ本体同期では _extract_title(content, repo_file_rel) を使っている。一方、インデックス要約側では mapped_pages に title が無いため、path 末尾を title として使っている。
要約 prompt の品質を上げるため、インデックス要約側でも repo file を読んだ後に _extract_title(content, repo_file) を使う。
注意点として、同じ title を all_entries の表示名にも使うと、インデックス上のリンク表示が path 末尾から実 H1 へ変わる。たとえば Butler利用ガイド が Butler 利用ガイド になる。description は wiki_path をキーに保持されるため壊れないが、表示名変更を避けたい場合は「要約 prompt 用 title」と「インデックス表示 title」を分ける。
summarize_doc_result() は outer_timeout_seconds を受け取れる。Wiki sync 側の timeout / soft deadline と独立した既定値に任せると、長文や複数ページで全体 timeout に近づきやすい。
初期対応では、次のどちらかを採用する。
ただし #77 の主眼は空上書き防止と診断性改善なので、timeout 最適化は優先度を下げてもよい。
all_entries は全 docs から構築するupdated_entries は今回変更された docs のうち、要約成功したものだけでよい。一方、all_entries は project index 全体を再構築するための入力なので、直近変更 docs ではなく、管理対象の全 docs から作る必要がある。
現行の問題:
updated_entries = []
all_entries = []
for page in mapped_pages:
...
if wiki_path in changed_paths:
...
all_entries.append(entry)
この mapped_pages は committed_files から選ばれた docs に対応するため、直近コミットで変更されていない docs は all_entries に入らない。_update_index_page() は all_entries に無い行を除外するので、未変更 docs の行がインデックスから消える。
改善後の考え方:
changed_pages = _map_to_wiki_paths(wiki_root_prefix, candidate_files, docs_root)
all_doc_files = get_all_managed_docs(repo_root, docs_root)
all_pages = _map_to_wiki_paths(wiki_root_prefix, all_doc_files, docs_root)
updated_entries = build_updated_entries(changed_pages)
all_entries = build_all_entries(all_pages)
all_entries の description は空でよい。既存 index から description を抽出し、updated_entries の成功要約だけで上書きするのが _update_index_page() の役割である。
全 docs の列挙では、少なくとも次を守る。
docs/**/*.md を対象にするREADME.md や index 相当ページをどう扱うかは既存 mapping と揃えるupdated_entries は全 docs ではなく、今回変更された docs かつ要約成功した docs に限定する改善案が有効かどうかは、単体テスト、結合テスト、実 Wiki smoke の 3 段で確認する。
追加検証で判明した all_entries 不完全問題を検出するには、_update_index_page() 単体だけでは足りない。単体テストでは合成した all_entries を直接渡すため、呼び出し側が all_entries を変更 docs だけで作ってしまうバグを見逃す。結合テストと実 Wiki smoke では、「未変更 docs の行が sync 後も残る」ことを必ず確認する。
_update_index_page() の単体テスト目的は、空 description が既存 description を消さないことを固定することである。
前提:
| [guide](/repo/guide) | 既存説明 | があるupdated_entries に {"wiki_path": "repo/guide", "description": ""} が渡る期待:
| [guide](/repo/guide) | 既存説明 | を維持する_update_index_page() は成功を返すこのケースが通れば、要約失敗時に説明欄が消える事故を _update_index_page() 側でも防げる。
前提:
既存説明 があるupdated_entries に {"description": "新しい説明。"} が渡る期待:
新しい説明。 へ更新されるこのケースが通れば、空上書き防止が通常の要約更新を妨げていないと確認できる。
前提:
all_entries に含まれる期待:
これにより、「新規ページで説明がまだ無い」状態と「既存説明を消してしまう」状態を区別できる。
wiki_worker の結合テスト目的は、summarize_doc_result() の結果が evidence と index update に正しく反映されることを確認することである。
summarize_doc_result() と _graphql_call() を mock し、Wiki sync の post sync 経路を通す。
mock:
SummaryOutcome("ok", "要約文です。", None, None)
期待:
_update_index_page() に非空 description が渡る要約文です。 になるevidence["index_summaries"][0]["status"] == "ok"description_updated == truemock:
SummaryOutcome("failed", None, "validation_failed:too_long", None)
期待:
_update_index_page() に空 description 更新が渡らないevidence["index_summaries"][0]["status"] == "failed"reason == "validation_failed:too_long"description_updated == falsemock:
SummaryOutcome("timeout", None, "deadline_exceeded", None)
期待:
status=timeout と reason=deadline_exceeded が残るmock:
SummaryOutcome("skipped", None, "quota_backoff", 30.0)
期待:
status=skipped, reason=quota_backoff, retry_after_seconds=30.0 が残るmock:
SummaryOutcome("skipped", None, "backend_unavailable", None)
期待:
前提:
docs/a.md, docs/b.md, docs/c.md があるcommitted_files は docs/a.md だけa, b, c の 3 行がある期待:
all_entries は a, b, c の 3 件で _update_index_page() に渡るupdated_entries は要約成功した a だけでよいb, c の行が残るb, c の既存 description は維持されるこのケースがないと、要約の空上書き対策が通っても、実インデックスが直近変更 docs だけに縮むバグを検出できない。
テスト実装上の注意:
summarize_doc が wiki_worker.py の関数内で import されるため、改善後の結合テストでは butler.wiki_doc_summarizer.summarize_doc_result を patch 対象にする。butler.maids.wiki_worker.summarize_doc_result のような名前空間には存在しない可能性があるため、import 位置に合わせて patch する。単体・結合テストだけでは、Wiki.js API との実際の保存結果までは確認できない。実環境では、失敗時と成功時を分けて smoke する。
手順:
BUTLER_WIKI_SUMMARY_BACKEND=unavailable など、要約が skipped/backend_unavailable になる設定で Wiki sync を実行する。index_summaries があり、対象 page の status=skipped, reason=backend_unavailable, description_updated=false が残っていることを確認する。判定:
手順:
docs/**/*.md を 1 件更新する。status=ok, description_updated=true, summary_length > 0 が残っていることを確認する。判定:
手順:
docs/Butler利用ガイド.md のような長文 docs を対象に Wiki sync を実行する。status=ok と説明欄更新を確認する。reason に validation_failed:* / timeout / quota_backoff などが残ることを確認する。判定:
手順:
判定:
evidence["index_summaries"] で各 docs の status / reason / retry_after_seconds / description_updated を確認できる_update_index_page() の単体テストで空 description の既存説明保持を確認できるwiki_worker の結合テストで要約成功・validation failed・timeout・skipped を確認できるwiki_worker の結合テストで、1 件だけ docs を更新しても未変更 docs のインデックス行が残る_update_index_page() に空 description で既存説明を消さない防御を入れ、単体テストを追加する。all_entries を変更 docs ではなく全 docs から構築する。wiki_worker のインデックス要約経路を summarize_doc_result() に変更する。index_summaries evidence を追加する。updated_entries へ description を入れるようにする。summary_length と更新有無を優先する。all_entries を全 docs 化しないまま提案 1〜5 だけを実装すると、説明が付くのは直近変更 docs の行だけで、未変更 docs の行欠落は解消しない。2026-06-25 の Claude 検証では、本書の更新版(提案 1〜6、検証 2 ケース 6、smoke D 追加後)を、実 wiki_worker helper の上でプロトタイプ実装し、GraphQL のみ mock、SummaryOutcome は mock して検証した。
結果は 22/22 PASS。
確認できたこと:
_update_index_page() の空 description 上書きバグは対照実験で再現できた。summarize_doc_result() 相当の SummaryOutcome から status / reason / retry_after_seconds / description_updated / summary_length は導出できた。all_entries を全 docs から構築すると、committed_files=[docs/a.md] のような 1 件更新でも、未変更の b.md / c.md の行と既存 description を保持できた。all_entries を変更 docs だけで構築すると、未変更 docs 行が消えることを再現できた。この検証により、提案 1〜6 は設計として有効と判断できる。ただし、検証は SummaryOutcome 契約を mock した設計レベルの実証であり、実 wiki_worker.py への結線と回帰テスト追加は別途必要である。また、大型 doc が実 agy で返す具体的 reason は、認証済みユーザー環境での実行確認が必要である。