TypeSafe の Jev は、文章を書かずに「判断」だけを返す AI です。
これを kintone の問い合わせ管理アプリに組み込んで、3つの機能を作りました。
記事中の数値は、2026年9月17〜18日に jev-latest(応答では jev-1.13.0)へ実際に投げて測ったものです。
振り分けと優先度付け
返信の品質チェック
お客さまの気持ち予報
記事中のスクリーンショットは、動画と同じ画面の静止画です。
1. Jev の概要と特徴
Jev は、TypeSafe が「System One」と呼んでいるタイプのモデルです。
LLM のように文章を生成するのではなく、型の決まった質問に、確率付きで答えます。
| 質問の型 | 聞けること | 返ってくるもの |
|---|---|---|
| Choice | 選択肢から1つ選ぶ(どの部署?) | 選んだ選択肢、全選択肢の確率、confidence |
| Score | 段階のどこにあたるか(緊急度は?) | 段階の位置(小数)、各段階の確率、confidence |
| Noul | Yes / No(解約をほのめかしている?) | Yes の確率 |
たとえば、「今月の請求が2回引き落とされています。至急確認してください。」という問い合わせを state に入れて、3問を同時に聞くとこうなります。
{
"model": "jev-latest",
"state": { "件名": "請求が二重です", "内容": "今月の請求が2回引き落とされています。至急確認してください。" },
"questions": {
"dept": {
"type": "choice",
"instructions": "この問い合わせを担当すべき部署はどこか",
"criteria": {
"請求": "支払い・請求・返金に関する問題",
"技術": "不具合・操作方法・連携の問題",
"営業": "料金プラン・契約・見積の相談",
"その他": "上記のどれにも当てはまらない"
}
},
"urgency": {
"type": "score",
"instructions": "対応の緊急度",
"criteria": [
"急ぎではない。情報提供や一般的な質問",
"業務に影響があるが回避策がある",
"業務が止まっている、または金銭的な被害が出ている"
]
},
"urgent": { "type": "noul", "instructions": "この問い合わせは急ぎの対応を求めているか" }
}
}
{
"model": "jev-1.13.0",
"answers": {
"dept": { "type": "choice", "choice": "請求", "confidence": 1,
"probabilities": { "請求": 1, "営業": 0, "技術": 0, "その他": 0 } },
"urgency": { "type": "score", "score": 1.99, "confidence": 0.99,
"probabilities": { "0": 0, "1": 0.01, "2": 0.99 } },
"urgent": { "type": "noul", "noul": 0.96 }
},
"usage": { "input_tokens": 569, "output_tokens": 79 }
}
TypeScript で受けるときの型は、こうなります。答えは3種類のユニオン型で、type で絞り込めます。
export type JevChoiceAnswer = { type: 'choice'; choice: string; confidence: number; probabilities: Record<string, number> };
export type JevScoreAnswer = { type: 'score'; score: number; confidence: number; probabilities: Record<string, number>; legend: Record<string, string> };
export type JevNoulAnswer = { type: 'noul'; noul: number };
export type JevAnswer = JevChoiceAnswer | JevScoreAnswer | JevNoulAnswer;
export type JevResponse = {
model: string;
answers: Record<string, JevAnswer>; // リクエストで付けた質問 id がそのままキーになる
usage?: { input_tokens: number; output_tokens: number };
};
呼び出しは、エンドポイントに POST するだけです。SDK も用意されていますが、今回は素の fetch で足りました。
const res = await fetch('https://api.typesafe.ai/v1/systemone', {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'jev-latest', state, questions }),
});
const { answers } = (await res.json()) as JevResponse;
特徴をまとめると、こうなります。
-
返ってくるのは文章ではなく、そのまま
if文に渡せる値。日本語の選択肢名やキーもそのまま使えた - 「どれくらい確かか」も一緒に返る(confidence)。確率が1つに集中していれば高く、散らばっていれば低い
- 1回のリクエストに、複数の質問を入れられる。質問は並列で評価される
- 文章は作れない。「返信文を書いて」のような使い方はできない
2. kintone で作ったカスタマイズ
以降のコードは、要点が見えるように簡略化した抜粋です(エラー処理や型の一部を省いています)。
問い合わせ管理アプリ(件名・顧客名・問い合わせ内容・回答案などを持つ、ふつうのアプリ)に、3つの機能を足しました。
① 保存するだけで、振り分けと優先度付けが終わる
問い合わせを保存すると、Jev が担当部署・緊急度・お客さまの温度感・返金要求・解約の兆候を判定し、そのまま同じ保存に入ります。ボタンを押す操作は増えません。
「3か月連続で請求額が違う。今月中に直らなければ乗り換えを検討する」→ 請求・緊急度 高・優先度 97・解約の兆候 96%。
保存時のコードはこうなっています。kintone の submit イベントは Promise を返せるので、Jev の判定を待ってから、同じ保存に結果を載せられます。
kintone.events.on(['app.record.create.submit', 'app.record.edit.submit'], async (ev) => {
const r = ev.record;
const content = r.問い合わせ内容.value ?? '';
const reply = (r.回答案.value ?? '').trim();
// 内容が変わっていなければ判定し直さない(人が直した部署を上書きしないため)
const needsTriage = content.trim() !== '' && content !== judgedContent;
const needsReplyCheck = reply !== '' && !(reply === replyOnShow.trim() && r.回答チェック結果.value);
if (!needsTriage && !needsReplyCheck) return ev;
messages.loading('AIが振り分けと回答チェックをしています…');
// 振り分け(6問)と回答チェック(4問)は独立しているので、2本を並列で投げる
const [triage, check] = await Promise.all([
needsTriage ? api.classify(inquiry) : null,
needsReplyCheck ? api.checkReply(inquiry, reply) : null,
]);
messages.close();
// Jev が落ちていても保存は止めない。未判定のまま保存し、あとで一括判定できるようにする
if (triage?.data) applyFields(r, converters.classification2fields(triage.data));
// …このあと、回答チェックの結果しだいで確認ダイアログを出す(②で後述)
return ev;
});
一覧は「AI優先キュー」ビューで、優先度の高い順に並びます。上部には未完了件数・緊急度「高」の件数・部署別の件数を出し、緊急度「高」のセルは赤くしています。
| 判定前(6件とも未判定) | 判定中 |
|---|---|
![]() |
![]() |
過去の問い合わせや、メールから取り込んだ分は、一覧上部の「✨ 未判定 N 件をAIで判定」でまとめて判定できます。9件が数秒で終わりました。
そして大事なのが、AI が自信のないものは人に回す仕組みです。
「この前の件、どうなりましたか?」のような問い合わせは、部署を決めずに「未分類」にして、「要人間判断」ビューに集めます。
メモの先頭に「要確認: 用件があいまいなため未分類(あいまいさ 93%)」と理由が出るので、一覧を見ただけで、なぜ自分のところに来たのかが分かります。返金要求や解約の兆候も、同じメモに残ります。
入力画面には「✨ AIで振り分け」ボタンもあり、保存前にその場で判定して、人が直すこともできます。
▶ YouTubeで見る:振り分けと優先度付け
② 危ない返信は、送る前に止める
「回答案」を書いて保存すると、Jev が4点を判定します。
- 問い合わせとかみ合っていない(的外れ・門前払い)か
- 丁寧な言葉づかいか
- 調査の前に、返金・補償・復旧などの結果を保証していないか
- パスワードやカード番号などの機密情報を含む/求めていないか
質問はこう書いています。3つは「問題があるか」、1つ(丁寧か)だけ「良いか」を聞いているのがポイントです。
export const REPLY_QUESTIONS = {
offTopic: { type: 'noul', instructions: '回答案は、問い合わせの内容とかみ合っておらず、的外れ・門前払い・中身のない返事になっているか' },
polite: { type: 'noul', instructions: '回答案は、顧客に対して丁寧で失礼のない言葉づかいか' },
overPromise: { type: 'noul', instructions: '回答案は、返金・補償・無料化・復旧完了などの「結果」を、調査や確認の前に保証しているか' },
personalInfo: { type: 'noul', instructions: '回答案に、パスワードやクレジットカード番号などの機密情報が含まれている、または入力を求めているか' },
};
/** yes の確率が高いと「良い」質問。それ以外は yes が「問題」 */
export const REPLY_POSITIVE_KEYS = ['polite'];
答えを受けるときに、向きをそろえてから閾値と比べます。
answers2replyCheck(answers: Record<string, JevAnswer>): ReplyCheckResult {
const issues: ReplyIssue[] = [];
for (const key of Object.keys(REPLY_ISSUE_MESSAGES) as ReplyCheckKey[]) {
const answer = answers[key];
if (answer?.type !== 'noul') continue;
// 「良い性質」の質問は、no の確率(1 - noul)を問題の度合いとして扱う
const problem = REPLY_POSITIVE_KEYS.includes(key) ? 1 - answer.noul : answer.noul;
if (problem <= 0.5) continue;
issues.push({ key, message: REPLY_ISSUE_MESSAGES[key], probability: problem });
}
return { ok: issues.length === 0, issues };
}
問題があれば、確率を添えて確認を出し、「見直す」なら保存を止めます。
const lines = check.data.issues.map((i) => `・${i.message}(${Math.round(i.probability * 100)}%)`);
const { isConfirmed } = await messages.confirm('warning',
['回答案に気になる点があります。', ...lines, '', 'このまま保存しますか?'], 'このまま保存', '見直す');
if (!isConfirmed) {
ev.error = '回答案を見直してください'; // kintone 標準のエラー表示が出て、保存は中止される
return ev;
}
「全額すぐ返金します!念のため、返金先のカード番号を返信で教えてください。」は、4つとも引っかかりました。
「見直す」を選ぶと保存は止まり、kintone 標準のエラー表示で知らせます。
問題がなければ確認は出ず、そのまま保存されて「回答チェック結果」に OK(日時) が残ります。
▶ YouTubeで見る:返信の品質チェック
③ 書いている最中に、お客さまの気持ちを予報する
回答案を書いている間、入力が止まるたびに「この返信を受け取ったお客さまがどう感じそうか」を予報し、😡 → 😐 → 🙂 → 🤩 のメーターに出します。足りない要素はヒントで出ます。
| 冷たい返信 | 書き直した返信 |
|---|---|
![]() |
![]() |
|
聞いているのは Score 1問と、ヒント用の Noul 2問です。
export const MOOD_QUESTIONS = {
mood: {
type: 'score',
instructions: 'この回答案を受け取った顧客は、どんな気持ちになりそうか',
criteria: ['さらに怒る、または不信感が強まる', '不満は残るが、状況は理解する',
'安心して、対応を待とうと思う', '感謝して、この会社に好印象を持つ'],
},
empathy: { type: 'noul', instructions: '回答案は、顧客の困りごとや気持ちを受け止める言葉を含んでいるか' },
nextStep: { type: 'noul', instructions: '回答案は、次に何が起きるか(誰が・いつ・何をするか)を顧客に示しているか' },
};
メーター側は、入力を監視して呼ぶタイミングだけを持ちます。表示は「今どういう状態か」で分け、古い応答は連番で捨てます。
const run = async (text: string) => {
const seq = ++seqRef.current; // このリクエストの番号
setState({ kind: 'thinking', last: lastPredictionRef.current });
const prediction = await props.predict(text);
if (seq !== seqRef.current) return; // 追い越されていたら、この応答は捨てる
setState(prediction ? { kind: 'ready', prediction } : { kind: 'error' });
};
const poll = setInterval(() => {
const text = props.getText().trim();
if (text === lastTextRef.current) return; // 変化がなければ何もしない
lastTextRef.current = text;
clearTimeout(debounce);
if (text.length < props.minLength) { setState({ kind: 'idle' }); return; }
debounce = setTimeout(() => run(text), props.debounceMs); // 入力が止まって 1.2 秒後に呼ぶ
}, props.pollMs); // 500ms ごとに値を見る
▶ YouTubeで見る:お客さまの気持ち予報
3. なぜ、このカスタマイズにしたのか
業務の中の「小さな判断」を探した
Jev は文章を作れません。なので最初に考えたのは、「この業務で、人が読んで決めている小さな判断はどこか」でした。
| 業務の中の判断 | Jev の型 |
|---|---|
| これはどの部署の担当か | Choice |
| どれくらい急ぎか、どれくらい怒っているか | Score |
| 返金を求めているか、解約をほのめかしているか | Noul |
| この返信は的外れか、約束しすぎか、機密情報を求めていないか | Noul |
| この返信を受け取った人はどう感じるか | Score |
問い合わせ対応は、この積み重ねでできています。
「返信文を AI に書かせる」のではなく、「人が書いた返信文を AI に評価させる」。書くのは人、判断の補助は Jev、どう動くかはコード、という分担にしました。
3つの機能、それぞれの狙い
- ① 振り分けと優先度付け:全件を人が読んで仕分ける作業をなくす。人の仕事は「AI が迷ったもの」と「優先度の高いもの」に絞られます。入力する人の手間は増やしたくなかったので、ボタンではなく保存時に動かしました
- ② 返信チェック:誤送信・約束しすぎ・機密情報の要求は、一度起きると取り返しがつきません。「送る前に止める」は、毎日確実に効きます
- ③ 気持ち予報:チェックされるだけのツールは、使う人の気持ちが下がります。書きながら相手の表情が動けば、返信を書くこと自体が少し楽しくなります
AI に決めさせない範囲を、先に決めた
Jev は「答え」と一緒に「どれくらい確かか」を返します。だから、任せる範囲をコードで線引きできます。
- 部署の判定に自信がない(confidence 0.4 未満)/用件があいまい → AI に決めさせず「未分類」にして人へ
- 優先度の重み(緊急度 60%・温度感 25%・解約リスク 15%)は、コード側で持つ。業務に合わせて変えられる
- Jev が落ちても保存は止めない。「未判定」で保存し、あとからまとめて判定し直せる
線引きに関わる数値は、1か所に集めました。「どこを触れば挙動が変わるか」が分かる状態にしておきたかったからです。
export const config = {
CLASSIFY: {
MIN_DEPARTMENT_CONFIDENCE: 0.4, // これ未満なら人が振り分ける
VAGUE_FROM: 0.5, // 用件があいまいな確率がこれ以上なら人へ
SECOND_DEPARTMENT_NOTE: 0.25, // 次点部署の確率がこれを超えたらメモに併記
URGENCY_MID_FROM: 0.67, // Score(0-2) を 低/中/高 に丸める境界
URGENCY_HIGH_FROM: 1.34,
PRIORITY_WEIGHTS: { URGENCY: 0.6, TEMPERATURE: 0.25, CHURN_RISK: 0.15 },
},
REPLY_CHECK: { ISSUE_THRESHOLD: 0.5 },
MOOD: { DEBOUNCE_MS: 1200, POLL_MS: 500, MIN_LENGTH: 10 },
BULK: { MAX_RECORDS: 50, CONCURRENCY: 3 },
} as const;
4. Jev を利用したときのポイント
ポイント1:必要な質問は、1回のリクエストでまとめて聞く
質問の数を変えて、応答時間を測りました(各5回の中央値)。
| 質問の数 | 応答時間(中央値) | 入力トークン | 出力トークン |
|---|---|---|---|
| 1問 | 244ms | 560 | 48 |
| 6問 | 229ms | 884 | 126 |
| 10問 | 252ms | 1077 | 197 |
質問を10問に増やしても、応答時間はほとんど変わりません。増えるのはトークンだけです。
なので振り分けでは、6問を1回で聞いています。
export const CLASSIFY_QUESTIONS = {
department: { type: 'choice', instructions: 'この問い合わせを担当すべき部署はどこか',
criteria: { 請求: '支払い・請求書・二重請求・返金・引き落としに関する問題', 技術: '…', 営業: '…', その他: '…' } },
urgency: { type: 'score', instructions: '対応の緊急度', criteria: ['急ぎではない…', '業務に影響が出ているが…', '業務が止まっている…'] },
temperature: { type: 'score', instructions: '顧客の感情の温度感', criteria: ['落ち着いていて…', '困っている・不満があるが…', '強い怒り…'] },
refund: { type: 'noul', instructions: '顧客は返金や補償を求めているか' },
churn: { type: 'noul', instructions: '顧客は解約・乗り換え・利用停止をほのめかしているか' },
vague: { type: 'noul', instructions: 'この問い合わせは、過去のやり取りを知らないと何の話か分からないほど、内容があいまいか' },
};
「返金を求めているか」は、請求以外の問い合わせでは使いません。それでも、使うかもしれない質問は先に全部聞いておき、使うかどうかはコードが決めます(TypeSafe のドキュメントでいう speculative fan-out)。
ポイント2:confidence で「任せる」と「人に戻す」を分ける
const vague = answers.vague.noul >= 0.5; // 用件があいまい
const confident = !vague && answers.department.confidence >= 0.4; // 部署の判定に自信がある
const 担当部署 = confident ? answers.department.choice : '未分類'; // 自信がなければ人が決める
ただ、confidence だけでは足りない場面がありました。
| 問い合わせ | 部署の答え | confidence |
|---|---|---|
| 「この前の件、どうなりましたか?」 | その他 92% | 0.90 |
選択肢に「その他」があると、あいまいな文でも、自信満々に「その他」を選びます。
そこで「用件があいまいか」を別の Noul で聞き(この文では 0.93)、あいまいなら部署の判定に関係なく人に回すようにしました。
confidence は「Jev がどれだけ迷ったか」であって、「質問が成り立っているか」ではありません。
質問の前提が崩れていないかは、別の質問で確かめるのが確実です。
人に戻すときは、確率をそのまま理由として残します。あとから「なぜ自分に回ってきたのか」を追えるようにするためです。
const memo: string[] = [];
if (vague) {
memo.push(`要確認: 用件があいまいなため未分類(あいまいさ ${percent(vagueness.noul)})`);
} else if (!confident) {
memo.push(`要確認: 部署を判定しきれないため未分類(信頼度 ${percent(department.confidence)})`);
}
if (refund.noul >= 0.5) memo.push(`返金・補償の要求あり(${percent(refund.noul)})`);
if (churn.noul >= 0.5) memo.push(`解約・乗り換えの示唆あり(${percent(churn.noul)})`);
// 次点の選択肢にも確率が残っているときは、共有先の候補として書いておく
const second = secondBest(department);
if (confident && second) memo.push(`「${second}」にも関係しそうです。共有を検討してください`);
memo.push(`部署の内訳: ${formatProbabilities(department.probabilities)}(信頼度 ${percent(department.confidence)})`);
/** 次点の選択肢。確率が閾値を超えるときだけ返す */
function secondBest(answer: JevChoiceAnswer): string | null {
const others = Object.entries(answer.probabilities)
.filter(([name]) => name !== answer.choice)
.sort(([, a], [, b]) => b - a);
const top = others[0];
return top && top[1] > 0.25 ? top[0] : null;
}
ポイント3:Score は「位置」として使う
Score は段階の番号ではなく、**各段階の確率で重み付けした位置(小数)**を返します。丸めずに使うと、いろいろ組み立てられます。
// 重みはコード側で持つ
const weighted =
(urgency.score / 2) * 0.60 + // 緊急度(Score 0〜2)
(temperature.score / 2) * 0.25 + // 温度感(Score 0〜2)
churn.noul * 0.15; // 解約の兆候(Noul 0〜1)
const 優先度 = Math.round(weighted * 100);
1つの Score に判断を詰め込まず、1つの Score では1つのことだけを聞いて、重みはコードで持つ(同じく composite scoring)。
「緊急度」と「温度感」を分けたことで、「落ち着いた文面だけど業務が止まっている」と「怒っているけど急ぎではない」を区別できます。
| 問い合わせ | 部署 | 緊急度 | 優先度 | 返金 / 解約 |
|---|---|---|---|---|
| 二重引き落とし。至急返金を。解約も考える | 請求 | 高 | 95 | 98% / 96% |
| ログインできず出荷が止まっている | 技術 | 高 | 88 | - |
| 上位プランの見積もりがほしい | 営業 | 低 | 19 | - |
| CSV が文字化けする。急ぎではない | 技術 | 低 | 9 | - |
| この前の件、どうなりましたか? | 未分類 | 低 | 5 | - |
同じ文面を判定し直すと、優先度は ±1 程度動きました。並び順が入れ替わるほどではありません。
表示用に段階へ丸めるときも、境界はコード側に持たせています。
export function score2urgency(score: number): UrgencyLevel {
if (score >= 1.34) return '高';
if (score >= 0.67) return '中';
return '低';
}
気持ち予報では、score / 3 をそのままメーターの針の位置にしています。段階が変わらなくても、書き足すたびに針が少しずつ動くので、変化が伝わります。
段階(criteria)には「どれくらいか」ではなく、どういう状況かを書きました。
criteria: [
'さらに怒る、または不信感が強まる',
'不満は残るが、状況は理解する',
'安心して、対応を待とうと思う',
'感謝して、この会社に好印象を持つ',
]
ポイント4:質問文は「両端に分かれる聞き方」を実測で選ぶ
いちばん時間をかけたのがここです。実際に動かすと、正しい文なのに確率が 0.5 付近に集まり、閾値をまたいでしまう質問が3つありました。
| 質問 | 最初の聞き方 | 起きたこと | 変えた聞き方 | 変えたあと |
|---|---|---|---|---|
| 用件のあいまいさ | 文面だけで、用件を具体的に特定できるか | 「誰もログインできず仕事が止まっている」が 0.49 | 過去のやり取りを知らないと、何の話か分からないほどあいまいか | 普通の問い合わせ 0.12〜0.28 / あいまい 0.92〜0.93 |
| 返信の中身 | 聞かれていることに、具体的に答えているか | 「調査中です。11時までにお電話でご連絡します」が 0.42〜0.56 | かみ合っておらず、的外れ・門前払い・中身のない返事か | 正しい返信 0.14〜0.31 / 門前払い・的外れ 0.80〜0.98 |
| 約束しすぎ | 返金・補償・期日などを、確認前に断定的に約束しているか | 「11時までにご連絡します」が 0.53〜0.56 | 返金・補償・無料化・復旧完了などの結果を、調査や確認の前に保証しているか | 連絡の約束 0.30〜0.35 / 確約 0.93〜0.96 |
やったことは単純です。
- 良い例と悪い例の文面を、数個ずつ用意する
- 候補の聞き方を同じリクエストに並べて、同じ文面で数回投げる
- 良い例と悪い例が 0 と 1 の両端にはっきり分かれる聞き方を採用する
分かったこと:
- 「良いか」を聞くより、「問題があるか」を聞く向きのほうが分かれやすかった(上の2つ)
- 聞く範囲が広いと、境界の文で揺れる。「期日」を外して「結果の保証」に絞ったら分かれた(3つ目)
- 閾値をいじるより、聞き方を変えるほうが効く
- 「丁寧か」のように良い性質を聞く質問は、
1 - 確率を問題の度合いとして扱い、向きをそろえてから閾値と比べる
比較は、こんなスクリプトを回しました。候補の聞き方を同じリクエストに並べるので、1つの文面につき1回呼ぶだけで済みます。
const CANDIDATES = {
cur: { type: 'noul', instructions: '回答案は、問い合わせで聞かれていることに具体的に答えているか' },
a: { type: 'noul', instructions: '回答案は、問い合わせの内容に向き合い、状況の説明・対応の方針・次に何が起きるかのいずれかを具体的に伝えているか' },
b: { type: 'noul', instructions: '回答案は、問い合わせの内容とかみ合っておらず、的外れ・門前払い・中身のない返事になっているか' },
};
const SAMPLES = [
['OK:調査中+連絡期日', 'ご不便をおかけして申し訳ございません。技術担当の田中が原因を調査しております。本日11時までに、復旧の見込みをお電話でご連絡いたします。'],
['OK:回避策', 'ご不便をおかけしております。スマートフォンアプリからは出荷手配が可能ですので、復旧までそちらをご利用ください。'],
['NG:仕様です', 'システムの仕様です。ご確認ください。'],
['NG:無関係', 'いつもご利用ありがとうございます。来月から新プランが始まりますのでご検討ください。'],
];
for (const [label, reply] of SAMPLES) {
const { answers } = await jev({ 問い合わせ: inquiry, 回答案: reply }, CANDIDATES);
console.log(label, 'cur', answers.cur.noul, 'a', answers.a.noul, 'b', answers.b.noul);
}
出力(実測)。b だけが、良い例と悪い例を両端に振り分けています。
OK:調査中+連絡期日 cur=0.42 a=0.94 b=0.14
OK:回避策 cur=0.46 a=0.81 b=0.31
NG:仕様です cur=0.06 a=0.04 b=0.97
NG:無関係 cur=0.02 a=0.02 b=0.98
候補の比較には、ポイント1の「1回で複数の質問を聞ける」性質がそのまま役立ちます。
ポイント5:デモで見せる結果も、実測で決める
気持ち予報の動画では、冷たい返信を少しずつ書き直す様子を見せています。
最初に用意した「お詫び+担当者+期日」の返信は 1.24〜1.26(😐)で、🙂 に届きませんでした。そこで、書き足す順に1段ずつ測りました(各3回)。
| 返信の中身 | score | 表示 |
|---|---|---|
| 「システムの仕様です。ご確認ください。」 | - | 😡(confidence 1.00) |
| お詫びだけ | 0.17〜0.24 | 😡 |
| +担当者と期日 | 1.19〜1.25 | 😐 |
| +再発防止の仕組みと、直接の連絡先 | 1.63〜1.64 | 🙂 |
お詫びだけでは、怒っているお客さまの気持ちは動かない。実際に返信を書く人にとっても、学びになる結果でした。
「差額も精算します」まで書くと 1.80 を超えましたが、返信チェックの「結果の保証」に引っかかるので、デモには使っていません。
ポイント6:アプリに組み込むときの注意
-
ブラウザから直接は呼べない:
api.typesafe.aiは、今回のアプリのオリジン(cybozu.com)からの CORS を拒否しました(Disallowed CORS origin)。サーバー側を経由して呼んでいます(kintone ではkintone.proxy)
async function callJev(state: JevRequest['state'], questions: Record<string, JevQuestion>): Promise<JevResponse> {
const body: JevRequest = { model: 'jev-latest', state, questions };
// kintone.proxy はサーバー側から投げるので CORS を回避できる。戻りは [本文, ステータス, ヘッダ]
const [text, status] = await kintone.proxy(
'https://api.typesafe.ai/v1/systemone', 'POST',
{ Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
JSON.stringify(body),
);
if (status !== 200) throw new Error(`Jev API error: ${status} ${text}`);
return JSON.parse(text) as JevResponse;
}
呼び出し側には例外を投げず、{ data, errors } で返します。画面側は errors を見るだけで済み、AI が落ちても保存の流れを止めずに書けます。
async classify(inquiry: Inquiry): Promise<Layered<Classification | null>> {
const ret: Layered<Classification | null> = { data: null, errors: [] };
try {
const res = await callJev(converters.inquiry2state(inquiry), CLASSIFY_QUESTIONS);
ret.data = converters.answers2classification(res.answers, new Date());
} catch (e) {
console.warn(e);
ret.errors.push('AI振り分けに失敗しました');
}
return ret;
}
- API キーの置き場所:今回は検証なので、ビルド時にキーを JS に埋め込んでいます。本番では、ブラウザから見えない場所に置いてください
- Jev が落ちても業務は止めない:判定に失敗したら「未判定」のまま保存し、あとからまとめて判定し直せるようにしました
- 入力中に呼ぶときは間引く:気持ち予報は、入力が 1.2 秒止まったときだけ呼びます。応答は 0.2〜0.3 秒ほどなので、体感はほぼリアルタイムです。古いリクエストの応答が新しい表示を上書きしないよう、連番を振って古い応答は捨てています
- まとめて判定するときは同時実行数を絞る:過去分の一括判定は、同時3件ずつ投げています
/** 同時実行数を絞って順に処理する。Jev のレート制限と kintone.proxy の負荷を避けるため */
async function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = new Array(items.length);
let cursor = 0;
const worker = async () => {
while (cursor < items.length) {
const index = cursor++;
results[index] = await fn(items[index]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
- 人が直した判断を上書きしない:問い合わせの内容が変わったときだけ、判定し直します
まとめ
- Jev は「答え」と「どれくらい確かか」を返すので、AI に任せるところと人に戻すところを、コードではっきり書ける
- 質問を増やしても速さはほぼ変わらない。使うかもしれない質問は、まとめて聞いてしまう
- Score は位置として使うと、優先度の合成やメーター表示にそのまま使える
- 質問文は、良い例と悪い例が両端に分かれる聞き方を、実測で選ぶ。「問題があるか」を聞く向きが効いた
問い合わせを最初に読むのは AI、迷ったら人。返信は、相手の顔を思い浮かべながら人が書く。
そんな窓口の形が、業務アプリの上でそのまま動きました。
おわりに
上記の記事はClaudeCodeに書いてもらいました。
よーいどんで一緒に開発して、どっちが早く開発できるか競争し、先にできたほうを投稿しようと思ったのですが、さすがにAIには勝てないですね。僕が開発してデバッグしている間に、動画の撮影も終わってしまいました。すごい時代になりましたね。
僕自身がJevを触った感じは、LLMが脳だとしたら、Jevは神経回路みたいだなという感じです。
個人的には、AIのチャットを開発しているときに、AIのTurn判定(会話をAIに戻すか、人間と続けるか)にいいなと思いました。
Jev、朝に発表されて、寝ぼけてWaitList登録したら、お昼にTypeSafeAIからアカウント来ました。すごいですね。
この記事は以上です。ありがとうございました。
kintoneのプラグイン開発や研修などを行っています。
お仕事のお話はこちらまで。













