こんにちは。
今回は、先週末に個人プロジェクトとして作った「Plaudの議事録から、自分の脳内Wikiを構築するツール」の話をします。設計から実装、そしてハマったところまで、開発記として全部書いていきます。
わたしは普段、打ち合わせを Plaud のレコーダーで録っているのですが、文字起こしも要約もPlaudのクラウドにどんどん溜まっていく一方で、正直「撮りっぱなし」になっていました。あの案件の採用ステータス、誰と何を決めたんだっけ…というのが、自分の頭からもPlaudからもサッと出てこない。
そこで、こう考えました。
Plaudに全ての議事録が載っている。APIがあるらしいから、それらを定期的に全部引っ張ってきて、その上でLLM Wikiを構築すれば、僕の脳内を完全に再現するためのプロジェクトになるんじゃないか。
大げさなタイトルですが、本気です。同じように「議事録が資産にならずに死蔵されている」方の参考になれば嬉しいです。
TL;DR
先に結論というか、作ったものの全体像を3行で。
- やったこと: Plaudの全議事録を毎朝GCPに同期 → LLMで相互リンク付きの「Wiki」に自動生成 → MCPサーバー経由でClaudeから直接検索・参照できるようにした。
- 技術の核:
sync → index → wikiの3段パイプライン(Cloud Run Jobs)+ MCPサーバー(Cloud Run Service)。生成AIはGemini 2.5(Flash/Pro)。 - かかった時間とお金: 実装は正味2日ほど。Wikiの全再生成が1回あたり**$2〜7**、月のランニングコストは**$5〜15**くらいの見込み(かなりざっくりです)。
では、順番に振り返っていきます。
1. なにを作ったか(2つの機能は別物、という設計判断)
最初に自分の中でハッキリさせたのは、「議事録を探す機能」と「LLM Wikiの機能」は別物だ、ということでした。ここを混ぜると詰むと思ったんです。
| 観点 | 議事録の検索 | LLM Wiki |
|---|---|---|
| ほしいのは | 「あの発言、いつどこで言ったっけ」を原文で引く | 「あの案件、今どうなってる」を要約・構造化して知る |
| 実体 | 文字起こしのチャンク+ベクトル検索 | 人物・案件・決定・トピック単位のMarkdownページ |
| 正確性 | 一次情報そのまま | LLMが再構成(=ハルシネーションのリスクあり) |
なので、出力も3層に分けました。
- 生データ(source of truth): Plaudのレスポンスを一切加工せずGCSに保存。
- 検索インデックス: 文字起こしを400〜800トークンのチャンクに割って、768次元のベクトルと一緒にFirestoreへ。
- Wiki: LLMが生成した
[[wikilink]]付きのMarkdownページ。人物 / 案件 / 決定 / トピック / 会議シリーズの5タイプ。
この設計で一番効いたのは、「生データさえ残しておけば、インデックスもWikiも何度でもゼロから作り直せる」 という原則を最優先にしたことです。個人プロジェクトの生命線は、プロンプトやパイプラインの改良を恐れずに何度も試せること。実際このあとプロンプトを何度もいじって全再生成することになるので、この判断は正解でした。
2. 早速主題:Plaud APIをどう叩くか(CLIのバンドル解析)
さて、大前提の「Plaudから議事録を引っ張ってくる」部分。ここが最初の関門でした。
Plaudには公式のMCPサーバーとCLIがあるのですが、サーバー側で毎朝バッチ同期したいので、CLIのさらに下にあるREST APIを直接叩く必要があります。ドキュメントが見当たらなかったので、@plaud-ai/cli v0.3.4 のバンドル(dist/index.js)を解析させてもらいました。
分かったことを備忘録として残しておきます。
- ベースURL:
https://platform.plaud.ai/developer/api - 一覧:
GET /open/third-party/files/?page=...&page_size=...(サーバー側の日付フィルタは無い) - 詳細:
GET /open/third-party/files/{fileId}— なんと文字起こし・要約・音声URLが全部この1レスポンスに入っている。別エンドポイント不要。 - 認証はOAuth 2.0(Authorization Code + PKCE / S256)。クライアントIDはCLIと同じパブリッククライアント(secretは空)。
// Plaud CLI と同じパブリッククライアント(PKCE、secretなし)
const DEFAULT_CLIENT_ID = "client_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"; // ダミー
地味にハマったのがレスポンスの包み方が一貫してないところ。一覧系は {type, data, page, page_size} で包まれているのに、詳細は生で返ってくる。クライアント側で「包みを剥がすかどうか」のフラグを持たせて対処しました。あと、CLIにはPostHogのテレメトリが仕込まれていたので、自作クライアントでは当然外しています。
Plaud側への配慮として、レート制限の記載が見当たらなかったのでリクエスト間隔を最低300msに絞る自主規制も入れました(minIntervalMs)。音声ファイルはコストとPlaud側の負荷を考えてデフォルトでは落としません。
3. 唯一の大きな不確実性:トークンはヘッドレスで回り続けるのか?
設計の初日に「このプロジェクト、これが崩れたら成立しない」と思っていた点が1つだけありました。それが、Plaudのトークンリフレッシュが、サーバー上(=人がブラウザで認証しない状態)で回り続けるかです。毎朝の自動同期が前提なので、ここが一番怖かった。
検証した結果、動くには動くのですが、罠がありました。
- アクセストークンの有効期限は
expires_in: 86400(24時間)。 - リフレッシュのたびに、リフレッシュトークン自体もローテーションする。
つまり、リフレッシュして新しいトークンをもらったら、その新しいリフレッシュトークンを必ず永続化しないと、次から詰む。ここを見落とすと「昨日は動いてたのに今日死んだ」というやつになります。なので、sync-jobがリフレッシュするたびにSecret Managerへ書き戻す構成にしました。
以後トークンはsync-jobがリフレッシュのたびにSecret Managerへ書き戻す
(リフレッシュトークンはローテーションされる)。
ローカルの ~/.plaud/tokens.json は古くなるため、
ローカルでCLIを使い続けたい場合は別アカウント扱いになる点に留意。
正直に言うと、「使われないまま放置されたリフレッシュトークンの寿命」だけは検証しきれていません。ただ、毎朝同期が回っている限り常にリフレッシュされ続けるので、実運用では問題にならないはず…という判断です(ここは推測です)。念のため、リフレッシュに失敗したらログに PLAUD_AUTH_ERROR というマーカーを吐いて、Cloud Monitoringから自分にメールが飛ぶようにしてあります。
4. パイプライン:sync → index → wiki
ここからが本体です。3段のパイプラインを、それぞれ**冪等(何度流しても壊れない)**なCloud Run Jobとして作り、順番に連鎖させました。
Plaud API ──sync──> GCS(生データ) + Firestore(メタ) status=synced
└──> index ──> チャンク+ベクトル status=indexed
└──> wiki ──> Wikiページ群 status=wikified
sync:日付フィルタが無いので「既知IDの連続ヒット」で止める
前述の通り一覧APIに日付フィルタが無いので、差分同期は「新しい順にページをめくって、既知のIDに連続で当たったら追いついたとみなして止める」という素朴な戦略にしました。
// 既知IDにこの件数連続で当たったら「追いついた」とみなす
// (名前変更などで並びが揺れても取りこぼさないためのマージン)
const KNOWN_STREAK_TO_STOP = 20;
index:埋め込みは768次元に落とす(Firestoreの壁)
埋め込みには gemini-embedding-001 を使ったのですが、ここでFirestoreのベクトルインデックスは2048次元が上限という壁にぶつかりました。このモデルのデフォルトは3072次元なので、そのままだと入りません。outputDimensionality を明示して768次元に落とすことで回避しています。
/**
* gemini-embedding-001 のデフォルト3072はFirestoreベクトルインデックス上限(2048)を
* 超えるため、outputDimensionality の明示指定が必須。変更時は両ジョブの再実行が必要。
*/
export const EMBEDDING_DIMENSION = 768;
wiki:抽出 → 名寄せ → マージ
Wiki生成が一番の心臓部です。「議事録 → エンティティと事実を抽出 → 既存ページと名寄せ(同一人物・同一案件か判定) → ページにマージ」という流れ。ここでLLMを3回呼びます。
- 抽出 (Gemini 2.5 Flash): 議事録から
entitiesとfacts(fact / decision / open_question)を構造化抽出。 - 名寄せ (Gemini 2.5 Flash): 埋め込みで候補を絞ってから、同一エンティティかをLLMに判定させる。
- マージ (Gemini 2.5 Pro): ページ全体を書き直す。
マージのプロンプトで一番こだわったのが、出典の強制です。「僕の脳内の再現」とは言っても、LLMが勝手に補完した嘘が混ざったら意味がないので。
- 全ての記述(箇条書き・文)の末尾に出典を [recording_id] の形式で付ける。
出典のない記述を書かない
- 新しい事実が既存の記述と矛盾する場合、新しい方を本文に採用し、
古い内容は「## 経緯」節へ日付付きで残す
- 議事録由来の情報のみを書く。推測や一般知識で補完しない
ただ、プロンプトでお願いするだけだと守られないことがあるので、ハルシネーション対策はコード側でも二重にかけています。たとえば「この事実が古い事実を上書きした」という関係も、両方が実在するペアのときだけ採用するようにしました。
// supersededの検証: 実在する既存事実 × 今回の新事実 のペアのみ採用(幻覚対策)
const superseded = (result.superseded ?? []).filter(
(s) => existingIds.has(s.old_fact_id) && newIds.has(s.new_fact_id),
);
余談:Claudeで作ろうとして、Geminiに変えた
余談:当初の設計では、Wiki生成のLLMはClaude(Sonnet)を第一候補にしていました。品質的にはそうしたかった。でも、議事録は機密情報なので「リージョンをasia-northeast1に固定する」を優先して、**Gemini一本(asia-northeast1で完結)**に変更しました。品質やコストではなく、データの置き場所の都合です。餅は餅屋というか、こういう割り切りも個人プロジェクトなら自分で即決できるのが良いところ。
5. MCPでClaudeに繋ぐ:Googleを認可サーバーにする
Wikiができたら、あとはClaudeから引けるようにするだけです。ここはMCPサーバー(Cloud Run Service)を建てました。公開しているツールは検索・取得に徹した6つ。
| ツール | できること |
|---|---|
search_transcripts |
文字起こしをベクトル検索(recording_id と mm:ss 付き) |
get_transcript |
指定録音の要約+全文字起こし |
list_meetings |
同期済みの会議一覧 |
search_wiki |
Wikiページの意味検索(要約のみ返す) |
get_wiki_page |
Wikiページ本文 |
list_wiki_pages |
タイプ別のページ一覧 |
ここでの設計方針は「MCPは検索・取得に徹して、統合・推論は呼び出し側のLLM(Claude)に任せる」。サーバー側に「質問に答えるツール」を持たせるとLLMを二重に呼ぶことになってコストも複雑さも増えるので、あえて作りませんでした。
技術的に一番おもしろかったのが認証です。Claude.aiのカスタムコネクタとして繋ぐにはOAuthが必要なのですが、認証基盤を自前で建てるのも、認証SaaSを契約するのも大げさ。そこで、Googleを認可サーバー(Authorization Server)に据えて、自分のサーバーは「アクセストークンを検証するだけのリソースサーバー」に徹する構成にしました。
- サーバーはRFC 9728の保護リソースとして「認可サーバーは
accounts.google.comです」と名乗るだけ。 - 送られてきたアクセストークンをGoogleの
userinfoで検証し、emailが自分(you@example.com)かどうかだけチェック。
これで認証ベンダーもゼロ、自前の認可サーバーもゼロで済みました。GoogleはDCR(動的クライアント登録)に対応していないので、client_id / secret はClaude.aiのコネクタ設定に手で入れる必要がありましたが、それ以外はスッと繋がりました。Claude Codeからは、シンプルにSecret Manager発行の固定Bearerトークンでも叩けるようにしてあります。
6. 実際に自分の脳を検索してみた(と、痛恨のオチ)
初回の本番Wiki生成が回りきったときの数字がこれです。
- 録音 19件 から、Wikiページ 159枚(案件36 / 人物28 / トピック86 / 決定8 / 会議シリーズ1)、事実 539件(全部に出典
recording_id付き)。 - LLM呼び出し 383回、入力 72万トークン / 出力 61.5万トークン、コスト概算 $2〜7。
さっそくClaudeから「採用の件のステータスを教えて」と聞いてみたら、ちゃんと関連する会議を横断して答えが返ってきました。これは素直に感動しました。自分が忘れていた決定事項が、出典付きで出てくる。狙い通りです。
…が、ここで痛恨のオチがありました。
Wikiの人物ページを眺めていて気づいたのですが、このPlaudを使っている本人であるわたし、「安達」なのに、文字起こしで「足立」になっているんです。つまり「自分の脳内を再現する」と息巻いて作ったツールが、オーナーである自分の名前を間違えているという…(笑ってください)。
原因は文字起こし段階の表記揺れなので、aliases.yaml という人手のメンテ層を用意して、正準タイトルと別名を吸収させることにしました。
pages:
# 本人。文字起こしで「足立」と表記されるが正しくは「安達」
person-adachi-san:
title: 安達さん
aliases: [足立さん, 足立氏, 足立]
このとき、もう一つ設計上の発見がありました。person-speaker-2 みたいな話者ラベルのページがゴミとして量産されていたのですが、これは名寄せでは直せない。「会議Aのスピーカー2」と「会議Bのスピーカー2」は別人なので、そもそも抽出の段階で潰すしかない。このあたりはWikiの品質課題として、まだ宿題が残っています(正直、品質はまだまだこれからです)。
7. 開発プロセスの話:worktree並列 × Claude自動レビュー
最後に、作り方の話も少しだけ。このプロジェクトは、機能ごとにissueを立てて、git worktreeで並列に進めました。sync / index / wiki / MCP / CI をそれぞれ別のworktreeで同時に動かす感じです。並列で進めるとmainがどんどん動いてコンフリクトが頻発するのですが、それでも1本ずつやるより圧倒的に速かった。
さらに、CI/CDにClaudeの自動コードレビュー(claude-code-action)を挟みました。ここは正直ハマりました。最初、PRにレビューコメントが全然来ない。ログを漁ったら、レビュー自体は完走していたのに、--comment を付け忘れていてfindingsがActionsのログの中で完結していたんです。「なんでコメント来ないんだろう?」と首をかしげた原因がこれでした。/code-review --comment に直して解決。
次回の自分へのメモ:
claude-code-actionでレビュー結果をPRに載せたいなら、--commentと inline comment 用のツール許可を忘れずに。あと、--max-turnsが小さいと大きめのdiffでレビューが打ち切られるので注意(最初5で全然足りず、最終的に80まで上げました)。
このあたりの並列開発やAIレビューのやり方は、以前 git worktreeでAI駆動開発を並列化する話 や AGENTS.mdで並列AI開発を回す話 にも書いているので、興味があればぜひ。
ちなみに、盛大にやらかした話
かっこいい話ばかりだと嘘くさいので、やらかしも正直に。開発中盤で「gitの監視対象に不要なものが入ってそう」と気になって確認したら、案の定**infra/.terraform/ のプロバイダバイナリ(114MB)** をコミットしていました。さらに .dockerignore の置き場所を間違えていて、機密である data/(本物の議事録)がDockerビルドコンテキストに吸い込まれていたという、地味に一番ヤバいやつも見つかりました。まだpushしていなかったので、履歴を巻き戻して事なきを得ました。リポジトリは30MB超 → 75KBに。危なかった…。
(他にも、Docker Desktopがハングしてローカルビルドを諦めてCloud Build主経路に切り替えたり、GCPの認証が会社アカウントのままでコケたり、細かいのは色々ありました。個人プロジェクトあるあるです。)
まとめ
長くなったので、要点を圧縮して振り返ります。
- 議事録を資産にするなら、「原文検索」と「LLMによる再構成(Wiki)」は別機能として分けるのが吉。生データを source of truth に据えて、いつでも作り直せる構成にしておくと改良が怖くなくなる。
- Plaud APIはCLIバンドルから解析可能。ただしトークンはリフレッシュのたびにローテーションするので、新トークンの永続化を絶対に忘れないこと。
- LLM Wikiはハルシネーション対策が命。「出典を必ず付ける」をプロンプトとコードの両方で強制する。
- MCP + Googleを認可サーバーにすれば、認証基盤ゼロでClaude.aiのカスタムコネクタに繋がる。
- そして、自分の脳を再現するツールは、まず自分の名前を正しく覚えさせるところから(教訓)。
品質はまだ発展途上で、話者ラベルの扱いや表記揺れなど宿題は山積みですが、「あの件どうなってたっけ」をClaudeに聞いて出典付きで返ってくる体験は、想像以上に効きました。同じように議事録を死蔵させている方の、何かのヒントになれば幸いです!
改善が進んだら、また追記していきます。それではまた!
![[Git] AI駆動開発で必須の git worktree、使えてますか?](/assets/ogp/git-worktree-for-ai-driven-development.webp)
![[AGENTS.md] 並列AI開発に耐えるリポジトリの作り方](/assets/ogp/agents-md-for-parallel-ai-development.webp)
![[生成AI] Web開発に有効なMCP5選](/assets/ogp/mcp-top5-genai-dev.webp)
![[Claude] Claude CodeをGitHub Actions内で使いこなす](/assets/ogp/claude-code-github-actions.webp)