ブログ一覧

自動投稿 bot の設計を「ネタ→投稿」から「背景→投稿」に変えた

Slack にエピソードを書き込むと LLM と対話しながら 4 コマ漫画にして X に投稿する bot を運用していて、テキストの投稿にも対応させようとしました。最初の設計は「ネタの粒度」で機能を分ける形でした。4 コマ向きの長いネタと、短文向きの小さなネタを判別して、それぞれの出口に流す。この設計は、書き始める前に破綻しました。この記事は、その設計を捨てて「背景→投稿」に置き直した経緯と、置き直したあとに実際に出た混線と競合の記録です。

症状: 投稿が続かない原因は「書けない」ではなく「書き始めない」

bot を作った動機は、投稿を続けることでした。ところが 4 コマの生成が動いても、投稿は続きませんでした。起点が全部人間だったからです。ネタを思い出す、Slack に書く、対話して整える、生成する、確認して投稿する。このうち最初の「ネタを思い出して書く」が一番重く、そこで止まります。

テキスト投稿を足そうとしたとき、私は「ネタの粒度」で設計を始めました。4 コマにするほどではない小ネタをどう扱うか、という問いです。設計を進めるうちに、判別基準がどこにも置けないことが分かりました。ツイート 1 本分の思いつきは、bot を経由する意味がありません。X に直接書けばいい。bot を通す理由があるのは、そのままでは投稿の形になっていないものだけです。

置き直し: bot の仕事は「投稿を書くこと」ではなく「変換すること」

境界線を 1 本にしました。変換(圧縮か変形)が要るか、要らないか。

  • 4 コマ: エピソードとスレッドの対話を、漫画に変形する。変換の落差が最も大きく、人力では作れない。看板
  • テキスト投稿: 背景が「長い、散らかっている、文になっていない」ときだけ意味がある。セッションログ、試行錯誤の履歴、エラーとの格闘を、1 本のポストに圧縮する
  • 普通の雑談: 変換が要らない。X に直接書く。bot の管轄外

「ネタ→投稿」ではなく「背景→投稿」。この言い換えで、機能の設計が「粒度の判別」から「背景の収集と変換」に変わりました。

pull から push へ

背景は人間が思い出すものではなく、bot が機械的に集めて提示するものにしました。毎朝の cron で、指定したリポジトリの直近 24 時間の commit・PR・Issue と、自分のブログのサイトマップの新着を集め、LLM で「独立した出来事」の単位に整理して、Slack に番号付きで提示します。人間は、その各項目に付いた「4 コマ化」「ポストに凝縮」のボタンを押すか、押さないかだけ。

背景の源は、実体験の度合いと自動化のしやすさで選びました。

  • GitHub の活動: 毎日ある。実体験そのもの。本命
  • ブログのサイトマップ: 質は高いが、月に数回しか動かない。1 つの源として同梱
  • 一言メモの受け皿: 見送り。運用データが出てから判断
  • AI のニュースやトレンド: やらない。誰でも書けるものは、実体験という差別化を薄める

収集対象のリポジトリは、設定ファイルに並べるのではなく、読み取り専用の細粒度 PAT がアクセスできるリポジトリをそのまま列挙する形にしました。PAT にリポジトリを加えることが「そのリポジトリの活動内容を LLM に送ることへの同意」になり、設定との二重管理が消えます。

src/
  services/
    digest/
      github.ts           # PAT で列挙したリポジトリの直近 24 時間の活動
      blog.ts             # サイトマップの新着(既知 URL の集合との差分)
      index.ts            # 素材 → LLM → 背景アイテム → DynamoDB → Slack
    processor.ts          # ボタンから 4 コマ化 / テキスト投稿へ
  prompts/
    digest.txt            # 素材の取捨選択。温度は低め
    post.txt              # 背景を X の 1 ポストに圧縮する文体の規則

物語は PR の本文で運ぶ

GitHub の活動のうち、commit のメッセージには「何をしたか」しか残りません。「何に詰まって、どう抜けたか」は PR の本文にしか無い。そこで、PR テンプレートに「🔥 ハマったこと・試行錯誤」の欄を置き、bot が PR の本文を読むときにこの欄を優先して引用させるようにしました。テンプレートはリポジトリごとに置くのではなく、組織の `.github` リポジトリ に 1 つ置いて、全部のリポジトリの既定にしています(この仕組みは .github リポジトリの PR テンプレートに「ハマったこと」欄を置く理由 に分けて書きました)。

ここで 1 つ失敗しています。bot が PR の本文を素材に含める上限を、最初は 300 文字にしていました。テンプレートの「ハマったこと」欄は本文の一番下にあるので、300 文字で冒頭から切ると、まさに欲しい部分が落ちる。上限を 2,000 文字に上げて直しました。欄を作った PR 自身の「ハマったこと」には「特になし(この欄自体が本 PR の成果物)」と書いてあります。

置き直したあとに出た混線と競合

返信がどのネタに届くか分からない

ダイジェストから変換した episode は、最初はすべてダイジェストのメッセージのスレッドにぶら下がっていました。スレッド返信での修正指示は、スレッドの ts から episode を引くのですが、その索引はハッシュキーだけでソートキーが無く、同じスレッドに episode が複数あると先頭の 1 件を返すだけ。どれが返るかは不定です。

