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

「子どもと一日遊べるところ」と入力すると、AI が選んだ道の駅が 3 件カードで表示される

カードに出す文章は、各駅の既存のデータ(見どころ・写真・リンク)です。AI が文章を作ることはありません。

作り

  • 判断の形はスコアリングにしました。 Choice は選択肢から 1 つしか選ばないので、上位 3 駅は取れません。駅ごとに Score(0=無関係〜3=ぴったり)で採点し、点の高い順に並べます
  • 1 要望につき 1 リクエストです。 state に要望を入れ、questions に駅ごとの採点を 43 問並べます。公式の「Speculative fan-out」のパターンで、質問は並列に評価されます
  • Jev には文章だけを渡します。 各駅の説明文・キャッチ・見どころ・写真のキャプションを要約にして、質問の instructions に入れます
  • 数値や日付で決まる条件はコードで処理します。 県・距離・営業時間のような条件は、Jev に渡さずコード側で扱います

公式ドキュメントは、1 回のリクエストに質問をまとめる利点を次のように書いています。

Jev ingests the state once 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 件の要望で決めた値です。実際の利用者の要望でも通用するかは、これから確かめます。