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

NEW · Workers AI + Vectorize

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

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

0 / 500

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

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

なお、ページ上部の利用回数は「合計回数」という1つの数字だけを匿名で集計したものです。誰が・いつ・何を入力したかは記録していません(集計コードもソース公開に含まれています)。

APIソースコード(similar/coach)

functions/api/similar.js

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

import { bumpCounter } from "./_shared.js";

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 = [];
  }

  context.waitUntil(bumpCounter(env, "stats:checks"));

  return jsonResponse(response, 200);
}

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

functions/api/coach.js

/**
 * ============================================================================
 * プライバシーの誓約 / PRIVACY PLEDGE
 * ----------------------------------------------------------------------------
 * このエンドポイントは、入力されたアイデア文(q)を
 *   ・console.log 等でログに出力しません
 *   ・KV / D1 / R2 など、いかなるストレージにも保存しません
 *   ・外部の第三者サービスへ転送・送信しません(Workers AI / Vectorize への
 *     問い合わせのみを行い、いずれも本 Cloudflare アカウント内で完結します)
 * サーバー側で行うのは「埋め込みベクトルの計算」「類似検索」「LLMによる
 * コーチング文の生成」のみです。リクエストが終わればアイデア文はどこにも
 * 残りません。
 * レート制限のために IP アドレスを一時的に使いますが、生の IP は保存せず
 * SHA-256 でハッシュ化した値の先頭16文字のみを KV に 1 時間だけ保持します
 * (ハッシュから元の IP を復元することはできません)。
 * 作者自身もこのハッカソンの参加者であり、他の参加者と同じ立場で
 * アイデアの流出リスクを負いたくないからこそ、この設計を選んでいます。
 *
 * このエンドポイントは審査員ではありません。あくまで壁打ち相手となる
 * 「AIコーチ」であり、数値スコアや合否の断定的な予測は一切行いません。
 * ============================================================================
 */

import { bumpCounter } from "./_shared.js";

const MAX_QUERY_LENGTH = 500;
const TOP_K = 5;
const SUMMARY_TRUNCATE = 200;

const RATE_LIMIT_MAX = 10;
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60; // 1時間

// LLM 呼び出し1回あたりのタイムアウト(ミリ秒)。生成には 5〜20秒程度かかるため
// 余裕を見て設定する。primary が失敗/パース不能なら fallback に切り替える。
const LLM_TIMEOUT_MS = 25000;

// Workers AI: 日本語での指示追従・JSON構造化出力の質を検証した上で選定。
//   primary  = @cf/openai/gpt-oss-120b … 最も自然で具体的な日本語、
//              response_format:"json_object" 指定時に content へ純粋な
//              JSON のみを返す(reasoning はログに混ざらない別フィールド)。
//   fallback = @cf/meta/llama-3.3-70b-instruct-fp8-fast … primary が
//              エラー/パース不能だった場合の代替。応答は response フィールド
//              に入り、コードフェンスで囲まれることがあるため抽出処理で吸収する。
const MODELS = [
  {
    id: "@cf/openai/gpt-oss-120b",
    options: { max_tokens: 3000, reasoning: { effort: "low" } }
  },
  {
    id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
    options: { max_tokens: 2000 }
  }
];

const SYSTEM_PROMPT =
  "あなたはハッカソン出場者を支援する経験豊富なメンターです。" +
  "公式の5審査基準 — データ活用・アイデア力・技術力・ソーシャルインパクト・サービスデザイン " +
  "— の観点で、与えられたアイデアを建設的にコーチしてください。" +
  "提示される過去の類似作品との差分にも具体的に触れてください。" +
  "出力は必ず以下のJSONスキーマのみとし、それ以外の文章(前置き・説明・コードフェンス等)は一切含めないでください。" +
  "数値による採点は行わないでください。「受賞できる/できない」等の断定的な合否予測もしないでください。" +
  "JSONスキーマ: " +
  '{"criteria": [{"name": "データ活用", "strength": "...", "advice": "..."}, ' +
  '{"name": "アイデア力", "strength": "...", "advice": "..."}, ' +
  '{"name": "技術力", "strength": "...", "advice": "..."}, ' +
  '{"name": "ソーシャルインパクト", "strength": "...", "advice": "..."}, ' +
  '{"name": "サービスデザイン", "strength": "...", "advice": "..."}], ' +
  '"differentiation": "先行類似作品との差分についての助言(作品名を具体的に挙げること)", ' +
  '"questions": ["想定質問1", "想定質問2", "想定質問3"], ' +
  '"next_action": "今週できる具体的な次の一手"}';

const RETRY_INSTRUCTION =
  "直前の出力はJSONとして不正、またはスキーマに一致していませんでした。" +
  "説明や前置き、コードフェンスを一切含めず、指定されたJSONスキーマのオブジェクトのみを出力し直してください。";

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);
}

function serverError(message) {
  return jsonResponse({ error: message }, 502);
}

