コンテンツにスキップ

アセスメントの組み込み

このページは意図的に 1 ページで完結させてあります。アセスメントのガイドと 重複する内容も繰り返しているのは、これだけを渡せば Web サイトやボットにヒアリングを組み込める 状態にするためです (人に渡す場合も、コーディング支援の AI に渡す場合も)。

言語モデル向けのプレーンテキスト版は /ja/llms-assessment.txt にあります。

制作したサイトのサーバーが連携キーを持ち、API を呼びます。次にどの設問を聞くかと結果の算出は Minion が行い、サイトは設問を表示し、訪問者の返事を値に変換して送り返します。聞くことが なくなったらサイトがヒアリングを閉じ、ワークスペースの担当者が確認します。

Minion は自由記述を受け取らず、代わりに言語モデルを動かすこともありません。だから同じ API が、 ただのフォームにも、チャットボットにも、人が手で入力する画面にもなります。サイトが決めないのは 設問の順番です。返ってきた順に聞くことが、聞き漏れを防ぐ仕組みそのものです。

見た目どこから
API のベース URLhttps://minionworkspace.com/api/public/assessmentMinion を開いているオリジン + /api/public/assessment
連携キーasm_…カタログの 連携外部連携キー。表示されるのは発行時の 1 回だけ
カタログ識別子 (任意)web-production同じ 連携 の画面、キーの上。カタログ一覧の名前の下にも出ています

いずれもサーバー側の環境変数に置いてください (例: ASSESSMENT_API_BASEASSESSMENT_API_KEY)。 NEXT_PUBLIC_PUBLIC_VITE_ を付けてはいけません。付けるとブラウザに配る JavaScript に 埋め込まれます。

カタログが公開済みで、ワークスペースでアセスメントが有効になっている必要があります。

キーがカタログを決めます。 カタログを指定するリクエストはありません。別の連携のキーを貼っても エラーにはならず、黙って別のカタログの設問が出てきます。起動時に GET /catalog を 1 回呼び、 catalog.key が想定どおりかを確かめてください。

カタログ識別子は、この照合の当て先です。設定する理由はそれだけなので、任意の設定として扱って ください。 リクエストには一切乗らないため、未設定なら照合を飛ばすのが正しく、アプリを起動 できなくするのは筋が違います (キーが正しければ識別子が無くても動くので、必須にすると落ちる 理由が増えるだけです)。識別子はカタログを作るときに決まり、後から変わることはないので、 環境変数に焼いて構いません。

ブラウザ ──(自分のサイトのルート + Cookie)──▶ サイトのサーバー ──(Authorization: Bearer asm_…)──▶ Minion
  • API は CORS ヘッダを返しません。 ブラウザからは呼べず、それは意図したものです (呼べるなら キーがページに載ってしまう)。静的ホスティングだけでは足りません。SSR・ルートハンドラ・ サーバーレス関数・エッジ関数など、サーバーで動く部分が必要です。
  • キーは Authorization: Bearer ヘッダでだけ送ります。 クエリ文字列に入れたキーは読まれず、 api_key_required で失敗します。
  • サイトのルートは、その訪問者のヒアリングにだけ作用させます。 セッション ID は httpOnly Cookie に 置き、「はじめる・答える・わからない・送信する」といった決まった操作だけを用意してください。 ブラウザから来たパスやセッション ID をそのまま API へ転送してはいけません。ID さえ分かれば、 他人のヒアリングを読んだり削除したりできてしまいます。
1. POST /sessions → session.id, questions, result
2. questions[0] を表示する
3. POST /sessions/{id}/answers → 次の questions, 新しい result
(回答、または「わからない」)
4. questions が空になるまで 2〜3 を繰り返す
5. POST /sessions/{id}/close
  • questions が空配列なら、聞くことは残っていません。 done のようなフラグはありません。 設問の数は回答によって変わります。ある回答によって他の設問が無関係になれば、それらは消えます。
  • 返ってきた順に聞いてください。 並べ替え・飛ばし・追加をしてはいけません。
  • 一覧は回答のたびに計算し直されます。 複数の設問を同時に見せると、1 問目に答えた時点で後ろの 設問が不要になることがあります。1 問か数問ずつ見せ、答えを送ってから次を聞いてください。
  • いつ閉じても構いません。 残った未確定の事実は、確認する人に見える形で残ります。

limit で返ってくる設問の数を決めます。既定は 3、0 で全件です。1 画面 1 問、チャットの 1 発言 1 問にするなら limit: 1 です。

パスはすべてベース URL の下で、どのリクエストにもヘッダが必要です。

メソッドパス返すもの
POST/sessions201 { session, catalog, questions, result, progress }
GET/sessions/{id}?limit={ session, catalog, questions, result, progress } — 再開用に全部入り
POST/sessions/{id}/answers{ questions, topic_changed?, result, progress }
GET/sessions/{id}/answers{ answers, unknown }
DELETE/sessions/{id}/answers/{factId}{ questions, result, progress }
GET/sessions/{id}/questions?limit={ questions, progress }
GET/sessions/{id}/facts{ facts } — 全事実と、このヒアリングでの状態
GET/sessions/{id}/result{ result }
POST/sessions/{id}/close{ session }
DELETE/sessions/{id}{ ok: true }
GET/catalog{ catalog: { key, name, description, fact_count, measure_count } }
GET/catalog/facts{ facts } — 全設問をカタログの順で
GET/catalog/measures{ measures } — 全軸をカタログの順で

