ドメインとユースケース
ここで述べるコア (domain / application / infrastructure / composition / ports) は共有パッケージ packages/noisy-crow-core (@tied-workspace/noisy-crow-core) に置かれ、exports で公開されます。noisy-crow-web (loader / action) と noisy-crow-events (/slack/events) の両 Worker がこのコアを import します。framework には依存しません。
ドメインエンティティ
domain/emoji.ts
interface SlackEmoji {
name: string;
url: string;
}
const MANUAL_CAPTION_MODEL = "manual"; // 手動編集 caption の captionModel 値
interface EmojiVector {
name: string;
url: string;
description: string; // Gemini が生成した英語キャプション (手動編集も可)
vector: number[]; // Gemini embedding 1024 次元 (outputDimensionality で切り詰め + L2 正規化)
lastUpdated: string; // ISO8601
embeddingModel?: string; // vector を生成したモデル (rebuild の再処理判定)
captionModel?: string; // description を生成したモデル (同上)。手動編集は MANUAL_CAPTION_MODEL
}
interface ExcludedEmoji {
name: string;
url?: string;
excludedAt: string;
reason?: string;
}
function cleanEmojiName(name: string): string; // "_"/"-" を空白に変換domain/sync-job.ts
絵文字インデックス同期 (Workflow noisy-crow-sync) のジョブ状態。
type SyncMode = "diff" | "rebuild";
// diff: 未登録の絵文字だけを処理 (通常運用)
// rebuild: 現行モデルで作られていないものを再処理 (モデル更新時)
type SyncJobPhase = "planning" | "captioning" | "embedding" | "completed" | "failed";
interface SyncJobStatus {
instanceId: string; // Workflow インスタンス ID
mode: SyncMode;
phase: SyncJobPhase;
total: number; // 処理対象数
processed: number; // embedding + upsert 完了数
deleted: number; // Slack から消えていて削除した数
errors: string[]; // 件別の警告 (caption fallback 等)。SYNC_ERROR_LIMIT (50) で打ち切り
startedAt: string;
updatedAt: string;
failureReason?: string;
}
interface SyncPlan {
toProcess: SlackEmoji[];
toDelete: string[];
// 再生成せず維持する caption (手動編集分)。caption バッチから除外され、
// embedding だけ作り直すときにこの値を使う
pinnedCaptions?: Record<string, string>;
}
function isSyncJobActive(phase: SyncJobPhase): boolean;
function capSyncErrors(errors: string[]): string[];domain/stamp-request.ts
interface SlackMessage { user: string; text: string; ts: string }
interface StampRequest {
eventId: string; // Slack event_id か `<channel>-<ts>`
channel: string;
user: string;
text: string;
ts: string;
threadTs?: string;
}
interface StampResult {
eventId: string;
status: "completed" | "skipped_duplicate" | "completed_no_results";
addedReactions: number;
}domain/similarity.ts
cosineSimilarity(a, b) のみ。Vectorize 側で類似度計算は完結するので現在の本番コードからは直接呼ばれていないが、ドメインの一部として残している (テスト / ローカル検証用)。
domain/vector-projection.ts
可視化用の 2D 投影 (純 TS、外部ライブラリなし)。旧 apps/noisy-crow-app/app/lib/vector-visualization.ts (クライアント実行) から移設し、サーバー側 (action) で実行する。
type ProjectionMethod = "pca" | "tsne";
interface ProjectionResult {
coords: Array<[number, number]>; // 入力順を保った 2D 座標 (各軸 0..1 正規化)
method: ProjectionMethod; // 実際に使われた手法
originalDimensions: number;
variance?: number[]; // PCA のみ: 上位 2 主成分の寄与率
}
const TSNE_MAX_POINTS = 2000; // 超えると t-SNE (O(n^2)) を中止し PCA にフォールバック
function projectToPlane(vectors, method, options?): ProjectionResult;domain/clustering.ts
k-means クラスタリング (k-means++ 初期化 + Lloyd 法、純 TS)。シード固定の決定的 PRNG を使うため同じ入力なら同じ結果になる。高次元 embedding (事前クラスタ) にも 2D 座標 (事後クラスタ) にも使う。
interface KMeansResult {
k: number;
assignments: number[]; // 入力順のクラスタ id (0..k-1)。空クラスタは作らない
centroids: number[][];
}
function kMeansCluster(vectors, k, options?): KMeansResult;
function defaultClusterCount(n: number): number; // 件数からの自動決定 (2..12)
function euclideanDistance(a, b): number;Ports (依存注入インターフェース)
application/ports.ts で全ての I/O 境界を定義しています。ユースケース関数は必ずここを経由してください。
| Port | 用途 | 主な実装 |
|---|---|---|
KeyValueStore | 重複排除セッション | CfKvStore (Worker) |
EmojiVectorStore | 絵文字ベクトルの保存・検索・削除 (get は 1 件のメタデータ取得、listStored は sync の差分計算用の軽量一覧、listPage は一覧ページング — 件数に比例しないコストで 1 ページ分 + 総件数を返す) | VectorizeEmojiStore (ベクトル: Vectorize / メタデータ: D1 emojis) |
ExcludedEmojiStore | 除外絵文字リスト | R2ExcludedEmojiStore |
AiGateway | 埋め込み (マルチ input バッチ)・caption バッチ (submit / check)・caption 単件生成 (generateCaption、追加指示つき再解析用)・文脈フレーズ生成・可視化クラスタのカテゴリラベル + 説明生成 (generateClusterLabels、全クラスタを 1 リクエストで)。models で現行モデル名を公開 | CfAiGateway |
SlackGateway | Slack Web API | WebApiSlackGateway |
SyncStateStore | 同期ジョブの status / plan / captions の永続化 (Workflow step 間の受け渡し)。putStatus は instance_id で upsert され履歴を兼ね、getStatus は最新行、listJobs でページング参照できる | D1SyncStateStore (D1 sync_jobs / sync_job_data) |
SyncJobRunner | 同期ジョブ実行基盤の抽象 (起動・実行中判定) | WorkflowSyncJobRunner |
AiGateway の caption 生成は Gemini Batch API 前提の非同期 2 段 (submit → check)、embedding は Gemini batchEmbedContents (1 リクエストに複数 caption) で、同期ジョブでは絵文字 1 件ごとの API 呼び出しを行わない。例外は個別の再解析用 generateCaption (単件・同期、extraInstructions でプロンプトに追加指示を渡せる) で、一括処理に使ってはいけない。
新しい外部依存を追加するときは:
ports.tsにインターフェースを追加- ユースケース関数で依存を引数で受ける
infrastructure/に実装を追加composition/web-deps.tsの composition root (buildWebDeps/buildSyncDeps/buildProcessStampDeps) から注入
をこの順で行います。ユースケース関数の中から fetch / Cloudflare global / Slack SDK を直接呼ばないこと。
ユースケース関数
processStampRequest(request, deps)
noisy-crow-events Worker の /slack/events ハンドラが、署名検証後に ctx.waitUntil から呼ぶ (旧構成では Queue Consumer が呼んでいた)。SESSIONS_KV の event:<eventId> で重複チェックし、重複なら skipped_duplicate で抜ける。詳細なアルゴリズムは architecture.md の「データフロー」参照。
deps.config:
| key | デフォルト | 意味 |
|---|---|---|
similarityThreshold | 0.6 | Vectorize の類似度フィルタしきい値 (env SIMILARITY_THRESHOLD) |
maxEmojiLimit | 3 | reactions.add する上限 |
minEmojisRequired | 2 | これ未満なら reactions を付けず completed_no_results |
しきい値は noisy-crow-events の env (SIMILARITY_THRESHOLD = "0.6") で上書き可。maxEmojiLimit / minEmojisRequired はコード側のデフォルト (3 / 2) を使う。
sync-emoji-vectors.ts (同期ジョブ)
旧 syncEmojiVectors() (リクエスト内で全件直列処理) は廃止し、起動・参照 と Workflow の各 step に分割した。
起動・参照 (UI action / loader / cron が呼ぶ。deps は { syncState, runner }):
startEmojiSync(deps, mode)— 実行中ジョブがなければ status (D1sync_jobs) を planning で書いてから Workflow を起動する。実行中なら{ started: false }を返す (二重起動防止)getSyncStatus(deps)— 現在のSyncJobStatus。status が active なのに Workflow インスタンスが消えている場合は failed に倒して返す (自己修復)listSyncHistory(deps, { page, perPage })— 過去ジョブの履歴 (D1sync_jobs、startedAt 降順) をページングで返す。/syncsページが使う
Workflow step (deps は { ai, vectors, slack, syncState }。SyncEmojiVectorsWorkflow が順に呼ぶ):
| step 関数 | 処理 |
|---|---|
planEmojiSync | emoji.list × listStored() の差分計算。mode に応じて対象を決め、plan を D1 (sync_job_data) へ。手動編集 caption (captionModel: "manual") は rebuild でも caption 再生成の対象にせず、embedding が古い場合だけ pinnedCaptions つきで対象にする。戻り値は件数のみ |
deleteRemovedEmojis | Slack から消えた絵文字を Vectorize + D1 から削除 |
submitEmojiCaptionBatch | Gemini Batch API へ caption 生成を一括投入し batch 名を返す。pinned は除外し、全件 pinned なら captions を確定して batchName: null (Workflow はポーリング省略) |
checkEmojiCaptionBatch | バッチ完了確認。完了したら captions (pinned とマージ) を D1 (sync_job_data) へ (個別失敗は名前ベース fallback + errors 記録)。pinned 以外が全件パース不能なら failed |
embedEmojiChunk | 50 件 chunk の caption を Gemini で一括 embedding → upsert (モデル名つき。pinned は captionModel: "manual" を維持)。processed は冪等に絶対値更新 |
completeSync / failSync | status (D1 sync_jobs) の終端遷移 |
step 関数は リトライされても結果が変わらない (冪等) ように書く。大きなデータ (plan / captions) は step の戻り値にせず SyncStateStore 経由で受け渡す。
updateEmojiCaption(input, deps) / reanalyzeEmoji(input, deps) (個別更新)
update-emoji-caption.ts。Dashboard の絵文字詳細ページ (/emojis/:name) が叩く、1 件だけ の caption 修正 / 再解析。deps は { vectors, ai, syncState }。
updateEmojiCaption({ name, description }, deps)— 手動で修正した caption で embedding を作り直して upsert する。captionModelにMANUAL_CAPTION_MODEL("manual") を記録し、rebuild でも caption が再生成されない (再生成したいときはreanalyzeEmojiを使う)reanalyzeEmoji({ name, extraInstructions? }, deps)—AiGateway.generateCaption(単件・同期) で caption を再生成し、embedding を作り直して upsert する。extraInstructionsでプロンプトに追加指示を渡せる。結果は通常のモデル生成 caption として記録する
どちらも caption 1 件 + embedding 1 件の定数時間なので Workflow を使わず action 内で同期実行する (全件バッチの sync とは性質が異なる)。sync ジョブの実行中は拒否する (plan 済みのバッチ結果に上書きされうるため)。未登録の絵文字名や空 caption はエラー。
listEmojis(params, deps) / excludeEmoji(input, deps) / includeEmoji(name, deps)
Dashboard の Emojis ページが叩く CRUD。listEmojis({ page, perPage, filter? }, deps) は EmojiVectorStore.listPage (D1) で 1 ページ分 + 総件数 だけを返す — 全件を返すとインデックス件数に比例して遅くなるため。filter は name / description の部分一致でストア側 (SQL LIKE) が解決する。除外リストは R2 上の単一 JSON (件数が少ないので毎回全件)。Vectorize 側の vector は消さず、付与時に除外リストでフィルタする方針。
searchEmojis(input, deps)
クエリ文字列から query embedding を作って Vectorize で類似検索。Dashboard の Search ページが利用。
visualizeEmojis(input, deps)
visualize-emojis.ts。Dashboard の Visualization ページ (/visualization の intent=generate) が叩く。deps は { vectors, ai }。入力は { method: "pca" | "tsne", queries?: string[], clusterCount? }。
サーバー側 (action) で次を順に実行し、可視化データ一式 (EmojiVisualization) を返す:
listWithVectors()で全絵文字ベクトルを取得 (0 件ならエラー)。query 文字列はgenerateQueryEmbeddingで embedding する- 事前クラスタリング: 次元削減前の高次元 (1024 次元) embedding を k-means でクラスタリング
- 投影:
projectToPlane(PCA / t-SNE) で絵文字 + クエリをまとめて 2D へ (t-SNE は 2000 点超で PCA フォールバック + 警告) - 事後クラスタリング: 投影後の 2D 座標を同じ k で k-means クラスタリング
- AI ラベル生成: 事前/事後それぞれのクラスタについて、centroid 近傍順の代表メンバー (名前 + caption、最大 40 件/クラスタ) を
AiGateway.generateClusterLabelsに渡し、カテゴリ名 (label) と説明 (description) を生成する (並列 2 リクエスト)
クラスタ数は clusterCount (2..30 にクランプ) か、省略時は defaultClusterCount(n) (2..12)。AI ラベル生成の失敗は可視化全体を失敗させない: 「クラスタ N」の仮ラベルにフォールバックし warnings で伝える。戻り値の点 (VisualizationPoint) は 2D 座標 + preCluster / postCluster の id を持ち、1024 次元ベクトルはブラウザへ返さない。query 点には高次元空間での近傍絵文字上位 10 件 (nearestEmojis) を付与する。
不変条件
- ユースケースは 副作用を持つが純粋に書く: 引数
deps以外から I/O しない。テスト時はports.tsのモックを差し込めば動く。 processStampRequestは冪等に近づけている (重複イベントはskipped_duplicate)。が、Slack の reactions.add 自体は二重実行されると "already_reacted" エラーになる点に注意。WebApiSlackGateway.addReactionはalready_reactedを成功扱いに変換すること (実装側の不変条件)。EmojiVector.lastUpdatedは ISO8601 文字列。Date オブジェクトを永続化しない。