このページでは、E2E 以外で Jev を使った例として、道の駅のおすすめ機能を扱います。「どんな道の駅に行きたいですか?」に一行で答えると、訪問済みの 43 駅から合うものを最大 3 駅選んで返す機能です。Jev の Score で全駅を一度に採点し、上位を取ります。この機能は、道の駅の訪問記録サイト michinoekis.com で公開しています。

カードに出す文章は、各駅の既存のデータ(見どころ・写真・リンク)です。AI が文章を作ることはありません。
作り
- 判断の形はスコアリングにしました。 Choice は選択肢から 1 つしか選ばないので、上位 3 駅は取れません。駅ごとに Score(0=無関係〜3=ぴったり)で採点し、点の高い順に並べます
- 1 要望につき 1 リクエストです。
stateに要望を入れ、questionsに駅ごとの採点を 43 問並べます。公式の「Speculative fan-out」のパターンで、質問は並列に評価されます - Jev には文章だけを渡します。 各駅の説明文・キャッチ・見どころ・写真のキャプションを要約にして、質問の
instructionsに入れます - 数値や日付で決まる条件はコードで処理します。 県・距離・営業時間のような条件は、Jev に渡さずコード側で扱います
公式ドキュメントは、1 回のリクエストに質問をまとめる利点を次のように書いています。
Jev ingests the
stateonce and evaluates every question against it in parallel.
出典: https://docs.typesafe.ai/models.md(取得日: 2026-09-23)
20 件の要望で精度を測る
要望を 20 件用意し、上位に出てよい駅を手作業で決めて、Jev の結果と照らしました。うち 3 件(北海道・スキー・カヌー)は、訪問済みの駅に該当するものが無く、「該当なし」を返してほしい要望です。渡し方は 3 通り比べました。要約・質問・要望をすべて日本語で渡す(ja)、要約と質問だけ英訳する(en)、要望まで英訳する(en-q)の 3 つです。
検証に使ったスクリプトの全文です。要約(summaries.*.json)と要望(queries.json)は別のファイルに置いています。
// scripts/jev-recommend/score.mjs
// issue #650 Phase 1:queries.json の要望ごとに、訪問済みの駅をJevのScoreで採点し、上位3駅が妥当かを確かめる。
//
// node scripts/jev-recommend/score.mjs # 3モードすべて
// node scripts/jev-recommend/score.mjs --mode ja # 1モードだけ
// node scripts/jev-recommend/score.mjs --dry-run # APIを呼ばずにリクエストの大きさだけ見る
//
// APIキーは環境変数 TYPESAFE_API_KEY か、~/.config/roadside-station/typesafe-api-key(GSCの認証ファイルと同じ置き場)から読む。
//
// モード(要約の言語 × 要望の言語):
// ja … 要約・質問文・要望すべて日本語
// en … 要約・質問文は英訳、要望は日本語のまま(本番で要望を訳さずに投げる場合の想定)
// en-q … 要約・質問文・要望すべて英語(要望まで英語にした場合の上限)
//
// 1要望につき1リクエスト。stateに要望を入れ、questionsに駅ごとのScoreを1問ずつ並べる(公式のfan-outパターン)。
// 結果の要約は scripts/jev-recommend/results.md に、生のレスポンスは tmp/jev-recommend/ に書き出す。
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const rawDir = join(here, '../../tmp/jev-recommend')
const ENDPOINT = 'https://api.typesafe.ai/v1/systemone'
const MODEL = 'jev-latest'
const PROMPTS = {
ja: {
question:
'state の `user_request` は、ユーザーが行きたい道の駅についての要望です。`station` の道の駅は、この要望にどの程度合っていますか?',
criteria: [
'要望とまったく関係がない',
'要望に少しだけ関係する要素がある',
'要望にある程度合っている',
'要望にぴったり合っている',
],
},
en: {
question:
'`user_request` in the state is what the user wants from a roadside station (michi-no-eki) visit. How well does the roadside station described in `station` match this request?',
criteria: [
'Unrelated to the request',
'Has something slightly related to the request',
'Matches the request reasonably well',
'Matches the request very well',
],
},
}
const MODES = {
ja: { summaries: 'summaries.ja.json', prompt: 'ja', queryLang: 'ja' },
en: { summaries: 'summaries.en.json', prompt: 'en', queryLang: 'ja' },
'en-q': { summaries: 'summaries.en.json', prompt: 'en', queryLang: 'en' },
}
const args = process.argv.slice(2)
const dryRun = args.includes('--dry-run')
const modeArg = args.includes('--mode') ? args[args.indexOf('--mode') + 1] : null
const modes = modeArg ? [modeArg] : Object.keys(MODES)
for (const m of modes) if (!MODES[m]) throw new Error(`不明なモード: ${m}`)
const keyFile = join(homedir(), '.config/roadside-station/typesafe-api-key')
const apiKey = process.env.TYPESAFE_API_KEY ?? (existsSync(keyFile) ? readFileSync(keyFile, 'utf8').trim() : undefined)
if (!dryRun && !apiKey) {
console.error(`APIキーがありません。TYPESAFE_API_KEY か ${keyFile} に置いてください(--dry-run ならAPIを呼ばずに実行できます)`)
process.exit(1)
}
const { queries } = JSON.parse(readFileSync(join(here, 'queries.json'), 'utf8'))
const load = (f) => JSON.parse(readFileSync(join(here, f), 'utf8'))
function buildRequest(mode, query) {
const { summaries, prompt, queryLang } = MODES[mode]
const p = PROMPTS[prompt]
const questions = {}
for (const { slug, ...station } of load(summaries)) {
questions[slug] = {
type: 'score',
instructions: { station, question: p.question },
criteria: p.criteria,
}
}
return { state: { user_request: query[queryLang] }, model: MODEL, questions }
}
async function callJev(body) {
for (let attempt = 0; ; attempt++) {
const started = performance.now()
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const ms = Math.round(performance.now() - started)
if ((res.status === 429 || res.status === 529) && attempt < 4) {
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt))
continue
}
if (!res.ok) throw new Error(`Jev ${res.status}: ${await res.text()}`)
return { json: await res.json(), ms }
}
}
const fmt = (n) => n.toFixed(2)
const report = [
'# Jev採点の検証結果(issue #650 Phase 1)',
'',
`生成:${new Date().toISOString()}(\`node scripts/jev-recommend/score.mjs\`)`,
'',
'Scoreは0〜3(0=無関係、3=ぴったり)。「妥当」は queries.json の acceptable に入っている駅。acceptable が空の要望は「該当なし」を返してほしいもので、1位のスコアが低いほどよい。',
'',
]
mkdirSync(rawDir, { recursive: true })
for (const mode of modes) {
report.push(`## モード:${mode}`, '')
report.push('| 要望 | 1位 | 2位 | 3位 | 1位妥当 | 上位3の妥当数 | ms |', '| --- | --- | --- | --- | --- | --- | --- |')
let top1 = 0
let prec = 0
let answerable = 0
let tokens = 0
const raws = {}
const noMatchTop = []
const matchTop = []
for (const q of queries) {
const body = buildRequest(mode, q)
if (dryRun) {
const chars = JSON.stringify(body).length
const longest = Math.max(...Object.values(body.questions).map((x) => JSON.stringify(x).length))
console.log(`[${mode}] ${q.id}: リクエスト全体 ${chars}字 / 最長の質問 ${longest}字`)
continue
}
const { json, ms } = await callJev(body)
raws[q.id] = json
tokens += json.usage?.input_tokens ?? 0
const ranked = Object.entries(json.answers)
.map(([slug, a]) => ({ slug, score: a.score, confidence: a.confidence }))
.sort((a, b) => b.score - a.score)
const top = ranked.slice(0, 3)
const ok = (s) => q.acceptable.includes(s)
const cell = (r) => `${ok(r.slug) ? '**' : ''}${r.slug}${ok(r.slug) ? '**' : ''} ${fmt(r.score)}`
if (q.acceptable.length) {
answerable++
if (ok(top[0].slug)) top1++
const hits = top.filter((r) => ok(r.slug)).length
prec += hits / Math.min(3, q.acceptable.length)
matchTop.push(top[0].score)
report.push(`| ${q.ja} | ${top.map(cell).join(' | ')} | ${ok(top[0].slug) ? '○' : '×'} | ${hits}/${Math.min(3, q.acceptable.length)} | ${ms} |`)
} else {
noMatchTop.push(top[0].score)
report.push(`| ${q.ja}(該当なし期待) | ${top.map(cell).join(' | ')} | - | - | ${ms} |`)
}
console.log(`[${mode}] ${q.id}: ${top.map((r) => `${r.slug}(${fmt(r.score)})`).join(', ')} ${ms}ms`)
}
if (dryRun) continue
writeFileSync(join(rawDir, `${mode}.json`), `${JSON.stringify(raws, null, 2)}\n`)
report.push(
'',
`- 1位が妥当:${top1}/${answerable}`,
`- 上位3の妥当率(平均):${fmt((prec / answerable) * 100)}%`,
`- 該当ありの要望の1位スコア:最小 ${fmt(Math.min(...matchTop))}/中央 ${fmt(matchTop.sort((a, b) => a - b)[Math.floor(matchTop.length / 2)])}`,
`- 該当なしの要望の1位スコア:${noMatchTop.map(fmt).join('、')}`,
`- 入力トークン合計:${tokens}(${queries.length}リクエスト)`,
'',
)
}
if (!dryRun) {
writeFileSync(join(here, 'results.md'), `${report.join('\n')}\n`)
console.log('scripts/jev-recommend/results.md に書き出しました')
}
日本語のままでも、1 位は 17 件すべてで妥当でした。英訳しても精度はほとんど変わらず、要望まで英訳すると上位 3 の妥当率はかえって下がりました。公式は英語以外では精度が下がるとしていますが、この用途では日本語のままで足りました。要約を英訳すると入力トークンは約 2 割減ります。それでも、駅のデータを更新するたびに英訳を作り直す手間に見合わないので、日本語のまま渡すことにしました。
費用は、ja モードで 1 回あたり入力約 36,800 トークンです。入力 100 万トークンあたり 0.042 ドルなので、1 回約 0.0015 ドル、1 日 1,000 回呼ばれても約 1.5 ドルです。
「該当なし」を足切りで表す
選択肢に「該当なし」を入れなくても、全駅のスコアが低いことで「該当なし」を表せます。ja モードの結果に「スコア 2.5 未満の駅は出さない」という足切りを当てはめると、次のようになりました。
- 該当なしを期待した 3 件は、1 位でも 1.99 以下なので、すべて「該当なし」になる
- 該当ありの要望で表示される駅は 35 件で、そのうち 34 件が妥当
- 外れた 1 件は、「足湯」の 3 位に入った駅(2.70)。温浴施設はありますが、要約に足湯の記載はありません
公開中のサイトでも、北海道の要望には何も出しません。

要約に無いことは採点されない
うまくいかなかった要望は、どれも要約に情報が無いことが原因でした。
- 「夜遅くでもカフェでくつろげるところ」は、1 位でも 1.76 で、2.5 の足切りで何も出ません。要約に営業時間を入れていないので当然です。こうした条件はコード側で処理します
- 「いちごのスイーツ」で、いちごがキャッチに一語あるだけの駅は 1.47 止まりでした
精度を上げるには、Jev の使い方を工夫するより、駅のデータのほうを充実させるのが近道です。
分かったこと
- 候補の数が決まっていて、要望との相性で並べたいときは、Score を並列に聞く形がそのまま使えます。E2E の Choice で困った「正解が複数あって確率が割れる」問題は起きません
- 足切りの閾値を決めておけば、「該当なし」も Jev の結果から判断できます
- AI が文章を作らないので、事実を創作される心配がありません。悪意のある入力を受けても、出力は駅の採点にとどまります
2.5 の閾値は、手元の 20 件の要望で決めた値です。実際の利用者の要望でも通用するかは、これから確かめます。