1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

判断特化AI「Jev」で、kintoneの問い合わせ対応を「AIが先に読む」形にしてみた

1
Posted at

TypeSafe の Jev は、文章を書かずに「判断」だけを返す AI です。
これを kintone の問い合わせ管理アプリに組み込んで、3つの機能を作りました。

記事中の数値は、2026年9月17〜18日に jev-latest(応答では jev-1.13.0)へ実際に投げて測ったものです。

カスタマイズしたアプリの画面はこんな感じ。
03-index-after.png

振り分けと優先度付け

返信の品質チェック

お客さまの気持ち予報

記事中のスクリーンショットは、動画と同じ画面の静止画です。


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 が担当部署・緊急度・お客さまの温度感・返金要求・解約の兆候を判定し、そのまま同じ保存に入ります。ボタンを押す操作は増えません。

09b-zero-touch-detail-lower.png

「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件とも未判定) 判定中
01-index-before.png 02-bulk-progress.png

過去の問い合わせや、メールから取り込んだ分は、一覧上部の「✨ 未判定 N 件をAIで判定」でまとめて判定できます。9件が数秒で終わりました。

02b-bulk-done.png

そして大事なのが、AI が自信のないものは人に回す仕組みです。
「この前の件、どうなりましたか?」のような問い合わせは、部署を決めずに「未分類」にして、「要人間判断」ビューに集めます。

11-human-review-view.png

メモの先頭に「要確認: 用件があいまいなため未分類(あいまいさ 93%)」と理由が出るので、一覧を見ただけで、なぜ自分のところに来たのかが分かります。返金要求や解約の兆候も、同じメモに残ります。

12-refund-churn-detail.png

入力画面には「✨ AIで振り分け」ボタンもあり、保存前にその場で判定して、人が直すこともできます。

04b-classify-toast.png

▶ 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つとも引っかかりました。

07-reply-check-dialog.png

「見直す」を選ぶと保存は止まり、kintone 標準のエラー表示で知らせます。

07b-review-error.png

問題がなければ確認は出ず、そのまま保存されて「回答チェック結果」に OK(日時) が残ります。

08b-saved-detail-lower.png

▶ YouTubeで見る:返信の品質チェック

③ 書いている最中に、お客さまの気持ちを予報する

回答案を書いている間、入力が止まるたびに「この返信を受け取ったお客さまがどう感じそうか」を予報し、😡 → 😐 → 🙂 → 🤩 のメーターに出します。足りない要素はヒントで出ます。

冷たい返信 書き直した返信
05-mood-cold.png 06b-mood-rewritten.png

|

聞いているのは 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 ごとに値を見る

06-mood-warm.png

▶ 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

やったことは単純です。

  1. 良い例と悪い例の文面を、数個ずつ用意する
  2. 候補の聞き方を同じリクエストに並べて、同じ文面で数回投げる
  3. 良い例と悪い例が 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のプラグイン開発や研修などを行っています。
お仕事のお話はこちらまで。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?