リソースハブ / アイデア壁打ちAI

NEW · Workers AI + Vectorize

アイデア壁打ちAI — かぶりチェッカー

アイデアを一文で入力すると、過去5年・全442作品から「意味的に似ている先行作品」と、東京都オープンデータカタログから「使えそうなデータセット」をAIが即座に提案します。

0 / 500

入力されたアイデアは保存されません

サーバー側では埋め込み計算と類似検索のみを行い、入力テキストをログ・保存・第三者送信しません。作者自身も参加者だからこそ、これを設計で保証します。

APIのソースコードを見る(functions/api/similar.js)
/**
 * ============================================================================
 * プライバシーの誓約 / PRIVACY PLEDGE
 * ----------------------------------------------------------------------------
 * このエンドポイントは、入力されたアイデア文(q)を
 *   ・console.log 等でログに出力しません
 *   ・KV / D1 / R2 など、いかなるストレージにも保存しません
 *   ・外部の第三者サービスへ転送・送信しません(Workers AI / Vectorize への
 *     問い合わせのみを行い、いずれも本 Cloudflare アカウント内で完結します)
 * サーバー側で行うのは「埋め込みベクトルの計算」と「類似検索」のみです。
 * リクエストが終わればテキストは破棄され、どこにも残りません。
 * 作者自身もこのハッカソンの参加者であり、他の参加者と同じ立場で
 * アイデアの流出リスクを負いたくないからこそ、この設計を選んでいます。
 * ============================================================================
 */

const MAX_QUERY_LENGTH = 500;
const TOP_K = 10;
const VALID_TARGETS = ["works", "datasets"];

function jsonResponse(body, status) {
  return new Response(JSON.stringify(body), {
    status: status || 200,
    headers: {
      "content-type": "application/json; charset=utf-8",
      "cache-control": "no-store"
    }
  });
}

function badRequest(message) {
  return jsonResponse({ error: message }, 400);
}

// AI.run() のレスポンス形状はモデル/バージョンによって揺れがあるため、
// {data: [[...]]} と {data: {...}} の両方を防御的に処理する。
function extractVector(resp) {
  if (!resp || !resp.data) return null;
  const d = resp.data;
  if (Array.isArray(d)) {
    const first = d[0];
    if (Array.isArray(first)) return first;
    if (first && typeof first === "object" && Array.isArray(first.values)) return first.values;
    if (typeof first === "number") return d; // data 自体が単一ベクトルだった場合
    return null;
  }
  if (typeof d === "object") {
    if (Array.isArray(d.values)) return d.values;
    if (Array.isArray(d.embedding)) return d.embedding;
  }
  return null;
}

async function queryIndex(index, vector, label, mapMatch) {
  if (!index) {
    return { list: [], note: `${label} インデックスが利用できません(未設定)。` };
  }
  try {
    const result = await index.query(vector, { topK: TOP_K, returnMetadata: "all" });
    const matches = (result && result.matches) || [];
    if (!matches.length) {
      return { list: [], note: `${label} インデックスにまだデータがありません。` };
    }
    const list = matches.map(mapMatch);
    return { list: list };
  } catch (err) {
    // インデックス側の障害は 500 にせず、当該セクションのみ空扱いにする。
    return { list: [], note: `${label} の検索中にエラーが発生しました。` };
  }
}

// 過去作品インデックス: メタデータは works.json の配列インデックス(i)のみを持つ。
// タイトル等の表示情報はクライアント側で ./data/works.json と突き合わせる。
function mapWorkMatch(m) {
  const meta = m.metadata || {};
  return { i: meta.i, score: m.score };
}

// データセットインデックス: メタデータ自体に表示に必要な情報(タイトル/組織/
// ライセンス/カタログURL)を持たせてあるため、クライアント側で 3.3MB の
// datasets.json を取得する必要がない。ここで直接展開して返す。
function mapDatasetMatch(m) {
  const meta = m.metadata || {};
  return { score: m.score, t: meta.t, o: meta.o, lic: meta.lic, url: meta.url };
}

export async function onRequestPost(context) {
  const { request, env } = context;

  let payload;
  try {
    payload = await request.json();
  } catch (err) {
    return badRequest("リクエストボディは JSON である必要があります。");
  }

  const rawQ = payload && typeof payload.q === "string" ? payload.q : null;
  if (rawQ === null) {
    return badRequest("q(アイデア文)は必須です。");
  }
  const q = rawQ.trim();
  if (!q) {
    return badRequest("q(アイデア文)は必須です。");
  }
  if (q.length > MAX_QUERY_LENGTH) {
    return badRequest(`q は ${MAX_QUERY_LENGTH} 文字以内で入力してください。`);
  }

  let targets = VALID_TARGETS;
  if (Array.isArray(payload.targets) && payload.targets.length) {
    targets = payload.targets.filter(function (t) { return VALID_TARGETS.indexOf(t) !== -1; });
    if (!targets.length) {
      return badRequest("targets は works / datasets のいずれかを含めてください。");
    }
  }

  let vector;
  try {
    const embedResp = await env.AI.run("@cf/baai/bge-m3", { text: [q] });
    vector = extractVector(embedResp);
  } catch (err) {
    return jsonResponse({ error: "埋め込みの計算に失敗しました。しばらくして再度お試しください。" }, 502);
  }

  if (!vector) {
    return jsonResponse({ error: "埋め込みの計算結果を解釈できませんでした。" }, 502);
  }

  const response = {};

  if (targets.indexOf("works") !== -1) {
    const r = await queryIndex(env.VEC_WORKS, vector, "過去作品", mapWorkMatch);
    response.works = r.list;
    if (r.note) response.worksNote = r.note;
  } else {
    response.works = [];
  }

  if (targets.indexOf("datasets") !== -1) {
    const r = await queryIndex(env.VEC_DATA, vector, "データセット", mapDatasetMatch);
    response.datasets = r.list;
    if (r.note) response.datasetsNote = r.note;
  } else {
    response.datasets = [];
  }

  return jsonResponse(response, 200);
}

// Pages Functions はメソッド別ハンドラ(onRequestPost)を onRequest より優先して
// 呼び出すため、ここに到達するのは POST 以外のメソッドのみ。明示的に 405 を返す。
export async function onRequest() {
  return jsonResponse({ error: "このエンドポイントは POST のみ対応しています。" }, 405);
}