0 / 500
似ている過去作品 TOP10
類似度は意味ベクトルの近さであり「かぶり判定」の絶対的基準ではありません。
使えそうなデータセット TOP10
東京都オープンデータカタログ 全9,642データセットから検索
AIコーチに深掘りしてもらう
AIによる参考コーチングです。審査・事務局とは無関係で、内容の正確性・受賞可能性を保証しません。5基準の名称のみ公式募集要項から引用しています。入力は保存されません。
コーチが考えています…(5〜20秒ほどかかります)
審査で聞かれそうな質問
入力されたアイデアは保存されません
サーバー側では埋め込み計算・類似検索・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" }
});
}