これは実際に起きました。同じダイジェストから 4 コマとテキスト投稿の 2 つを変換したスレッドで、「宇宙のくだりを外して」という修正指示が 4 コマ側に届いたのは偶然で、テキスト投稿側に届いていればポスト文が書き換わっていました。先頭の 1 件が投稿済みの episode だった場合は、返信が無反応で死にます。

直し方は、変換ボタンを押した時点で、そのネタ専用のスレッドを新しく立てることにしました。チャンネルに「4 コマ化: <タイトル>」「ポスト化: <タイトル>」の起点メッセージを投稿し、その ts を episode に紐づける。以降の対話、シナリオの提示、確認、投稿のボタンは全部その専用スレッドの中で進みます。1 episode = 1 スレッドになり、ルーティングの曖昧さが構造から消えます。あわせて、索引の引き方を全件返却に変え、古いデータ(スレッドを共有していた時代の episode)でも修正可能なものを選んで返信が届くようにしました。

捨てたのは、ダイジェストの一覧性の一部です。チャンネルのトップレベルに増えるのは「実際に変換したネタ」だけなので、実害は小さいと判断しました。逆に「ダイジェストを 1 項目 1 メッセージで配る」案は、触られないネタでチャンネルが埋まるので採りませんでした。

状態機械を使い回したことで生まれた競合

テキスト投稿は、4 コマ用の状態機械(draftinggeneratingreviewingpostingposted)を拡張して実装しました。別の状態種別を作らず、kind: "text_post" を足しただけです。status 遷移の排他、スレッドでの対話、キャンセル、冪等化をそのまま使い回せるからです。

ただ、4 コマとテキストでは、人が手を入れられるタイミングが違います。4 コマはシナリオ提示中が drafting で、画像ができてから reviewing になります。テキストは文案の提示と同時に reviewing(投稿ボタンが押せる状態)にしないと使い物にならず、しかも reviewing のままスレッド返信で文案を修正できる必要がありました。これで競合が 1 つ生まれます。修正の生成が終わる前に投稿ボタンが押され、投稿が先に進み、あとから生成の完了が reviewing に巻き戻す。巻き戻れば、もう一度投稿できてしまいます。

文案の保存を条件付き更新にして塞ぎました。statusdraftingreviewing のときだけ保存し、postedcancelled に先に進んでいたら文案を捨てます。

ConditionExpression: "#status IN (:drafting, :reviewing)",
// posted / cancelled に先行されていたら、生成した文案は破棄する

もう 1 つ、複数のポストを返信で繋ぐスレッド投稿で、2 件目以降が失敗したときの扱いです。失敗として reviewing に戻すと、再試行で先頭が二重に投稿されます(X の重複検出で畳まれますが、その時点で残りは失われます)。巻き戻す方が被害が大きいので、先頭の成功を優先して posted にし、失敗はログに残して連鎖を止める、と決めました。

明示的に見送ったもの

  • 一言メモの受け皿。ネタの種を放り込む inbox
  • 任意のチャンネルへの対応(絵文字リアクションでの起動)。専用チャンネル前提を維持する限り、50 文字未満は無視するという単純な入口で破綻しない
  • チャンネルごとのモード切り替え
  • 過去記事 93 本を LLM に採点させてストック化すること
  • メンション方式。push に寄せたことで不要になった

どれも「運用データが出てから」に置いています。設計の時点で作れるものを作らないのは、この bot が私一人の運用で、使われない機能の保守がそのまま負債になるからです。

検証

背景の収集は、ソースごとの失敗を他のソースに波及させず、既知 URL の集合の更新は処理全体が成功したときだけ確定する(途中で落ちたら次回もう一度検出する)形にしました。テストは収集基盤の PR で 222 件、変換の出口の PR で 251 件、専用スレッド化の PR で 256 件です。

どこで止まるか

  • 雑談レベルの背景を bot に流さない、という棲み分けは運用で守っていて、機械的には強制していません
  • 集める背景は、PAT がアクセスできるリポジトリの活動に限られます。それ以外の場所で起きたことは、今も人間が思い出して書くしかありません
  • 「ハマったこと」の欄は、書く人が書かなければ空です。テンプレートは欄を用意するだけで、埋めることは強制しません

この構成で気をつけること

  • 自動投稿の bot で先に決めるのは「何を投稿するか」ではなく「bot を通す理由は何か」。理由は変換で、変換が要らないものは通さない
  • 継続の障害は「書けない」ではなく「書き始めない」。起点を人間から bot に反転させる(pull から push へ)
  • 物語は commit ではなく PR の本文に残る。残す場所(テンプレートの欄)と拾う側(本文の上限)の両方を揃える
  • 対話の単位(スレッド)と状態の単位(episode)は 1 対 1 にする。共有すると、どちらに届くかが運任せになる
  • 既存の状態機械を拡張して使い回すと、種類ごとに違う「人が手を入れられる区間」が競合を生む。競合は条件付き更新で塞ぐ
  • 実体験を差別化にするなら、誰でも書けるソース(ニュース、トレンド)は最初から入れない

この設計の後、bot の出口は漫画である必要が無くなりました。背景を集めて変換する部分が本体で、漫画も短文も出口の 1 つです。

参考リンク

関連

この記事をシェア