キーが届くのは、そのキーで始めたヒアリングだけです。 別のキーで始めたもの、Minion の画面で 作ったアセスメントの ID を指定すると、存在しない ID と同じく 404 が返ります。

POST /api/public/assessment/sessions
Authorization: Bearer asm_…
Content-Type: application/json
{ "title": "株式会社◯◯ サイトリニューアル", "limit": 1 }

どちらのフィールドも任意です。

  • title は、確認する人が一覧で見る名前です。 後から変更できません。省略するとカタログ名と 日時になり、見分けにくくなります。ヒアリングが保存できる唯一の自由記述でもあります。
  • ヒアリングは訪問者が始めたときに作ってください。 ページを開いたときではありません。 1 件ごとにワークスペースに記録が残ります。
  • 開始は冪等ではありません。 POST /sessions のたびに新しいヒアリングが作られます。 タイムアウト後の再試行が実は成功していた場合も同じです。session.id を保存し、再開には GET /sessions/{id} を使ってください。
{
"session": {
"id": "0b8f4c7e-6d0a-4f5e-9d61-2c3a1e7b9f10",
"title": "株式会社◯◯ サイトリニューアル",
"state": "in_progress",
"closed_at": null,
"created_at": "2026-09-16T04:12:30.000Z"
},
"catalog": { "key": "web-production", "name": "Web サイト制作" },
"questions": [
{
"fact_id": "fct_pages",
"label": "固定ページ数",
"description": "トップを含む固定ページの数。",
"type": "number",
"unit": "ページ",
"topic": "デザイン"
}
],
"result": {
"measures": [
{ "measure_id": "msr_initial", "label": "初期費用", "group": null, "primary": true,
"display": "currency", "value": { "min": 1130000, "max": 3300000 }, "settled": false },
{ "measure_id": "msr_monthly_expense", "label": "実費", "group": "月額", "primary": false,
"display": "currency", "value": { "min": 0, "max": 14900 }, "settled": false },
{ "measure_id": "msr_monthly_service", "label": "自社分", "group": "月額", "primary": false,
"display": "currency", "value": { "min": 0, "max": 25000 }, "settled": false }
],
"verdict": null,
"pending": [
{ "fact_id": "fct_pages", "label": "固定ページ数" },
{ "fact_id": "fct_design", "label": "デザインの進め方" }
]
},
"progress": { "answered": 0, "unknown": 0 }
}
フィールド
fact_id回答のキーとしてそのまま送り返す
label設問。カタログに書かれたまま
description任意。補足の説明
typebooleannumberselect のいずれか
unitnumber のみ、任意。入力欄の横に表示する
optionsselect のみ。[{ "value": "opt_new_design", "label": "新規デザイン" }]
topic任意。設問が属する話題

topic_changed は、1 件以上の回答を含む POST /answers のレスポンスにだけ付きます。次の設問が 直前に答えた設問と別の話題なら true です。見出しや「続いて、〜について」という前置きに使うか、 無視してください。ほかのレスポンスには付きません。

設問はカタログを書いた言語のままです。翻訳はありません。

どの寄与項目からも使われていない事実は、GET /catalog/facts には出ますが questions には一度も 出ません。答えても結果が変わらないからです。これはサイトではなくカタログ側の不備で、カタログの 編集画面で 未使用 の印が付いています。

POST /api/public/assessment/sessions/{id}/answers
Content-Type: application/json
{
"answers": { "fct_pages": 12, "fct_design": "opt_new_design" },
"unknown": ["fct_hosting"],
"limit": 1
}

どのフィールドも任意です。answersfact_id をキーにしたオブジェクト、unknownfact_id の配列です。