// --- similar.js と同一の防御的ベクトル抽出 -------------------------------
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;
    return null;
  }
  if (typeof d === "object") {
    if (Array.isArray(d.values)) return d.values;
    if (Array.isArray(d.embedding)) return d.embedding;
  }
  return null;
}

function mapWorkMatch(m) {
  const meta = m.metadata || {};
  return { i: meta.i, score: m.score };
}

function truncate(text, n) {
  if (!text) return "";
  return text.length > n ? text.slice(0, n) + "…" : text;
}

// --- レート制限 -------------------------------------------------------------
// 生の IP は一切保存しない。SHA-256 ハッシュの先頭16文字のみを KV キーに使う。
async function hashIp(ip) {
  const enc = new TextEncoder().encode(ip);
  const digest = await crypto.subtle.digest("SHA-256", enc);
  const bytes = new Uint8Array(digest);
  let hex = "";
  for (let i = 0; i < bytes.length; i++) {
    hex += bytes[i].toString(16).padStart(2, "0");
  }
  return hex.slice(0, 16);
}

async function checkRateLimit(env, request) {
  if (!env.RL) {
    // KV 未設定(ローカル開発など)の場合は制限をスキップする。
    return { limited: false };
  }
  const ip = request.headers.get("CF-Connecting-IP") || "unknown";
  const hash = await hashIp(ip);
  const key = "rl:" + hash;
  let current = 0;
  try {
    const raw = await env.RL.get(key);
    current = raw ? parseInt(raw, 10) || 0 : 0;
  } catch (err) {
    // KV 読み取り失敗時は fail-open(コーチ機能を止めない)。
    return { limited: false };
  }
  if (current >= RATE_LIMIT_MAX) {
    return { limited: true };
  }
  try {
    await env.RL.put(key, String(current + 1), { expirationTtl: RATE_LIMIT_WINDOW_SECONDS });
  } catch (err) {
    // 書き込み失敗はリクエスト自体をブロックしない。
  }
  return { limited: false };
}

// --- LLM 呼び出し -----------------------------------------------------------
function withTimeout(promise, ms) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(function () {
      reject(new Error("timeout"));
    }, ms);
    promise.then(
      function (v) {
        clearTimeout(timer);
        resolve(v);
      },
      function (e) {
        clearTimeout(timer);
        reject(e);
      }
    );
  });
}

// gpt-oss 系(OpenAI 互換 chat.completion 形式)と llama 系({response: "..."})
// の両方のレスポンス形状を吸収する。
function extractText(resp) {
  if (!resp) return null;
  if (typeof resp.response === "string") return resp.response;
  if (Array.isArray(resp.choices) && resp.choices[0] && resp.choices[0].message) {
    return resp.choices[0].message.content || null;
  }
  return null;
}

// コードフェンス除去 + 最初の "{" から最後の "}" までを抜き出してパースする。
function tryParseJson(text) {
  if (!text) return null;
  let cleaned = text.trim();
  cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "");
  const start = cleaned.indexOf("{");
  const end = cleaned.lastIndexOf("}");
  if (start === -1 || end === -1 || end < start) return null;
  const jsonStr = cleaned.slice(start, end + 1);
  try {
    return JSON.parse(jsonStr);
  } catch (err) {
    return null;
  }
}

function isValidCoachShape(obj) {
  if (!obj || typeof obj !== "object") return false;
  if (!Array.isArray(obj.criteria) || obj.criteria.length < 1) return false;
  for (const c of obj.criteria) {
    if (!c || typeof c.name !== "string" || typeof c.strength !== "string" || typeof c.advice !== "string") {
      return false;
    }
  }
  if (typeof obj.differentiation !== "string") return false;
  if (!Array.isArray(obj.questions)) return false;
  if (typeof obj.next_action !== "string") return false;
  return true;
}

async function callModel(env, model, messages) {
  const resp = await withTimeout(
    env.AI.run(
      model.id,
      Object.assign(
        {
          messages: messages,
          response_format: { type: "json_object" }
        },
        model.options
      )
    ),
    LLM_TIMEOUT_MS
  );
  const text = extractText(resp);
  const parsed = tryParseJson(text);
  return { text: text, parsed: parsed };
}

