アーキテクチャ
ディレクトリ構造
noisy-crow は 共有コア packages/noisy-crow-core + 2 つの Worker (noisy-crow-web / noisy-crow-events) で構成する。クリーンアーキテクチャの層は共有コアに集約され、両 Worker から import される。
共有コア (packages/noisy-crow-core)
framework 非依存のクリーンアーキテクチャ層を所有し、package.json の exports で @tied-workspace/noisy-crow-core/domain/* /application/* /infrastructure/* /composition/* として公開する。React は含まない。noisy-crow-web (loader / action) と noisy-crow-events (fetch ハンドラ) の両方がこの package を import する。
packages/noisy-crow-core/
└── src/
├── domain/ # エンティティ・純粋関数 (環境非依存)
│ ├── emoji.ts # SlackEmoji / EmojiVector / ExcludedEmoji
│ ├── sync-job.ts # SyncMode / SyncJobStatus / SyncPlan (同期ジョブの状態)
│ ├── stamp-request.ts # StampRequest / StampResult
│ ├── similarity.ts # cosineSimilarity
│ ├── vector-projection.ts # PCA / t-SNE による 2D 投影 (純 TS)
│ └── clustering.ts # k-means (k-means++ 初期化、シード固定で決定的)
├── application/
│ ├── ports.ts # 依存注入用のインターフェース
│ └── usecases/ # ユースケース関数 (純 TS)
│ ├── process-stamp-request.ts
│ ├── sync-emoji-vectors.ts # Workflow の各 step + startEmojiSync / getSyncStatus
│ ├── list-emojis.ts
│ ├── search-emojis.ts
│ └── visualize-emojis.ts # 可視化 (投影 + 事前/事後クラスタリング + AI ラベル)
├── infrastructure/ # ports の実装 (Cloudflare / Slack)
│ ├── cf-kv-store.ts # Cloudflare KV (Worker binding)
│ ├── vectorize-emoji-store.ts # Cloudflare Vectorize + KV (メタデータ正本) + D1 (一覧ページング)
│ ├── d1-sync-state-store.ts # 同期ジョブ状態 + 履歴 + plan/captions (D1 sync_jobs / sync_job_data)
│ ├── workflow-sync-job-runner.ts # Workflow `noisy-crow-sync` の起動・状態参照
│ ├── r2-excluded-emoji-store.ts # Cloudflare R2 (除外絵文字 JSON)
│ ├── cf-ai-gateway.ts # Cloudflare AI Gateway (Google AI Studio, batch inference 対応)
│ └── web-api-slack-gateway.ts # @slack/web-api
└── composition/ # 合成ルート (DI)
└── web-deps.ts # buildWebDeps(env) + buildSyncDeps(env) + buildProcessStampDeps(env)UI Worker (apps/noisy-crow-app, Worker 名 noisy-crow-web)
管理 Web (app/, React Router v7) のみを担う。共有コアを @tied-workspace/noisy-crow-core/... で import し、app/lib/deps.server.ts 経由で loader / action が呼ぶ。src/ ディレクトリは持たない (コアは packages/noisy-crow-core へ移動済み)。Slack イベント処理は持たない (/slack/events ルートは撤去済み)。旧 src/api/ (Hono REST Worker) も廃止済み (旧構成)。
apps/noisy-crow-app/ # UI Worker noisy-crow-web: 管理 Web (app/) + 同期 Workflow。src/ は無い
├── workers/app.ts # Worker エントリ (createRequestHandler + scheduled (cron) + Workflow export)
├── workers/sync-workflow.ts # Workflow `noisy-crow-sync` (SyncEmojiVectorsWorkflow)。step の中身は core のユースケース
├── app/
│ ├── root.tsx # HTML document + nav + ErrorBoundary
│ ├── routes.ts # ルート定義 (UI ルートのみ)
│ ├── lib/deps.server.ts # @tied-workspace/noisy-crow-core の buildWebDeps / buildSyncDeps + usecases を再 export (サーバー専用)
│ └── routes/{index,emojis,emoji-detail,syncs,search,visualization}.tsx # loader / action + コンポーネント (index は /emojis へ redirect)
├── react-router.config.ts # ssr: true
├── vite.config.ts # @cloudflare/vite-plugin + reactRouter()
└── wrangler.toml # Worker noisy-crow-web + KV / D1 / R2 / Vectorize / Workflow バインディング + cron + assets
# D1 のスキーマ (migrations) はリポジトリ共通の packages/d1/ で統一管理 (Worker はバインディングのみ)Events Worker (apps/noisy-crow-events, Worker 名 noisy-crow-events)
Slack Events API 受信専用の素の Cloudflare Worker (fetch ハンドラ)。共有コアを @tied-workspace/noisy-crow-core/... で import し、buildProcessStampDeps(env) で deps を組んで processStampRequest を呼ぶ。ユーザー認証を持たない公開 Worker (真正性は Slack 署名で担保)。
apps/noisy-crow-events/ # Events Worker noisy-crow-events: Slack Events 受信専用
└── src/
├── index.ts # fetch ハンドラ: /slack/events 受信 → 署名検証 → 即 200 → ctx.waitUntil(processStampRequest)。url_verification 対応
└── verify-signature.ts # Slack 署名検証 (HMAC-SHA256, 5 分リプレイ窓)
# wrangler.toml: Worker noisy-crow-events + KV / D1 / R2 / Vectorize バインディング (UI と同じリソースを共有)レイヤーの責務と依存方向
domain ← application ← infrastructure (packages/noisy-crow-core)
↖ ↑
composition/web-deps (composition root)
↑
┌──────────────────────┴───────────────────────┐
noisy-crow-web の loader / action noisy-crow-events の /slack/events- domain: 環境非依存のエンティティと純粋関数のみ。
fetch/ Cloudflare API / Slack SDK を import しない。 - application: ユースケースを純 TS 関数として実装。引数で
ports.tsのインターフェースを受け取る (依存注入)。Cloudflare の型 (KVNamespace,R2Bucket, etc.) を直接参照しない。 - infrastructure:
application/ports.tsの各インターフェースを Cloudflare / Slack バインディング上で実装する。差し替え可能であることが価値の中心。 - composition root: 起動処理。
infrastructureの実装をインスタンス化してapplicationのユースケースに渡す。composition/web-deps.tsがbuildWebDeps(env)(UI Worker の loader / action 用) とbuildProcessStampDeps(env)(events Worker のprocessStampRequest用) の両方を提供し、どちらもenvのバインディングから組み立てる。
2 つの Worker (noisy-crow-web / noisy-crow-events)
noisy-crow は 2 つの Cloudflare Worker で動く。それぞれが 1 つの入口を持ち、共有コアの composition root を経由して infra を組み立てる。
| Worker | 入口 | 種別 | 起動方式 | 主な依存 |
|---|---|---|---|---|
noisy-crow-web | 管理 UI (loader / action) + Workflow noisy-crow-sync + cron | React Router v7 (SSR) | HTTP リクエスト駆動 / Workflow / cron (毎日 18:00 UTC) | buildWebDeps(env) / buildSyncDeps(env) 経由で infra (VectorizeEmojiStore, R2ExcludedEmojiStore, CfAiGateway, WebApiSlackGateway, CfKvStore, D1SyncStateStore, WorkflowSyncJobRunner) + 認証 requireSession (@tied-workspace/auth)。ブラウザからの管理操作を受ける。better-auth セッションで保護 |
noisy-crow-events | /slack/events | 素の fetch ハンドラ | Slack の HTTP Events API POST | verify-signature.ts (署名検証) + buildProcessStampDeps(env) 経由で processStampRequest を実行。ユーザー認証を持たない公開 Worker |
絵文字インデックス同期は Cloudflare Workflow noisy-crow-sync (SyncEmojiVectorsWorkflow, UI Worker にホスト) で実行する。HTTP リクエスト内で全件処理すると絵文字数に比例して実行時間が伸びタイムアウトするため、loader / action は起動と進捗参照だけを担い、実処理は Workflow に任せる。
どちらも Worker bindings を直接使う。env から各バインディングを受け取り buildWebDeps() / buildProcessStampDeps() に渡す。Queue / Container / REST 経由アクセスは廃止した (旧構成)。両 Worker は同じ KV / D1 / R2 / Vectorize / AI バインディングを共有する (各 Worker の wrangler 設定で定義)。
データフロー
スタンプ付与のメインフロー
Slack message
→ Events API POST /slack/events (noisy-crow-events の fetch ハンドラ)
├─ verify-signature.ts: Slack 署名検証 (SLACK_SIGNING_SECRET, v0:<ts>:<body> の HMAC-SHA256, 5 分リプレイ窓)
├─ 即 200 を返す (Slack の 3 秒 ack)
└─ ctx.waitUntil(processStampRequest()) # buildProcessStampDeps(env) で組み立てた deps を渡す
├─ SESSIONS_KV `event:<eventId>` で重複チェック (重複なら skipped_duplicate)
├─ AI Gateway: Slack スレッドを Gemini に渡し reactionText を生成
├─ AI Gateway: Gemini embedding で reactionText の query embedding を生成
├─ Vectorize: 類似上位 N 件を取得 (similarityThreshold で絞り込み)
├─ R2: 除外絵文字リストでフィルタ
├─ Slack reactions.add を順次発行 (100ms 間隔)
└─ SESSIONS_KV にステータスを書き戻しQueue を廃止したため自動リトライ / DLQ は無い。重複配送は processStampRequest 内の SESSIONS_KV (event:<id>) 重複排除で吸収する (簡素さを優先した意図的なトレードオフ)。
絵文字インデックス同期フロー (Workflow noisy-crow-sync)
起点は 3 つ: Web /syncs の "Sync from Slack" ボタン (diff)、"Rebuild" ボタン (rebuild、モデル更新時)、cron (毎日 18:00 UTC に diff)。いずれも startEmojiSync() が Workflow インスタンスを作るだけで即座に返り、実処理は Workflow が durable に実行する。実行中ジョブがあれば二重起動しない。
startEmojiSync(mode) # /syncs action (intent=sync|rebuild) または cron
→ SYNC_WORKFLOW.create({ params: { mode } }) # 即返却。UI は loader で進捗をポーリング
→ [Workflow noisy-crow-sync] SyncEmojiVectorsWorkflow.run()
├─ step: plan
│ ├─ Slack: emoji.list / KV: 既存インデックス一覧 (listStored)
│ ├─ diff: 未登録のみ / rebuild: 現行モデルで作られていないものも対象
│ │ (手動編集 caption は embedding が古い場合のみ対象。caption は pinned で維持)
│ ├─ Slack から消えた絵文字を削除対象に
│ └─ plan を D1 (sync_job_data) に保存 (step の戻り値は件数のみ)
├─ step: delete-removed # Vectorize deleteByIds + KV delete
├─ step: submit-caption-batch # Gemini Batch API (batchGenerateContent) に pinned 以外を投入
│ └─ 全件 pinned なら batch を省略して embedding へ (batchName: null)
├─ step.sleep + step: check-captions # 30 秒→5 分間隔でポーリング (最大 24h 強)
│ └─ 完了したら captions (pinned とマージ) を D1 (sync_job_data) に保存。個別失敗は名前ベースの fallback
├─ step: embed-chunk-<i> (× ⌈total/50⌉)
│ ├─ AI Gateway: Gemini batchEmbedContents に caption 50 件まとめて embedding 生成 (1 リクエスト)
│ └─ Vectorize + D1 `emojis` に upsert (モデル名をメタデータに記録)
└─ step: finalize # sync_jobs の status を completed に- 各 step は自動リトライ (exponential backoff) され、途中で落ちても続きから再開する。
- 進捗 (
SyncJobStatus) は D1sync_jobsに instance_id で upsert され、UI (/syncs) の loader がポーリングする。完了 / 失敗後もジョブ履歴として/syncsから遡れる。 - caption は Gemini Batch API (非同期・50% 割引)、embedding は Gemini
batchEmbedContents(1 リクエスト 50 件) でバッチ推論する。絵文字 1 件ごとに API を呼ばない。embedding の入力は Gemini が生成した caption テキスト (画像そのものは caption 段階でのみ参照する)。 - 失敗時は
mark-failedstep が status を failed (理由つき) に倒す。
なぜこの構成なのか
- Slack 受信は HTTP Events API に統一する。旧構成は Socket Mode (WebSocket 常時接続) のため Cloudflare Workers では維持できず Bot を Container にしていたが、Events API なら HTTP POST を Worker で直接受けられる。これにより Bot Container / Socket Mode / keep-alive cron が不要になった (旧構成)。
- Slack 受信を UI から分離し、専用の公開 Worker
noisy-crow-eventsに切り出す。UI Worker (noisy-crow-web) は better-auth (共有認証基盤) のセッションで保護でき、UI に Slack 受信用の認証 bypass 穴を開ける必要がなくなる。events Worker はユーザー認証を持たない公開 Worker とし、Slack 署名検証で真正性を担保する。両 Worker はデータアクセス層 (packages/noisy-crow-core) を共有する。 - Slack の 3 秒 ack は即 200 で満たし、重い AI / Vectorize 処理は
ctx.waitUntilに逃がす。Queue Consumer を別 Worker に分けなくても、同一リクエストの後続処理として非同期に走らせられる。- Queue (
noisy-crow-stamp-requests+ DLQ) を廃止した (旧構成)。その代わり自動リトライ / DLQ は無く、重複配送はprocessStampRequestのSESSIONS_KV重複排除で吸収する (簡素さ優先のトレードオフ)。
- Queue (
- 管理 UI は React Router v7 framework モードに統合する。loader / action がサーバー (Worker) 上で動き、
context.cloudflare.envのバインディング経由で共有コアのユースケースを直接呼ぶ。以前のように UI から別ドメインの REST API をfetchする必要がない。- 別ドメイン (
nc-api) / CORS /credentials: "include"/ クロスサブドメイン cookie / Vite proxy が不要になる - API レスポンス型を SPA 側で再定義する二重管理が消え、共有コア
noisy-crow-coreの型がそのまま唯一の正本になる - UI の本番ドメインは
https://nc.tied-workspace.com(better-auth セッションで保護)。Slack の Request URL は events Worker のhttps://nc-events.tied-workspace.com/slack/eventsに向ける。
- 別ドメイン (
- 絵文字インデックス同期は Cloudflare Workflow で実行する。同期は「Slack 全絵文字 × (caption + embedding)」のバッチジョブで、HTTP リクエスト内では絵文字数に比例してタイムアウトする。Workflow なら step ごとのリトライ・状態永続化・
step.sleepによる長時間ポーリングが使え、Gemini Batch API のような submit → poll → 取得 の非同期バッチ推論とも噛み合う。スタンプ付与 (単発イベント) は引き続きctx.waitUntilで十分であり、Workflow は同期ジョブ専用。- メタデータに生成時のモデル名 (
embeddingModel/captionModel) を記録し、rebuildモードで「現行モデルで作られていないものだけ」を再処理できる。モデルのバージョンアップ時はデプロイ後に Rebuild を 1 回実行すればよい。 - 手動編集された caption (
captionModel: "manual") は rebuild でも再生成せず維持する (plan のpinnedCaptions)。embedding モデルが古い場合はベクトルだけ作り直す。
- メタデータに生成時のモデル名 (
- 個別絵文字の caption 修正 / 再解析 (
updateEmojiCaption/reanalyzeEmoji) は Workflow を使わず action 内で同期実行する。1 件あたり caption 1 リクエスト + embedding 1 リクエストの定数時間で終わるため、全件バッチの同期ジョブとは性質が異なる。sync ジョブ実行中は結果が上書きされうるため拒否する。 - それでも ブラウザに配信されるコードからは Cloudflare リソースを直接触らない。データに触れるのは
*.server.tsと loader / action だけ。