type送るもの送ってはいけないもの
booleantrue / false"true""はい"1
numberJSON の数値: 12"12""10〜20"null
select選択肢の value: "opt_new_design"ラベルの "新規デザイン"
  • 回答は差分でマージされます。 変わるのは送った事実だけで、同じ事実に答え直すと上書きされます。
  • unknown は「聞いたが、分からない・答えたくない」です。 これも回答のひとつで、その事実は 二度と聞かれず、result.pending に確認事項として残ります。後から答えると unknown から外れ、 答えた事実を unknown にすると回答は消えます。同じリクエストで両方に入れた場合は回答が優先されます。
  • 回答を完全に取り消す (回答でも不明でもない状態に戻す) には DELETE /sessions/{id}/answers/{factId} を使います。
  • 全部通るか、全部通らないかです。 1 件でも不正なら 400 validation_failed になり、何も保存されません。
  • 形の崩れた本文はエラーにならず、無視されます。 本文が JSON でない、answers がオブジェクトで ない、unknown が配列でない場合、その部分は無かったものとして扱われ、200 が返って何も記録 されません。必ず Content-Type: application/json で JSON オブジェクトを送り、想定どおり progress が動いたかを確かめてください。
  • 推測で埋めないでください。 返事が設問にはっきり当てはまらないなら、聞き直すか「不明」として 送ります。推測で埋めると、未確定が無いように見えて中身が間違っている結果になります。
  • 数値は 1 つの数です。 「10〜20 ページ」は受け付けられません。1 つの数で聞き直すか、不明として送ります。
{
"questions": [
{ "fact_id": "fct_multilang", "label": "多言語対応", "type": "boolean", "topic": "実装" }
],
"topic_changed": true,
"result": {
"measures": [
{ "measure_id": "msr_initial", "label": "初期費用", "group": null, "primary": true,
"display": "currency", "value": { "min": 1930000, "max": 2640000 }, "settled": false }
],
"verdict": null,
"pending": [
{ "fact_id": "fct_multilang", "label": "多言語対応" },
{ "fact_id": "fct_hosting", "label": "ホスティング" }
]
},
"progress": { "answered": 2, "unknown": 1 }
}

(省略しています。実際のレスポンスにはすべての軸と、すべての確認事項が入ります。)

検証に失敗すると、問題ごとに理由が返ります。

{
"error": "validation_failed",
"message": "",
"errors": [
{ "fact_id": "fct_pages", "code": "type_mismatch", "message": "" },
{ "fact_id": "fct_design", "code": "unknown_option", "message": "" }
]
}
code
unknown_factこのカタログにその ID の事実が無い
archived_factその事実はカタログから削除された
type_mismatch設問に対して JSON の型が違う
unknown_option設問の options[].value のどれでもない
archived_optionその選択肢はカタログから削除された
not_finiteNaN または Infinity

result を持つレスポンスには、1 問目から必ず結果が入っています。

フィールド
measures[]カタログの出力の軸ごとに 1 つ、カタログの順で
measures[].label.group同じ group の軸はひとまとまり: 「月額 · 実費」「月額 · 自社分」
measures[].primary主役の数字。一番大きく見せる
measures[].displaycurrency: 金額。レスポンスに通貨コードはなく、Minion はこの金額を円として表示しています。number: ただの数値で、unit があれば添える
measures[].value{ min, max }。表示するときは丸める
measures[].settledminmax が等しければ true
verdictnull、または { level, label }levelpassborderlinefailinfolabel はカタログの作者が書いた文言。結果に効く未確定が残るうちは borderline のまま
pending[]幅の理由になっている未確定の事実。答えれば幅が縮むものと、「不明」と記録されたもの。「確認が必要な点」として見せる

金額をいつ見せるかは、サイトが決めてください。 序盤の幅は広いことがあります。当社の Web サイト制作 サンプルでは初期費用が ¥1,130,000 〜 ¥3,300,000 から始まり、21 問あるより大きな見積カタログでは 上限が下限の 15 倍から始まりました。広すぎる幅をそのまま見せると「見当がついていない」と読まれます。 最後に見せる、何問か答えてから見せる、基準の軸の max / min が十分小さくなってから見せる、などを 選んでください。月額の軸では min0 のことがよくあるので、割り算には注意してください。

設問の順位は、基準の軸 (primary) だけで決まります。 基準以外の軸にしか効かない事実は questions に一度も出ないので、questions が尽きても基準以外の軸は幅のまま残ることがあり、その理由は pending にも出ません。Web サイト制作サンプルでは「月額保守契約」が一度も聞かれず、「月額 · 自社分」は ¥0 〜 ¥25,000 のまま終わります。そういう軸も見せるなら、questions が空になったあとで GET /sessions/{id}/facts を取り、unanswered のまま残っている事実を聞いてください。 そうでなければ、基準の軸だけを見せてください。

寄与量・賃率・内訳・工数は、意図的にレスポンスに含めていません。金額から逆算しようとしないでください。

POST /api/public/assessment/sessions/{id}/close

本文は不要です。レスポンスは { session } で、stateawaiting_review になります。

session.state意味
in_progress進行中。回答を受け付ける
awaiting_reviewサイトが閉じた。ワークスペースの担当者の確認待ち
fixed人が確かめて確定した。結果は凍結されている
  • 閉じたあとの回答と取り消しは 409 session_closed で失敗します。 読み取りと、 ヒアリングの削除はそのまま使えます。
  • 回答が 1 件以上あるヒアリングを閉じると、ワークスペースに通知されます。
  • 人はいつでも確定できます。 訪問者が答えている途中でもです。その場合、次の回答は 409 assessment_fixed で失敗します。閉じられたヒアリングと同じに扱ってください。
  • サイトからは確定できません。 確定は人の判断で、そう設計してあります。
  • 閉じなかったヒアリングは、ワークスペースで進行中のまま残ります。訪問者が送信したときに閉じてください。 途中で離脱した訪問者のために何かする必要はありません。

セッション ID を保存しておき (httpOnly Cookie、ボットなら自前のデータベース)、訪問者が戻ってきたら GET /sessions/{id}?limit=1 を呼びます。開始時と同じものが全部返ります。