async function runCoachLLM(env, userPrompt) {
  const baseMessages = [
    { role: "system", content: SYSTEM_PROMPT },
    { role: "user", content: userPrompt }
  ];

  let lastErr = null;

  for (const model of MODELS) {
    try {
      const first = await callModel(env, model, baseMessages);
      if (first.parsed && isValidCoachShape(first.parsed)) {
        return first.parsed;
      }
      // パース失敗、またはスキーマ不一致 → 同モデルに1回だけ再試行。
      const retryMessages = baseMessages.concat([
        { role: "assistant", content: first.text || "" },
        { role: "user", content: RETRY_INSTRUCTION }
      ]);
      const retry = await callModel(env, model, retryMessages);
      if (retry.parsed && isValidCoachShape(retry.parsed)) {
        return retry.parsed;
      }
      lastErr = new Error("invalid JSON shape from " + model.id);
    } catch (err) {
      lastErr = err;
      // 次のモデル(fallback)へ。
    }
  }

  throw lastErr || new Error("all coach models failed");
}

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

  const rl = await checkRateLimit(env, request);
  if (rl.limited) {
    return jsonResponse({ error: "1時間あたりの利用上限に達しました。少し時間をおいてください。" }, 429);
  }

  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} 文字以内で入力してください。`);
  }

  // 1) 埋め込み計算 + 類似作品検索
  let vector;
  try {
    const embedResp = await env.AI.run("@cf/baai/bge-m3", { text: [q] });
    vector = extractVector(embedResp);
  } catch (err) {
    return serverError("埋め込みの計算に失敗しました。しばらくして再度お試しください。");
  }
  if (!vector) {
    return serverError("埋め込みの計算結果を解釈できませんでした。");
  }

  let similar = [];
  if (env.VEC_WORKS) {
    try {
      const result = await env.VEC_WORKS.query(vector, { topK: TOP_K, returnMetadata: "all" });
      const matches = (result && result.matches) || [];
      similar = matches.map(mapWorkMatch);
    } catch (err) {
      similar = []; // 検索失敗時は類似作品なしとして続行(コーチ自体は止めない)。
    }
  }

  // 2) works.json をクライアント同様に静的アセットとして取得し、i → タイトル/概要を解決する。
  let worksData = [];
  try {
    const assetUrl = new URL("/data/works.json", request.url);
    const worksResp = await env.ASSETS.fetch(new Request(assetUrl));
    if (worksResp.ok) {
      worksData = await worksResp.json();
    }
  } catch (err) {
    worksData = [];
  }

  const similarForPrompt = similar
    .map(function (entry) {
      const work = worksData[entry.i];
      if (!work) return null;
      return {
        title: work.title || "(タイトル不明)",
        year: work.year || "",
        summary: truncate(work.summary || "", SUMMARY_TRUNCATE)
      };
    })
    .filter(Boolean);

  // 3) LLM プロンプト組み立て
  const worksBlock = similarForPrompt.length
    ? similarForPrompt
        .map(function (w, idx) {
          return (idx + 1) + ". " + w.title + (w.year ? "(" + w.year + "年度)" : "") + " - " + w.summary;
        })
        .join("\n")
    : "(意味的に近い先行作品は見つかりませんでした)";

  const userPrompt =
    "アイデア: " + q + "\n\n" +
    "意味的に類似する過去の作品(Vectorize 検索結果 上位" + TOP_K + "件):\n" + worksBlock + "\n\n" +
    "上記スキーマの通り、JSONオブジェクトのみを出力してください。";

  // 4) LLM 呼び出し(primary → fallback、各1回まで整形リトライ)
  let coach;
  try {
    coach = await runCoachLLM(env, userPrompt);
  } catch (err) {
    return serverError("AIコーチの応答生成に失敗しました。混雑中の可能性があります。しばらくして再度お試しください。");
  }

  const responseBody = Object.assign({}, coach, {
    similar: similar
  });

  // 匿名利用カウンター(合計回数のみ。入力内容・IP・時刻は保存しない)
  context.waitUntil(bumpCounter(env, "stats:coach"));

  return jsonResponse(responseBody, 200);
}

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

functions/api/_shared.js(匿名カウンター)

// 匿名利用カウンター: 合計回数のみを KV でインクリメントする。
// 入力内容・IP・時刻など、個別リクエストに紐づく情報は一切保存しない。
// KV の read-modify-write は同時アクセス時にわずかに取りこぼす可能性があるが、
// 参考値としての集計なので許容する(正確な課金・制限用途には使わない)。
export async function bumpCounter(env, key) {
  try {
    const cur = parseInt(await env.RL.get(key), 10) || 0;
    await env.RL.put(key, String(cur + 1));
  } catch (err) {
    // 集計の失敗は本体機能に影響させない
  }
}

export async function readCounter(env, key) {
  try {
    return parseInt(await env.RL.get(key), 10) || 0;
  } catch (err) {
    return 0;
  }
}

functions/api/stats.js

// 匿名利用統計の参照エンドポイント(GET のみ)。
// 返すのは合計回数の2つの数値だけで、個別リクエストの情報は存在しない。
import { readCounter } from "./_shared.js";

export async function onRequestGet(context) {
  const { env } = context;
  const [checks, coach] = await Promise.all([
    readCounter(env, "stats:checks"),
    readCounter(env, "stats:coach")
  ]);
  return new Response(JSON.stringify({ checks: checks, coach: coach }), {
    status: 200,
    headers: {
      "content-type": "application/json; charset=utf-8",
      // 集計値は多少古くても構わないのでエッジ/ブラウザに60秒キャッシュさせ、KV 読み取りを抑える
      "cache-control": "public, max-age=60"
    }
  });
}

export async function onRequest() {
  return new Response(JSON.stringify({ error: "このエンドポイントは GET のみ対応しています。" }), {
    status: 405,
    headers: { "content-type": "application/json; charset=utf-8" }
  });
}