Skip to content

ドメインとユースケース

ここで述べるコア (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

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) のジョブ状態。

ts
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

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) で実行する。

ts
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 座標 (事後クラスタ) にも使う。

ts
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
SlackGatewaySlack Web APIWebApiSlackGateway
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 でプロンプトに追加指示を渡せる) で、一括処理に使ってはいけない。

新しい外部依存を追加するときは:

  1. ports.ts にインターフェースを追加
  2. ユースケース関数で依存を引数で受ける
  3. infrastructure/ に実装を追加
  4. 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_KVevent:<eventId> で重複チェックし、重複なら skipped_duplicate で抜ける。詳細なアルゴリズムは architecture.md の「データフロー」参照。

deps.config:

keyデフォルト意味
similarityThreshold0.6Vectorize の類似度フィルタしきい値 (env SIMILARITY_THRESHOLD)
maxEmojiLimit3reactions.add する上限
minEmojisRequired2これ未満なら 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 (D1 sync_jobs) を planning で書いてから Workflow を起動する。実行中なら { started: false } を返す (二重起動防止)
  • getSyncStatus(deps) — 現在の SyncJobStatus。status が active なのに Workflow インスタンスが消えている場合は failed に倒して返す (自己修復)
  • listSyncHistory(deps, { page, perPage }) — 過去ジョブの履歴 (D1 sync_jobs、startedAt 降順) をページングで返す。/syncs ページが使う

Workflow step (deps は { ai, vectors, slack, syncState }SyncEmojiVectorsWorkflow が順に呼ぶ):

step 関数処理
planEmojiSyncemoji.list × listStored() の差分計算。mode に応じて対象を決め、plan を D1 (sync_job_data) へ。手動編集 caption (captionModel: "manual") は rebuild でも caption 再生成の対象にせず、embedding が古い場合だけ pinnedCaptions つきで対象にする。戻り値は件数のみ
deleteRemovedEmojisSlack から消えた絵文字を Vectorize + D1 から削除
submitEmojiCaptionBatchGemini Batch API へ caption 生成を一括投入し batch 名を返す。pinned は除外し、全件 pinned なら captions を確定して batchName: null (Workflow はポーリング省略)
checkEmojiCaptionBatchバッチ完了確認。完了したら captions (pinned とマージ) を D1 (sync_job_data) へ (個別失敗は名前ベース fallback + errors 記録)。pinned 以外が全件パース不能なら failed
embedEmojiChunk50 件 chunk の caption を Gemini で一括 embedding → upsert (モデル名つき。pinned は captionModel: "manual" を維持)。processed は冪等に絶対値更新
completeSync / failSyncstatus (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 する。captionModelMANUAL_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 ページ (/visualizationintent=generate) が叩く。deps は { vectors, ai }。入力は { method: "pca" | "tsne", queries?: string[], clusterCount? }

サーバー側 (action) で次を順に実行し、可視化データ一式 (EmojiVisualization) を返す:

  1. listWithVectors() で全絵文字ベクトルを取得 (0 件ならエラー)。query 文字列は generateQueryEmbedding で embedding する
  2. 事前クラスタリング: 次元削減前の高次元 (1024 次元) embedding を k-means でクラスタリング
  3. 投影: projectToPlane (PCA / t-SNE) で絵文字 + クエリをまとめて 2D へ (t-SNE は 2000 点超で PCA フォールバック + 警告)
  4. 事後クラスタリング: 投影後の 2D 座標を同じ k で k-means クラスタリング
  5. 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.addReactionalready_reacted を成功扱いに変換すること (実装側の不変条件)。
  • EmojiVector.lastUpdated は ISO8601 文字列。Date オブジェクトを永続化しない。