404 が返るか、session.statein_progress でなくなっていたら、ID を捨てて最初から始められるようにしてください。

  • 全設問を 1 ページに並べる。 GET /sessions/{id}/facts は全事実をカタログの順に、state 付きで 返します。answered (value 付き)・unknownunansweredinactive のいずれかです。 inactive はいまの回答では結果に効かない事実なので、隠すか薄く表示してください (入力済みの内容は 残っています)。基準の軸の幅を縮める unanswered の事実には rank が付き、1 が一番聞く価値があります。
  • ヒアリングを始める前に並べる。 GET /catalog/factsGET /catalog/measures で、設問と軸を 前もって取れます。ただし固定の順番で聞くと、一番効くことから聞くというこの機能の要点を 捨てることになります。できるだけ questions を使ってください。

DELETE /sessions/{id} で、そのキーで始めたヒアリングを削除できます。訪問者からデータの削除を 求められたときなどに使います。確定済みのヒアリングは API からは削除できない (409 assessment_fixed) ので、ワークスペースに削除を依頼してください。

エラーはすべて { "error": "<code>", "message": "…" } です。validation_failed だけ errors[] が付きます。

分岐は error で行い、message を訪問者に見せないでください。 多言語化されていません。

ステータスerrorいつ
400validation_failed回答が設問に合わない。何も保存されていない
401api_key_requiredAuthorization: Bearer ヘッダが無い
401invalid_api_keyそのキーが存在しない。カタログを削除するとキーも消える
401api_key_revokedキーが失効している
404not_foundセッションが無い、またはこのキーで始めたものではない
404catalog_not_foundカタログが使えなくなった、またはワークスペースでアセスメントが無効
409catalog_unpublishedカタログが公開されていない
409session_closedヒアリングは閉じられている
409assessment_fixed人がヒアリングを確定した
409conflict同じヒアリングに同時に書き込まれた。読み直して 1 回だけ再試行する
429rate_limitedリクエストが多すぎる。Retry-After ヘッダは無いので、間隔を空ける
500internal_error時間をおいて再試行する
503unsupported_engineカタログが今は使えない。ワークスペースに問い合わせる
対象上限
すべてのリクエストキーごとに毎分 300 (キーが無ければ IP アドレスごと)
POST /sessionsキーごとに毎分 60
1 件のヒアリングへの書き込み (回答・取り消し・閉じる・削除)ヒアリングごとに毎分 20

上限はおおよその値です。ポーリングはしないでください。サイトかワークスペースの人が変えない限り、 Minion 側では何も変わりません。

下の 2 つの例で共通に使います。lib/assessment.ts (Next.js) または src/lib/assessment.ts (Astro) に置きます。

lib/assessment.ts
//
// サーバー側専用。連携キーを読むので、ブラウザで動くコードから import してはいけない。
// クライアントコンポーネントから「型だけ」を import するのは問題ない (型は消える)。
const BASE = process.env.ASSESSMENT_API_BASE ?? 'https://minionworkspace.com/api/public/assessment'
export type AnswerValue = boolean | number | string
export interface Question {
fact_id: string
label: string
description?: string
type: 'boolean' | 'number' | 'select'
/** number のみ */
unit?: string
/** select のみ */
options?: { value: string; label: string }[]
topic?: string
}
export interface MeasureValue {
measure_id: string
label: string
group: string | null
primary: boolean
display: 'currency' | 'number'
unit?: string
value: { min: number; max: number }
settled: boolean
}
export interface Result {
measures: MeasureValue[]
verdict: { level: 'pass' | 'borderline' | 'fail' | 'info'; label: string } | null
pending: { fact_id: string; label: string }[]
}
export interface Progress {
answered: number
unknown: number
}
export interface Session {
id: string
title: string
state: 'in_progress' | 'awaiting_review' | 'fixed'
closed_at: string | null
created_at: string
}
/** POST /sessions, GET /sessions/:id */
export interface SessionView {
session: Session
catalog: { key: string; name: string }
questions: Question[]
result: Result
progress: Progress
}
/** POST /sessions/:id/answers, DELETE /sessions/:id/answers/:factId */
export interface AnswerView {
questions: Question[]
/** そのリクエストで 1 件以上回答したときだけ付く。 */
topic_changed?: boolean
result: Result
progress: Progress
}
export class AssessmentApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly errors: { fact_id: string; code: string; message: string }[] = [],
) {
super(message)
}
}
async function call<T>(method: 'GET' | 'POST' | 'DELETE', path: string, body?: object): Promise<T> {
const key = process.env.ASSESSMENT_API_KEY
if (!key) throw new Error('ASSESSMENT_API_KEY is not set')
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${key}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
cache: 'no-store',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new AssessmentApiError(
res.status,
data?.error ?? 'http_error',
data?.message ?? `HTTP ${res.status}`,
data?.errors,
)
}
return data as T
}
const session = (id: string) => `/sessions/${encodeURIComponent(id)}`
export const assessment = {
/** キーがどのカタログを指しているか。起動時に 1 回呼んで、キーの取り違えに気づく。 */
catalog: () =>
call<{
catalog: { key: string; name: string; description: string | null; fact_count: number; measure_count: number }
}>('GET', '/catalog'),
start: (title: string, limit = 1) => call<SessionView>('POST', '/sessions', { title, limit }),
get: (id: string, limit = 1) => call<SessionView>('GET', `${session(id)}?limit=${limit}`),
answer: (id: string, factId: string, value: AnswerValue, limit = 1) =>
call<AnswerView>('POST', `${session(id)}/answers`, { answers: { [factId]: value }, limit }),
unknown: (id: string, factId: string, limit = 1) =>
call<AnswerView>('POST', `${session(id)}/answers`, { unknown: [factId], limit }),
clear: (id: string, factId: string) =>
call<AnswerView>('DELETE', `${session(id)}/answers/${encodeURIComponent(factId)}`),
close: (id: string) => call<{ session: Session }>('POST', `${session(id)}/close`),
remove: (id: string) => call<{ ok: true }>('DELETE', session(id)),
}

