AIチャットや管理画面のUIは継続的に更新されます。個人開発では、久しぶりに問い合わせを開いたときにボタンの位置が変わっているだけで、返信に迷うことがあります。
ここでつらいのは、新UIを覚え直すことではありません。問い合わせを受け取ったのか、AIが何をしたのか、まだ人間の承認が必要なのかが画面の見た目に埋もれることです。
そこで、問い合わせ対応を画面操作の手順ではなく、次の不変条件として実装します。
- 問い合わせは先に保存する
- AIは返信案を作れても承認できない
- 人間の承認なしでは送信できない
- 返信後は訪問者側から到達を確認できる
- UIを並べ替えても同じE2Eテストが通る
結論
管理画面の変更に強い問い合わせ導線を作るには、UIより下にある状態を固定します。
received ──> drafted ──> approved ──> replied
│ │
└────────────┴──> 人間による手動入力へ切り替え可能
AIエージェントのように見せるために自由な自動実行を許すのではなく、各操作に遷移可能な状態を定義します。今回AIに許すのは received -> drafted だけです。
管理画面には、CSSクラスとは別に次の操作契約を持たせます。
<section data-flow='ticket-review' data-state='drafted'>
<textarea data-field='reply'></textarea>
<input type='checkbox' data-action='approve'>
<button data-action='send'>返信を送信</button>
</section>
Playwrightは画面上の座標やDOMの順番ではなく、この契約を使って受付から返信到達まで確認します。
前提
この記事では以下の構成を想定します。
- Node.js 22以降
- TypeScript
- Express
- SQLite(
better-sqlite3) - Playwright
- 訪問者向け問い合わせ画面
/support - 訪問者向け会話画面
/tickets/:token - 運営者向け管理画面
/ops
必要なパッケージを追加します。
mkdir stable-support-flow
cd stable-support-flow
npm init -y
npm install express better-sqlite3
npm install -D typescript tsx @types/express @types/better-sqlite3 @playwright/test
npx playwright install chromium
本番の認証やメール配送を全部実装するのではなく、今回は「UIが変わっても壊してはいけない返信経路」に焦点を当てます。ただし、データはメモリではなくSQLiteへ保存します。
先に固定する操作契約
問い合わせ対応で変わってよいものと、変えてはいけないものを分けます。
| 対象 | 変更してよい | 互換性を維持する |
|---|---|---|
| サイドバーの位置 | ○ | |
| 一覧からドロワーへの変更 | ○ | |
| ボタンの色や文言 | ○ | |
data-action='send' |
○ | |
| 承認前は送信不可 | ○ | |
| 返信後に訪問者が読める | ○ | |
| AI失敗時も問い合わせが残る | ○ |
data-action はテスト専用の飾りではなく、管理画面が提供する操作APIとして扱います。名称を変える場合はE2Eテストも含めた破壊的変更です。
コード1:状態をUIから独立させる
データモデル
schema.sql を作成します。
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
public_token TEXT NOT NULL UNIQUE,
body TEXT NOT NULL,
context_url TEXT,
status TEXT NOT NULL CHECK (
status IN ('received', 'drafted', 'approved', 'replied')
),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS drafts (
ticket_id TEXT PRIMARY KEY REFERENCES tickets(id),
body TEXT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('human', 'ai')),
model_name TEXT,
approved_at TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS replies (
id TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL UNIQUE REFERENCES tickets(id),
body TEXT NOT NULL,
sent_at TEXT NOT NULL
);
tickets.status を画面表示用のラベルにせず、サーバー側の認可判定に使うのがポイントです。管理画面のボタンを無効にするだけでは、APIを直接呼ばれると承認を迂回できるためです。
遷移を一か所に集める
src/workflow.ts に状態遷移を実装します。
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
export type TicketStatus =
| 'received'
| 'drafted'
| 'approved'
| 'replied';
const db = new Database('support.db');
db.pragma('foreign_keys = ON');
export function saveDraft(
ticketId: string,
body: string,
source: 'human' | 'ai',
modelName: string | null = null,
) {
const ticket = db
.prepare('SELECT status FROM tickets WHERE id = ?')
.get(ticketId) as { status: TicketStatus } | undefined;
if (!ticket) throw new Error('ticket_not_found');
if (!['received', 'drafted'].includes(ticket.status)) {
throw new Error(`invalid_transition:${ticket.status}->drafted`);
}
const now = new Date().toISOString();
db.transaction(() => {
db.prepare(`
INSERT INTO drafts (
ticket_id, body, source, model_name, approved_at, updated_at
) VALUES (?, ?, ?, ?, NULL, ?)
ON CONFLICT(ticket_id) DO UPDATE SET
body = excluded.body,
source = excluded.source,
model_name = excluded.model_name,
approved_at = NULL,
updated_at = excluded.updated_at
`).run(ticketId, body, source, modelName, now);
db.prepare(`
UPDATE tickets SET status = 'drafted' WHERE id = ?
`).run(ticketId);
})();
}
export function approveDraft(ticketId: string, editedBody: string) {
if (!editedBody.trim()) throw new Error('empty_reply');
const now = new Date().toISOString();
const result = db.prepare(`
UPDATE tickets
SET status = 'approved'
WHERE id = ? AND status = 'drafted'
`).run(ticketId);
if (result.changes !== 1) {
throw new Error('draft_required_before_approval');
}
db.prepare(`
UPDATE drafts
SET body = ?, approved_at = ?, updated_at = ?
WHERE ticket_id = ?
`).run(editedBody.trim(), now, now, ticketId);
}
export function publishReply(ticketId: string) {
return db.transaction(() => {
const draft = db.prepare(`
SELECT body FROM drafts
WHERE ticket_id = ? AND approved_at IS NOT NULL
`).get(ticketId) as { body: string } | undefined;
if (!draft) throw new Error('approved_draft_not_found');
const changed = db.prepare(`
UPDATE tickets
SET status = 'replied'
WHERE id = ? AND status = 'approved'
`).run(ticketId);
if (changed.changes !== 1) {
throw new Error('approval_required_before_reply');
}
const reply = {
id: randomUUID(),
body: draft.body,
sentAt: new Date().toISOString(),
};
db.prepare(`
INSERT INTO replies (id, ticket_id, body, sent_at)
VALUES (?, ?, ?, ?)
`).run(reply.id, ticketId, reply.body, reply.sentAt);
return reply;
})();
}
承認と返信公開を別操作にしたのは、誤送信を防ぐためです。一方、approved -> replied と返信レコードの追加は同じトランザクションに入れています。
外部メールAPIへ送る場合は、このトランザクション内でネットワーク通信をしてはいけません。後述するOutboxなどへ分離します。
コード2:AIには下書き権限だけを渡す
AIが実際に改善しやすいのは、問い合わせの要点整理、文体の統一、返信案の作成です。今回の構成だけでは、次の能力は実証されません。
- 返金や契約変更を決定できる
- ユーザー本人を識別できる
- 最新の障害状況を把握している
- 返信内容が事実だと保証できる
そのため、AIアダプターの戻り値は下書きに限定します。
export type Inquiry = {
id: string;
body: string;
contextUrl: string | null;
};
export type DraftResult = {
body: string;
provider: string;
model: string;
};
export interface DraftProvider {
createDraft(inquiry: Inquiry): Promise<DraftResult>;
}
export class FixtureDraftProvider implements DraftProvider {
async createDraft(inquiry: Inquiry): Promise<DraftResult> {
return {
body: [
'お問い合わせありがとうございます。',
`確認内容: ${inquiry.body}`,
'状況を確認し、必要に応じて追加情報をご案内します。',
].join('\n\n'),
provider: 'fixture',
model: 'deterministic-v1',
};
}
}
E2Eテストでは実際のLLMを使わず、必ず同じ結果を返す FixtureDraftProvider を使います。モデルの応答速度や出力揺れと、問い合わせ導線そのものの不具合を分離するためです。
実際のプロバイダーを追加しても、呼び出し後に許可する処理は saveDraft() だけにします。AIアダプターから approveDraft() や publishReply() を参照できないモジュール構成にすると、意図しない権限拡大を防ぎやすくなります。
手順:管理画面に安定した操作面を作る
画面を作るときは、CSS用クラスとE2E用の操作契約を分けます。
<article
class='ticket-card ticket-card--drawer'
data-flow='ticket-review'
data-state='drafted'
data-ticket-id='TICKET_ID'
>
<header>
<span data-field='status'>下書き確認中</span>
</header>
<div class='ticket-card__body'>
<p data-field='inquiry'>問い合わせ本文</p>
<label>
返信内容
<textarea data-field='reply'>AIまたは人間が作成した下書き</textarea>
</label>
</div>
<footer>
<button type='button' data-action='draft'>下書きを作成</button>
<label>
<input type='checkbox' data-action='approve'>
内容と宛先を確認した
</label>
<button type='button' data-action='send' disabled>
返信を送信
</button>
</footer>
</article>
レイアウト変更後に <footer> がサイドバーやドロワーへ移動しても、以下の契約を維持します。
-
data-flow='ticket-review'が一件の作業範囲を表す - 返信編集欄は
data-field='reply' - 人間の承認操作は
data-action='approve' - 最終送信は
data-action='send' -
data-stateはサーバーの状態と一致する
フロントエンドでチェックボックスを入れたときだけ送信ボタンを有効化しても、最終判定はAPI側の approved 状態で行います。
コード3:受付から返信到達までをPlaywrightで確認する
tests/support-flow.spec.ts を作ります。
import { test, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
test('問い合わせを人間が承認し、訪問者が返信を読める', async ({
browser,
}) => {
const marker = `e2e-${randomUUID()}`;
const visitorContext = await browser.newContext();
const visitor = await visitorContext.newPage();
await visitor.goto('http://127.0.0.1:3000/support');
await visitor.getByLabel('問い合わせ内容').fill(
`設定を保存しても反映されません。識別子: ${marker}`,
);
await visitor.getByRole('button', { name: '送信' }).click();
await expect(visitor).toHaveURL(/\/tickets\/[A-Za-z0-9_-]+$/);
await expect(visitor.locator('[data-field=receipt]')).toContainText(
'受け付けました',
);
const ticketUrl = visitor.url();
const operatorContext = await browser.newContext();
// この経路はNODE_ENV=testの場合だけ有効にする。
await operatorContext.request.post(
'http://127.0.0.1:3000/__test__/login',
{ data: { role: 'operator' } },
);
const operator = await operatorContext.newPage();
await operator.goto('http://127.0.0.1:3000/ops?layout=drawer');
const ticket = operator
.locator('[data-flow=ticket-review]')
.filter({ hasText: marker });
await expect(ticket).toHaveAttribute('data-state', 'received');
await expect(ticket.locator('[data-action=send]')).toBeDisabled();
await ticket.locator('[data-action=draft]').click();
await expect(ticket).toHaveAttribute('data-state', 'drafted');
const reply = ticket.locator('[data-field=reply]');
await reply.fill(
'お問い合わせありがとうございます。設定の再読み込み手順をご案内します。',
);
await ticket.locator('[data-action=approve]').check();
await expect(ticket).toHaveAttribute('data-state', 'approved');
await expect(ticket.locator('[data-action=send]')).toBeEnabled();
await ticket.locator('[data-action=send]').click();
await expect(ticket).toHaveAttribute('data-state', 'replied');
await visitor.goto(ticketUrl);
await expect(visitor.locator('[data-flow=reply-message]')).toContainText(
'設定の再読み込み手順をご案内します',
);
});
このテストは「送信ボタンを押せた」だけでは合格しません。運営者が返信した後、別のブラウザーコンテキストにいる訪問者が返信を読めるところまで確認します。
また、?layout=drawer を ?layout=list に変えたテストも用意します。2種類のテンプレートでDOM順が異なっていても、操作契約を維持していれば同じテストを再利用できます。
確認方法
1. 通常の返信経路
テスト用サーバーを起動してPlaywrightを実行します。
NODE_ENV=test DRAFT_MODE=fixture npx tsx src/server.ts
npx playwright test tests/support-flow.spec.ts
確認対象は次の5点です。
- 問い合わせ送信後に受付URLへ移動する
- 管理画面で同じ問い合わせを識別できる
- 承認前は送信できない
- 承認後だけ
repliedへ遷移する - 訪問者の別セッションで返信を読める
2. UIを並べ替える
管理画面のカードを、一覧型からドロワー型へ変更します。次の変更だけならテストは通るべきです。
- ヘッダーとフッターの順序変更
- ボタン文言の変更
- サイドバーへの操作領域の移動
- CSSクラスの全面変更
一方、data-action='approve' を削除した場合はテストを失敗させます。これはテストが壊れたのではなく、人間の承認という業務契約が消えたことを検出しています。
3. AIを停止する
下書きAPIを意図的に503にし、次を確認します。
期待する結果:
- ticket.status は received のまま
- 問い合わせ本文は残る
- 手動で返信案を入力できる
- 送信前の承認は省略されない
AIが停止しただけで問い合わせ対応全体が止まるなら、AIが補助機能ではなく単一障害点になっています。
4. 承認APIを迂回する
approved ではないチケットに対して返信APIを直接呼びます。
curl -i -X POST http://127.0.0.1:3000/api/ops/tickets/TICKET_ID/send
期待するステータスは 409 Conflict です。画面上でボタンが無効になっているかではなく、サーバーが不正な状態遷移を拒否することを確認します。
AIを使うか判断する基準
AI導入の判断は、「賢そうな返信が作れたか」よりも、誤りを誰が訂正できるかで決めます。
| 問い合わせ | AIに任せる範囲 | 人間が判断すること |
|---|---|---|
| 操作方法 | 要約、既存手順から下書き | 現行UIとの一致 |
| 不具合報告 | 再現条件の整理 | 不具合か仕様か |
| 課金・返金 | 文面の整形まで | 金額、本人確認、実行可否 |
| データ削除 | 受付内容の整理まで | 本人確認、対象範囲、実行 |
| セキュリティ報告 | 要点抽出も慎重に扱う | 閲覧権限、優先度、開示範囲 |
個人開発者に残る重要な役割は、文章を毎回ゼロから書くことではありません。どの状態なら次へ進めてよいかを決め、例外時に止めることです。
返信が容易に取り消せず、アカウント・金銭・個人情報へ影響するほど、人間の承認を明示的な状態として残す価値が高くなります。
自前実装しない場合
モバイルアプリ内に問い合わせ窓口を早く設置したい場合は、Knocket のモバイルWebView SDKも実装候補になります。Webサイト向けウィジェット、共有可能な問い合わせページ、統合受信箱も提供されています。
訪問者はKnocketアカウントなしでチャットを開始でき、メッセージをTelegramへ転送し、Telegramでの引用返信をWebサイト側へ返す構成も選べます。
外部サービスを採用する場合も、製品名や管理画面の位置ではなく、次のシナリオを受け入れテストにします。
- アプリのWebViewから一意な識別子を含む問い合わせを送る
- 運営者の受信箱に同じ識別子が現れる
- 運営者が引用返信する
- 元の訪問者側で返信を読める
- 別の訪問者セッションから会話を閲覧できない
自前実装と外部サービスの選択基準は、UIの好みではなく、認証・保存・通知・返信配送を自分で運用したいかです。
注意点
-
/__test__/loginはNODE_ENV=test以外で必ず404にしてください。 - 運営者APIには、本番用のセッション認証とCSRF対策が必要です。
- 問い合わせ本文やAIプロンプトへ、アクセストークンや不要な個人情報を含めないでください。
-
data-actionは外部公開APIではありませんが、変更時にはE2Eテストを更新するレビューを必須にします。 - メールやプッシュ通知へ配送する場合はOutboxテーブルを追加し、DB更新と外部通信を分離してください。
- AIの入力・出力を保存する場合は、保存期間と閲覧権限を先に決めてください。
- E2Eテストには実LLMを使わず、別途行うモデル評価と導線の回帰テストを分離します。
UIの変更に戸惑うこと自体は、スキル不足ではありません。画面の位置を記憶し続けるのではなく、状態遷移と受け入れテストを残せば、見た目が変わっても「何を確認してから返信するか」は維持できます。
開示:筆者は Knocket の開発・運営に関わっています。本記事では中立的なランキングではなく、実装例の一つとして紹介します。