アセスメントの組み込み
このページは意図的に 1 ページで完結させてあります。アセスメントのガイドと 重複する内容も繰り返しているのは、これだけを渡せば Web サイトやボットにヒアリングを組み込める 状態にするためです (人に渡す場合も、コーディング支援の AI に渡す場合も)。
言語モデル向けのプレーンテキスト版は
/ja/llms-assessment.txt にあります。
この構成を 3 行で
Section titled “この構成を 3 行で”制作したサイトのサーバーが連携キーを持ち、API を呼びます。次にどの設問を聞くかと結果の算出は Minion が行い、サイトは設問を表示し、訪問者の返事を値に変換して送り返します。聞くことが なくなったらサイトがヒアリングを閉じ、ワークスペースの担当者が確認します。
Minion は自由記述を受け取らず、代わりに言語モデルを動かすこともありません。だから同じ API が、 ただのフォームにも、チャットボットにも、人が手で入力する画面にもなります。サイトが決めないのは 設問の順番です。返ってきた順に聞くことが、聞き漏れを防ぐ仕組みそのものです。
始める前に必要な値
Section titled “始める前に必要な値”| 値 | 見た目 | どこから |
|---|---|---|
| API のベース URL | https://minionworkspace.com/api/public/assessment | Minion を開いているオリジン + /api/public/assessment |
| 連携キー | asm_… | カタログの 連携 → 外部連携キー。表示されるのは発行時の 1 回だけ |
| カタログ識別子 (任意) | web-production | 同じ 連携 の画面、キーの上。カタログ一覧の名前の下にも出ています |
いずれもサーバー側の環境変数に置いてください (例: ASSESSMENT_API_BASE、ASSESSMENT_API_KEY)。
NEXT_PUBLIC_・PUBLIC_・VITE_ を付けてはいけません。付けるとブラウザに配る JavaScript に
埋め込まれます。
カタログが公開済みで、ワークスペースでアセスメントが有効になっている必要があります。
キーがカタログを決めます。 カタログを指定するリクエストはありません。別の連携のキーを貼っても
エラーにはならず、黙って別のカタログの設問が出てきます。起動時に GET /catalog を 1 回呼び、
catalog.key が想定どおりかを確かめてください。
カタログ識別子は、この照合の当て先です。設定する理由はそれだけなので、任意の設定として扱って ください。 リクエストには一切乗らないため、未設定なら照合を飛ばすのが正しく、アプリを起動 できなくするのは筋が違います (キーが正しければ識別子が無くても動くので、必須にすると落ちる 理由が増えるだけです)。識別子はカタログを作るときに決まり、後から変わることはないので、 環境変数に焼いて構いません。
どこから呼ぶか
Section titled “どこから呼ぶか”ブラウザ ──(自分のサイトのルート + 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, result2. questions[0] を表示する3. POST /sessions/{id}/answers → 次の questions, 新しい result (回答、または「わからない」)4. questions が空になるまで 2〜3 を繰り返す5. POST /sessions/{id}/closequestionsが空配列なら、聞くことは残っていません。doneのようなフラグはありません。 設問の数は回答によって変わります。ある回答によって他の設問が無関係になれば、それらは消えます。- 返ってきた順に聞いてください。 並べ替え・飛ばし・追加をしてはいけません。
- 一覧は回答のたびに計算し直されます。 複数の設問を同時に見せると、1 問目に答えた時点で後ろの 設問が不要になることがあります。1 問か数問ずつ見せ、答えを送ってから次を聞いてください。
- いつ閉じても構いません。 残った未確定の事実は、確認する人に見える形で残ります。
limit で返ってくる設問の数を決めます。既定は 3、0 で全件です。1 画面 1 問、チャットの 1 発言
1 問にするなら limit: 1 です。
エンドポイント
Section titled “エンドポイント”パスはすべてベース URL の下で、どのリクエストにもヘッダが必要です。
| メソッド | パス | 返すもの |
|---|---|---|
POST | /sessions | 201 { 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 が返ります。
ヒアリングを始める
Section titled “ヒアリングを始める”POST /api/public/assessment/sessionsAuthorization: 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 | 任意。補足の説明 |
type | boolean・number・select のいずれか |
unit | number のみ、任意。入力欄の横に表示する |
options | select のみ。[{ "value": "opt_new_design", "label": "新規デザイン" }] |
topic | 任意。設問が属する話題 |
topic_changed は、1 件以上の回答を含む POST /answers のレスポンスにだけ付きます。次の設問が
直前に答えた設問と別の話題なら true です。見出しや「続いて、〜について」という前置きに使うか、
無視してください。ほかのレスポンスには付きません。
設問はカタログを書いた言語のままです。翻訳はありません。
どの寄与項目からも使われていない事実は、GET /catalog/facts には出ますが questions には一度も
出ません。答えても結果が変わらないからです。これはサイトではなくカタログ側の不備で、カタログの
編集画面で 未使用 の印が付いています。
POST /api/public/assessment/sessions/{id}/answersContent-Type: application/json
{ "answers": { "fct_pages": 12, "fct_design": "opt_new_design" }, "unknown": ["fct_hosting"], "limit": 1}どのフィールドも任意です。answers は fact_id をキーにしたオブジェクト、unknown は fact_id の配列です。
type | 送るもの | 送ってはいけないもの |
|---|---|---|
boolean | true / false | "true"、"はい"、1 |
number | JSON の数値: 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_finite | NaN または Infinity |
result を持つレスポンスには、1 問目から必ず結果が入っています。
| フィールド | |
|---|---|
measures[] | カタログの出力の軸ごとに 1 つ、カタログの順で |
measures[].label、.group | 同じ group の軸はひとまとまり: 「月額 · 実費」「月額 · 自社分」 |
measures[].primary | 主役の数字。一番大きく見せる |
measures[].display | currency: 金額。レスポンスに通貨コードはなく、Minion はこの金額を円として表示しています。number: ただの数値で、unit があれば添える |
measures[].value | { min, max }。表示するときは丸める |
measures[].settled | min と max が等しければ true |
verdict | null、または { level, label }。level は pass・borderline・fail・info。label はカタログの作者が書いた文言。結果に効く未確定が残るうちは borderline のまま |
pending[] | 幅の理由になっている未確定の事実。答えれば幅が縮むものと、「不明」と記録されたもの。「確認が必要な点」として見せる |
金額をいつ見せるかは、サイトが決めてください。 序盤の幅は広いことがあります。当社の Web サイト制作
サンプルでは初期費用が ¥1,130,000 〜 ¥3,300,000 から始まり、21 問あるより大きな見積カタログでは
上限が下限の 15 倍から始まりました。広すぎる幅をそのまま見せると「見当がついていない」と読まれます。
最後に見せる、何問か答えてから見せる、基準の軸の max / min が十分小さくなってから見せる、などを
選んでください。月額の軸では min が 0 のことがよくあるので、割り算には注意してください。
設問の順位は、基準の軸 (primary) だけで決まります。 基準以外の軸にしか効かない事実は
questions に一度も出ないので、questions が尽きても基準以外の軸は幅のまま残ることがあり、その理由は
pending にも出ません。Web サイト制作サンプルでは「月額保守契約」が一度も聞かれず、「月額 · 自社分」は
¥0 〜 ¥25,000 のまま終わります。そういう軸も見せるなら、questions が空になったあとで
GET /sessions/{id}/facts を取り、unanswered のまま残っている事実を聞いてください。
そうでなければ、基準の軸だけを見せてください。
寄与量・賃率・内訳・工数は、意図的にレスポンスに含めていません。金額から逆算しようとしないでください。
ヒアリングを閉じる
Section titled “ヒアリングを閉じる”POST /api/public/assessment/sessions/{id}/close本文は不要です。レスポンスは { session } で、state が awaiting_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.state が in_progress でなくなっていたら、ID を捨てて最初から始められるようにしてください。
設問の別の並べ方
Section titled “設問の別の並べ方”- 全設問を 1 ページに並べる。
GET /sessions/{id}/factsは全事実をカタログの順に、state付きで 返します。answered(value付き)・unknown・unanswered・inactiveのいずれかです。inactiveはいまの回答では結果に効かない事実なので、隠すか薄く表示してください (入力済みの内容は 残っています)。基準の軸の幅を縮めるunansweredの事実にはrankが付き、1が一番聞く価値があります。 - ヒアリングを始める前に並べる。
GET /catalog/factsとGET /catalog/measuresで、設問と軸を 前もって取れます。ただし固定の順番で聞くと、一番効くことから聞くというこの機能の要点を 捨てることになります。できるだけquestionsを使ってください。
ヒアリングを削除する
Section titled “ヒアリングを削除する”DELETE /sessions/{id} で、そのキーで始めたヒアリングを削除できます。訪問者からデータの削除を
求められたときなどに使います。確定済みのヒアリングは API からは削除できない (409 assessment_fixed)
ので、ワークスペースに削除を依頼してください。
エラーはすべて { "error": "<code>", "message": "…" } です。validation_failed だけ errors[] が付きます。
分岐は error で行い、message を訪問者に見せないでください。 多言語化されていません。
| ステータス | error | いつ |
|---|---|---|
| 400 | validation_failed | 回答が設問に合わない。何も保存されていない |
| 401 | api_key_required | Authorization: Bearer ヘッダが無い |
| 401 | invalid_api_key | そのキーが存在しない。カタログを削除するとキーも消える |
| 401 | api_key_revoked | キーが失効している |
| 404 | not_found | セッションが無い、またはこのキーで始めたものではない |
| 404 | catalog_not_found | カタログが使えなくなった、またはワークスペースでアセスメントが無効 |
| 409 | catalog_unpublished | カタログが公開されていない |
| 409 | session_closed | ヒアリングは閉じられている |
| 409 | assessment_fixed | 人がヒアリングを確定した |
| 409 | conflict | 同じヒアリングに同時に書き込まれた。読み直して 1 回だけ再試行する |
| 429 | rate_limited | リクエストが多すぎる。Retry-After ヘッダは無いので、間隔を空ける |
| 500 | internal_error | 時間をおいて再試行する |
| 503 | unsupported_engine | カタログが今は使えない。ワークスペースに問い合わせる |
| 対象 | 上限 |
|---|---|
| すべてのリクエスト | キーごとに毎分 300 (キーが無ければ IP アドレスごと) |
POST /sessions | キーごとに毎分 60 |
| 1 件のヒアリングへの書き込み (回答・取り消し・閉じる・削除) | ヒアリングごとに毎分 20 |
上限はおおよその値です。ポーリングはしないでください。サイトかワークスペースの人が変えない限り、 Minion 側では何も変わりません。
API クライアント
Section titled “API クライアント”下の 2 つの例で共通に使います。lib/assessment.ts (Next.js) または src/lib/assessment.ts (Astro) に置きます。
//// サーバー側専用。連携キーを読むので、ブラウザで動くコードから 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)),}Next.js (App Router)
Section titled “Next.js (App Router)”ルートハンドラがセッションの Cookie を持って API と話し、クライアントコンポーネントが 1 問ずつ
表示します。@/* はプロジェクトのルートを指す前提です。
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 }}'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> )}import Hearing from './Hearing'
export default function EstimatePage() { return ( <main> <h1>お見積もり</h1> <Hearing /> </main> )}Astro (サーバー出力、クライアント側 JavaScript なし)
Section titled “Astro (サーバー出力、クライアント側 JavaScript なし)”ただの HTML フォームで、1 回の読み込みに 1 問です。回答はすべて POST のあとにリダイレクトするので、 再読み込みで回答が二重に送られることはありません。
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' }] },})---// サーバーアダプタ (@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)?.valueconst id = cookie && UUID.test(cookie) ? cookie : nulllet 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 = nullif (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>言語モデルを前に置く
Section titled “言語モデルを前に置く”チャットボットでもループは同じです。言語モデルの仕事は 2 つだけで、設問を訪問者向けの言葉にすることと、 訪問者の返事を値に変換することです。順番は Minion が持ったままです。
訪問者と話すプロンプトに持たせる決まりは 3 つです。
- 渡された設問だけを聞く。 モデルが自分で質問を足さない。
- 1 回の発言で 1 問。 まとめて聞くと、返事を値に変換できなくなる。
- 訪問者が答えていないことを埋めない。 推測は「未確定が無いように見えて間違っている結果」になる。 「不明」なら、確認する人から見える場所に空白が残る。
返事を値に変換するときは、設問をモデルに渡して JSON で答えさせます。
構造化されたヒアリングの回答を 1 件記録します。質問はしないでください。
設問: デザインの進め方種類: select — 次の value のうちちょうど 1 つで答える: opt_new_design: 新規デザイン opt_reuse: 既存デザイン踏襲
訪問者の返事: """基本的には今のデザインのままでいいです"""
次のいずれかの JSON だけを返してください: {"value": <回答>} 返事が設問にはっきり答えている {"unknown": true} 分からない、または答えたくない {"unclear": true} 判断できない、または幅で答えている推測はしないでください。boolean なら true か false、number なら 1 つの数 (unit があればその単位で) を求めます。
モデルの出力は、送る前に設問と突き合わせてください。モデルは value の代わりにラベルを、数値の 代わりに文字列を返すことがあり、API はどちらも拒否します。
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 }}checkReply が null を返したら、言い方を変えて聞き直してください。2 回目もはっきりしなければ、
不明として送って次へ進みます。会話そのものを保存する場所は Minion にはありません。記録が必要なら
サイト側で残してください。
やってはいけないこと
Section titled “やってはいけないこと”| ❌ | ✅ | なぜ |
|---|---|---|
ブラウザの 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、選択肢の value、true | 400 validation_failed |
"unknown": "fct_x" やフォーム形式の本文を送る | JSON の本文で "unknown": ["fct_x"] | 形の崩れた部分は無視され、200 で何も記録されない |
| はっきりしない返事をモデルに推測させる | 聞き直し、それでもだめなら不明にする | 推測は、空白の見えない間違った結果になる |
| 1 問目から幅を見せる | 金額を見せる時点を決める | 序盤の幅は何倍にもなりうる |
questions が空になったら基準以外の軸も確定値として見せる | /facts で残りの事実を聞くか、基準の軸だけを見せる | 設問の順位は基準の軸だけで決まる |
message を訪問者に見せる | error を自分の文言に対応づける | message は多言語化されていない |
| サイトから確定しようとする | 閉じて、人が確定する | 確定は人の判断で、そう設計してある |
| 複数言語のサイトで 1 つのカタログを使う | 言語ごとにカタログとキーを分ける | カタログの文面は 1 言語 |