ルートハンドラがセッションの Cookie を持って API と話し、クライアントコンポーネントが 1 問ずつ 表示します。@/* はプロジェクトのルートを指す前提です。

app/api/hearing/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { assessment, AssessmentApiError, type AnswerValue } from '@/lib/assessment'
// 訪問者のセッション ID は httpOnly Cookie に置く。このルートはその 1 件にしか
// 触らないので、API への汎用プロキシにはならない。
const COOKIE = 'assessment_session'
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
type Action =
| { action: 'start' }
| { action: 'answer'; fact_id: string; value: AnswerValue }
| { action: 'unknown'; fact_id: string }
| { action: 'close' }
async function currentSessionId(): Promise<string | null> {
const id = (await cookies()).get(COOKIE)?.value
return id && UUID.test(id) ? id : null
}
/** 再読み込み後の再開。 */
export async function GET() {
const id = await currentSessionId()
if (!id) return NextResponse.json({ view: null })
try {
const view = await assessment.get(id)
// 閉じた、または Minion 側で確定された。止まったフォームを見せず、最初からにする。
if (view.session.state !== 'in_progress') {
;(await cookies()).delete(COOKIE)
return NextResponse.json({ view: null })
}
return NextResponse.json({ view })
} catch (err) {
if (err instanceof AssessmentApiError && err.status === 404) {
;(await cookies()).delete(COOKIE)
return NextResponse.json({ view: null })
}
throw err
}
}
export async function POST(request: Request) {
const body = (await request.json()) as Action
const jar = await cookies()
try {
if (body.action === 'start') {
// 確認する人が見分けられるタイトルにする。後から変更できない。
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ')
const view = await assessment.start(`Web サイト見積 ${stamp}`)
jar.set(COOKIE, view.session.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return NextResponse.json({ view })
}
const id = await currentSessionId()
if (!id) return NextResponse.json({ error: 'no_session' }, { status: 409 })
switch (body.action) {
case 'answer':
return NextResponse.json({ view: await assessment.answer(id, body.fact_id, body.value) })
case 'unknown':
return NextResponse.json({ view: await assessment.unknown(id, body.fact_id) })
case 'close':
await assessment.close(id)
jar.delete(COOKIE)
return NextResponse.json({ view: null, closed: true })
default:
return NextResponse.json({ error: 'unknown_action' }, { status: 400 })
}
} catch (err) {
if (err instanceof AssessmentApiError) {
// ページに渡すのはエラーコードだけ。キーの誤り (401) や障害 (5xx) は訪問者ではなく
// こちらの問題なので、ただの 502 にする。
console.error('[assessment]', err.status, err.code, err.message)
const status = [400, 404, 409, 429].includes(err.status) ? err.status : 502
return NextResponse.json({ error: err.code, errors: err.errors }, { status })
}
throw err
}
}
app/estimate/Hearing.tsx
'use client'
import { useEffect, useState } from 'react'
import type { AnswerValue, MeasureValue, Progress, Question, Result } from '@/lib/assessment'
interface View {
questions: Question[]
result: Result
progress: Progress
}
/** もう書き込めないことを表すコード。最初からやり直す。 */
const GONE = ['no_session', 'not_found', 'session_closed', 'assessment_fixed']
async function send(body: object): Promise<{ view: View | null; closed?: boolean }> {
const res = await fetch('/api/hearing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json()
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`)
return json
}
const numberFormat = new Intl.NumberFormat('ja-JP')
function formatRange(m: MeasureValue): string {
// `currency` に通貨コードは付かない。Minion はこの金額を円として表示している。
const one = (n: number) =>
m.display === 'currency'
? `¥${numberFormat.format(Math.round(n))}`
: `${numberFormat.format(Math.round(n))}${m.unit ? ` ${m.unit}` : ''}`
return m.settled ? one(m.value.min) : `${one(m.value.min)}${one(m.value.max)}`
}
/**
* 金額を見せてよい段階か。序盤の幅はとても広く、そのまま出すと「見当がついていない」と
* 読まれる。ここの基準は一例なので、サイトに合わせて調整する。
*/
function worthShowing(view: View): boolean {
if (view.questions.length === 0) return true
const primary = view.result.measures.find((m) => m.primary) ?? view.result.measures[0]
if (!primary) return false
if (primary.settled) return true
const { min, max } = primary.value
return view.progress.answered >= 3 && min > 0 && max / min <= 2
}
export default function Hearing() {
const [view, setView] = useState<View | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch('/api/hearing')
.then((res) => res.json())
.then((json) => setView(json.view))
.catch(() => setError('load_failed'))
.finally(() => setLoading(false))
}, [])
async function run(body: object) {
setBusy(true)
setError(null)
try {
const next = await send(body)
setView(next.view)
if (next.closed) setSent(true)
} catch (err) {
const code = err instanceof Error ? err.message : String(err)
if (GONE.includes(code)) setView(null)
else setError(code)
} finally {
setBusy(false)
}
}
if (loading) return <p>読み込み中…</p>
if (sent) return <p>ありがとうございました。内容を確認のうえ、ご連絡します。</p>
if (!view) {
return (
<button type="button" disabled={busy} onClick={() => run({ action: 'start' })}>
はじめる
</button>
)
}
// limit=1 なので設問は多くても 1 件。空配列なら、もう聞くことは残っていない。
const question = view.questions[0]
return (
<div>
{question ? (
<QuestionForm
key={question.fact_id}
question={question}
busy={busy}
onAnswer={(value) => run({ action: 'answer', fact_id: question.fact_id, value })}
onUnknown={() => run({ action: 'unknown', fact_id: question.fact_id })}
/>
) : (
<div>
<p>お伺いしたいことは以上です。</p>
<button type="button" disabled={busy} onClick={() => run({ action: 'close' })}>
送信する
</button>
</div>
)}
{error && <p role="alert">エラーが発生しました ({error})。もう一度お試しください。</p>}
{worthShowing(view) && <ResultPanel result={view.result} />}
</div>
)
}
function QuestionForm({
question,
busy,
onAnswer,
onUnknown,
}: {
question: Question
busy: boolean
onAnswer: (value: AnswerValue) => void
onUnknown: () => void
}) {
const [raw, setRaw] = useState('')
return (
<div>
{question.topic && <p>{question.topic}</p>}
<fieldset disabled={busy}>
<legend>{question.label}</legend>
{question.description && <p>{question.description}</p>}
{question.type === 'boolean' && (
<>
<button type="button" onClick={() => onAnswer(true)}>はい</button>
<button type="button" onClick={() => onAnswer(false)}>いいえ</button>
</>
)}
{question.type === 'select' &&
question.options?.map((option) => (
// 送るのは選択肢の value。ラベルではない。
<button key={option.value} type="button" onClick={() => onAnswer(option.value)}>
{option.label}
</button>
))}
{question.type === 'number' && (
<form
onSubmit={(e) => {
e.preventDefault()
const n = Number(raw)
// Number('') は 0 になるので、空欄は明示的に弾く。
if (raw.trim() !== '' && Number.isFinite(n)) onAnswer(n)
}}
>
<input type="number" step="any" value={raw} onChange={(e) => setRaw(e.target.value)} />
{question.unit && <span>{question.unit}</span>}
<button type="submit">次へ</button>
</form>
)}
<button type="button" onClick={onUnknown}>わからない</button>
</fieldset>
</div>
)
}
function ResultPanel({ result }: { result: Result }) {
return (
<section>
{result.measures.map((m) => (
<p key={m.measure_id}>
{m.group ? `${m.group} · ` : ''}
{m.label}: {m.primary ? <strong>{formatRange(m)}</strong> : formatRange(m)}
</p>
))}
{result.verdict && <p>{result.verdict.label}</p>}
{result.pending.length > 0 && (
<>
<p>確認が必要な点</p>
<ul>
{result.pending.map((p) => (
<li key={p.fact_id}>{p.label}</li>
))}
</ul>
</>
)}
</section>
)
}
app/estimate/page.tsx
import Hearing from './Hearing'
export default function EstimatePage() {
return (
<main>
<h1>お見積もり</h1>
<Hearing />
</main>
)
}

Astro (サーバー出力、クライアント側 JavaScript なし)

Section titled “Astro (サーバー出力、クライアント側 JavaScript なし)”

ただの HTML フォームで、1 回の読み込みに 1 問です。回答はすべて POST のあとにリダイレクトするので、 再読み込みで回答が二重に送られることはありません。

astro.config.mjs
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
// サイトを配信するホスト名を列挙する。これが無いと Astro はリクエストのホストを信用せず、
// Astro.url が localhost になり、フォームの POST がすべて
// 403 "Cross-site POST form submissions are forbidden" で拒否される。
security: { allowedDomains: [{ hostname: 'www.example.com', protocol: 'https' }] },
})
src/pages/estimate.astro
---
// サーバーアダプタ (@astrojs/node など) が必要。クライアント側の JavaScript は使わない。
import {
assessment,
AssessmentApiError,
type AnswerValue,
type MeasureValue,
type SessionView,
} from '../lib/assessment'
export const prerender = false
const COOKIE = 'assessment_session'
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** もう書き込めないことを表すコード。最初からやり直す。 */
const GONE = ['not_found', 'session_closed', 'assessment_fixed']
const cookie = Astro.cookies.get(COOKIE)?.value
const id = cookie && UUID.test(cookie) ? cookie : null
let error: string | null = null
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData()
const action = String(form.get('action') ?? '')
try {
if (action === 'start') {
// 確認する人が見分けられるタイトルにする。後から変更できない。
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ')
const started = await assessment.start(`Web サイト見積 ${stamp}`)
Astro.cookies.set(COOKIE, started.session.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return Astro.redirect('/estimate', 303)
}
if (id && action === 'answer') {
const factId = String(form.get('fact_id'))
if (form.get('unknown') === '1') {
await assessment.unknown(id, factId)
return Astro.redirect('/estimate', 303)
}
// フォームの値は常に文字列。設問の型に変換してから送る。
const type = String(form.get('type'))
const raw = String(form.get('value') ?? '')
let value: AnswerValue | null = raw
if (type === 'boolean') value = raw === 'true'
if (type === 'number') value = raw.trim() !== '' && Number.isFinite(Number(raw)) ? Number(raw) : null
if (value === null) {
error = '数値を入力してください。'
} else {
await assessment.answer(id, factId, value)
return Astro.redirect('/estimate', 303)
}
}
if (id && action === 'close') {
await assessment.close(id)
Astro.cookies.delete(COOKIE, { path: '/' })
return Astro.redirect('/estimate?sent=1', 303)
}
} catch (err) {
if (!(err instanceof AssessmentApiError)) throw err
if (!GONE.includes(err.code)) {
console.error('[assessment]', err.status, err.code, err.message)
error = 'エラーが発生しました。もう一度お試しください。'
}
}
}
let view: SessionView | null = null
if (id) {
try {
view = await assessment.get(id)
// 閉じた、または Minion 側で確定された。止まったフォームを見せず、最初からにする。
if (view.session.state !== 'in_progress') view = null
} catch (err) {
if (!(err instanceof AssessmentApiError) || err.status !== 404) throw err
}
if (!view) Astro.cookies.delete(COOKIE, { path: '/' })
}
// limit=1 なので設問は多くても 1 件。空配列なら、もう聞くことは残っていない。
const question = view?.questions[0]
const sent = Astro.url.searchParams.has('sent') && !view
const numberFormat = new Intl.NumberFormat('ja-JP')
function formatRange(m: MeasureValue): string {
// `currency` に通貨コードは付かない。Minion はこの金額を円として表示している。
const one = (n: number) =>
m.display === 'currency'
? `¥${numberFormat.format(Math.round(n))}`
: `${numberFormat.format(Math.round(n))}${m.unit ? ` ${m.unit}` : ''}`
return m.settled ? one(m.value.min) : `${one(m.value.min)}${one(m.value.max)}`
}
/** 序盤の幅はとても広い。ここの基準は一例なので、サイトに合わせて調整する。 */
function worthShowing(v: SessionView): boolean {
if (v.questions.length === 0) return true
const primary = v.result.measures.find((m) => m.primary) ?? v.result.measures[0]
if (!primary) return false
if (primary.settled) return true
const { min, max } = primary.value
return v.progress.answered >= 3 && min > 0 && max / min <= 2
}
---
<main>
<h1>お見積もり</h1>
{sent && <p>ありがとうございました。内容を確認のうえ、ご連絡します。</p>}
{error && <p role="alert">{error}</p>}
{!view && !sent && (
<form method="post">
<button name="action" value="start">はじめる</button>
</form>
)}
{question && (
<form method="post">
<input type="hidden" name="action" value="answer" />
<input type="hidden" name="fact_id" value={question.fact_id} />
<input type="hidden" name="type" value={question.type} />
{question.topic && <p>{question.topic}</p>}
<fieldset>
<legend>{question.label}</legend>
{question.description && <p>{question.description}</p>}
{question.type === 'boolean' && (
<>
<button name="value" value="true">はい</button>
<button name="value" value="false">いいえ</button>
</>
)}
{question.type === 'select' &&
question.options?.map((option) => (
<button name="value" value={option.value}>{option.label}</button>
))}
{question.type === 'number' && (
<>
<input type="number" step="any" name="value" required />
{question.unit && <span>{question.unit}</span>}
<button>次へ</button>
</>
)}
<button name="unknown" value="1" formnovalidate>わからない</button>
</fieldset>
</form>
)}
{view && !question && (
<form method="post">
<p>お伺いしたいことは以上です。</p>
<button name="action" value="close">送信する</button>
</form>
)}
{view && worthShowing(view) && (
<section>
{view.result.measures.map((m) => (
<p>
{m.group ? `${m.group} · ` : ''}{m.label}: {m.primary ? <strong>{formatRange(m)}</strong> : formatRange(m)}
</p>
))}
{view.result.verdict && <p>{view.result.verdict.label}</p>}
{view.result.pending.length > 0 && (
<>
<p>確認が必要な点</p>
<ul>{view.result.pending.map((p) => <li>{p.label}</li>)}</ul>
</>
)}
</section>
)}
</main>

チャットボットでもループは同じです。言語モデルの仕事は 2 つだけで、設問を訪問者向けの言葉にすることと、 訪問者の返事を値に変換することです。順番は Minion が持ったままです。

訪問者と話すプロンプトに持たせる決まりは 3 つです。

  • 渡された設問だけを聞く。 モデルが自分で質問を足さない。
  • 1 回の発言で 1 問。 まとめて聞くと、返事を値に変換できなくなる。
  • 訪問者が答えていないことを埋めない。 推測は「未確定が無いように見えて間違っている結果」になる。 「不明」なら、確認する人から見える場所に空白が残る。

返事を値に変換するときは、設問をモデルに渡して JSON で答えさせます。

構造化されたヒアリングの回答を 1 件記録します。質問はしないでください。
設問: デザインの進め方
種類: select — 次の value のうちちょうど 1 つで答える:
opt_new_design: 新規デザイン
opt_reuse: 既存デザイン踏襲
訪問者の返事: """基本的には今のデザインのままでいいです"""
次のいずれかの JSON だけを返してください:
{"value": <回答>} 返事が設問にはっきり答えている
{"unknown": true} 分からない、または答えたくない
{"unclear": true} 判断できない、または幅で答えている
推測はしないでください。

boolean なら truefalsenumber なら 1 つの数 (unit があればその単位で) を求めます。

モデルの出力は、送る前に設問と突き合わせてください。モデルは value の代わりにラベルを、数値の 代わりに文字列を返すことがあり、API はどちらも拒否します。

lib/check-reply.ts
import type { AnswerValue, Question } from './assessment'
/**
* モデルが取り出した値を、送る前に設問と突き合わせる。
*
* - `{ value }` — 設問の型に合う。回答として送る
* - `{ unknown }` — 相手が分からない / 答えたくない。`unknown` として送る
* - `null` — モデルが判断できなかった。推測せず、相手に聞き直す
*/
export function checkReply(
question: Question,
reply: unknown,
): { value: AnswerValue } | { unknown: true } | null {
if (!reply || typeof reply !== 'object') return null
if ('unknown' in reply && reply.unknown === true) return { unknown: true }
if (!('value' in reply)) return null
const { value } = reply
switch (question.type) {
case 'boolean':
return typeof value === 'boolean' ? { value } : null
case 'number':
return typeof value === 'number' && Number.isFinite(value) ? { value } : null
case 'select':
// 選択肢の value のどれかでなければならない。ラベルや、それに近い文字列は不可。
return typeof value === 'string' && question.options?.some((o) => o.value === value)
? { value }
: null
}
}

checkReplynull を返したら、言い方を変えて聞き直してください。2 回目もはっきりしなければ、 不明として送って次へ進みます。会話そのものを保存する場所は Minion にはありません。記録が必要なら サイト側で残してください。

なぜ
ブラウザの JavaScript から API を呼ぶ、キーを NEXT_PUBLIC_ / PUBLIC_ の変数に置くサーバーから呼ぶキーが誰にでも読める。そもそも CORS ヘッダが無いので呼べない
キーを ?apiKey= で送るAuthorization: Bearerクエリ文字列のキーは受け付けず、ログにも残る
ブラウザから来たパスやセッション ID をそのまま転送する訪問者の Cookie にあるセッション ID にだけ作用させるID さえ分かれば他人のヒアリングを読める・消せる
ページを開いた時点でヒアリングを始める訪問者が「はじめる」を押してから始める1 件ごとにワークスペースに記録が残る
再読み込みで新しいヒアリングを始める、タイムアウト後に POST /sessions を再試行するID を保存して GET /sessions/{id} で再開する開始は冪等ではないので重複する
設問を並べ替える・飛ばす・足す、/catalog/facts を上から順に聞くquestions[0] を聞く返ってきた順に聞くことが聞き漏れを防ぐ
設問の数を数えて終わりを判断するquestions[] になったら終わる設問の数は回答で変わる
"12"、選択肢のラベル、"はい" を送る12、選択肢の valuetrue400 validation_failed
"unknown": "fct_x" やフォーム形式の本文を送るJSON の本文で "unknown": ["fct_x"]形の崩れた部分は無視され、200 で何も記録されない
はっきりしない返事をモデルに推測させる聞き直し、それでもだめなら不明にする推測は、空白の見えない間違った結果になる
1 問目から幅を見せる金額を見せる時点を決める序盤の幅は何倍にもなりうる
questions が空になったら基準以外の軸も確定値として見せる/facts で残りの事実を聞くか、基準の軸だけを見せる設問の順位は基準の軸だけで決まる
message を訪問者に見せるerror を自分の文言に対応づけるmessage は多言語化されていない
サイトから確定しようとする閉じて、人が確定する確定は人の判断で、そう設計してある
複数言語のサイトで 1 つのカタログを使う言語ごとにカタログとキーを分けるカタログの文面は 1 言語