Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d99b66e
TTSをAzure SpeechからOpenAI gpt-4o-mini-ttsの女性声へ全面移行 (#2)
TinyKitten Aug 13, 2026
b2ea58b
TTSの既定ボイスを日英で分けて早口寄りに調整する (#3)
TinyKitten Aug 13, 2026
7782d5c
AIチャットのモデルにGemini(Vertex AI)を追加する (#4)
TinyKitten Aug 17, 2026
bf4776d
TTSをOpenAIからGoogle Cloud Text-to-Speechへ全面移行する (#5)
TinyKitten Aug 17, 2026
16a6518
本番の AGENT_MODEL を Gemini(Vertex AI)へ切り替える (#6)
TinyKitten Aug 17, 2026
335bbf4
staging の駅検索を sapi-bff-stg から stationapi-stg へ切り替える (#7)
TinyKitten Aug 22, 2026
d7dcbb3
本番の駅検索を sapi-bff から stationapi へ切り替える (#9)
TinyKitten Aug 23, 2026
7370018
フィードバックを原因リポジトリへ振り分け、トリアージの起票品質を改善する (#14)
TinyKitten Aug 27, 2026
f850f3e
アプリ指定の読み上げ速度を受け付け既定速度を等速へ変更 (#16)
TinyKitten Aug 27, 2026
06ce6bc
フィードバックのキューに dead letter queue を設定する (#19)
TinyKitten Aug 27, 2026
631398b
DLQ の紐付けを確認する手順を README に追加する (#22)
TinyKitten Aug 27, 2026
8c47ab7
フィードバック再試行での Issue 重複起票を防ぐ (#23)
TinyKitten Aug 28, 2026
94a409c
AI エージェントのモデルを Gemini 3.8 Flash へ更新する (#24)
TinyKitten Sep 3, 2026
f3fa862
英語TTSで「Keisei」が「かいせい」と読まれる誤読を合成前の表記置換で修正する (#25)
TinyKitten Sep 6, 2026
e73f616
GitHub Actions で dev・master からのデプロイを自動化する (#26)
TinyKitten Sep 6, 2026
a63ffe3
英語TTSで「Seibu」が「さいぶ」と読まれる誤読を合成前の表記置換で修正する (#27)
TinyKitten Sep 6, 2026
439ec90
master を dev へ取り込み AGENTS.md を同期する (#30)
TinyKitten Sep 12, 2026
3dda858
フィードバックトリアージの判定をTypeSafeに切り替える (#32)
TinyKitten Sep 17, 2026
6ee0bef
提案駅のリランクをTypeSafeで判定して対話ターンへ組み込む (#33)
TinyKitten Sep 17, 2026
cce388d
リランクの注記を user ロールで注入し Gemini でのターン失敗を直す (#34)
TinyKitten Sep 17, 2026
687a32d
master をリリースブランチへ取り込みコンフリクトを解消する
TinyKitten Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .secrets.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ OPENAI_API_KEY=
GOOGLE_VERTEX_SA_KEY=
# LangSmith トレーシング(dev のみ・任意)
LANGSMITH_API_KEY=
# --- フィードバックのトリアージ ---
# 判定(スパム・カテゴリ・優先度・原因コンポーネント)に使う TypeSafe の API キー。
# 計測スクリプト(npm run typesafe-spike)はこの値を環境変数から直接読むため、
# Worker のシークレットとして投入する必要があるのは本体を切り替えてから。
TYPESAFE_API_KEY=
# --- TTS(/tts)---
# Cloud Text-to-Speech を呼べるサービスアカウント鍵 JSON(必須)。
# 複数行 JSON は GOOGLE_TTS_SA_KEY_FILE=./secrets-tts-sa.json ./scripts/put-secrets.sh が
Expand Down
20 changes: 20 additions & 0 deletions agent-rerank-eval.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{"id": "ocean-nationwide", "request": "海が見える駅に行きたい", "queries": ["海", "根府川", "稲毛海岸"], "expect": ["根府川", "稲毛海岸"], "reject": ["北海道医療大学"], "note": "「海」の部分一致で海と無関係な駅が混ざるプールから拾えるか。種差海岸・松島海岸は実際に海沿いなので reject にしない"}
{"id": "ocean-from-tokyo", "request": "海が見える駅に行きたい", "from": "東京", "queries": ["根府川", "早川", "真鶴", "海"], "expect": ["根府川", "早川", "真鶴"], "reject": [], "note": "現在駅つき。東海道線の海側3駅はシステムプロンプトが例示している"}
{"id": "ocean-english", "request": "I want to go to a station with an ocean view", "from": "東京", "queries": ["根府川", "真鶴", "海"], "expect": ["根府川", "真鶴"], "reject": [], "note": "英語の要望と日本語の候補。命令文が日本語でも判定できるか"}
{"id": "onsen-nationwide", "request": "温泉がある駅に行きたい", "queries": ["温泉", "鬼怒川温泉", "大手町"], "expect": [], "reject": ["大手町"], "note": "無関係な駅を1件混ぜて閾値が効くかを見る。他の温泉駅も「温泉がある駅」として妥当なので expect は置かない(実測で全て0.94以上)"}
{"id": "onsen-atami", "request": "日帰りで温泉に入れる駅はある?", "from": "東京", "queries": ["熱海", "温泉"], "expect": ["熱海"], "reject": [], "note": "東海道線で直通。要望が「日帰り」でも駅の妥当性は変わらない"}
{"id": "atami-by-name", "request": "熱海に行きたい", "queries": ["熱海", "来宮"], "expect": ["熱海"], "reject": ["来宮", "磐梯熱海"], "note": "駅名の直接指定。隣接駅と、名前に「熱海」を含む別地域の駅を拾ってはいけない"}
{"id": "shinjuku-exact", "request": "新宿駅に行きたい", "queries": ["新宿"], "expect": ["新宿"], "reject": ["西新宿", "新宿三丁目", "新宿御苑前"], "note": "同名前方一致の取り違え。現在駅なしだと limit:10 が全部「新宿」で埋まり識別テストにならないため東京を起点にする", "from": "東京"}
{"id": "shinjuku-gyoen", "request": "新宿御苑に行きたい", "queries": ["新宿御苑", "新宿"], "expect": ["新宿御苑前"], "reject": ["新宿"], "note": "shinjuku-exact の逆方向。最短一致に倒れていないか。現在駅なしでは新宿御苑前がプールに入らなかった", "from": "東京"}
{"id": "kamakura-kokomae", "request": "鎌倉高校前に行きたい", "queries": ["鎌倉"], "expect": ["鎌倉高校前"], "reject": ["鎌倉", "北鎌倉"], "note": "部分一致で親の駅名が上位に来る典型"}
{"id": "kamakura-kokomae-roman", "request": "Kamakura-kokomae", "queries": ["鎌倉"], "expect": ["鎌倉高校前"], "reject": ["鎌倉"], "note": "ローマ字の駅名指定。候補側は日本語表記"}
{"id": "inage-kaigan", "request": "稲毛海岸", "queries": ["稲毛"], "expect": ["稲毛海岸"], "reject": ["稲毛", "京成稲毛"], "note": "駅名だけの発話。要望の語をそのまま含む駅を選べるか"}
{"id": "kinugawa-partial", "request": "鬼怒川温泉に行きたい", "queries": ["鬼怒川"], "expect": ["鬼怒川温泉"], "reject": ["鬼怒川公園"], "note": "システムプロンプトが例示している引き直し(鬼怒川温泉→鬼怒川)のプール"}
{"id": "osaka-castle", "request": "お城が見たい", "queries": ["城", "大阪城公園"], "expect": ["大阪城公園"], "reject": ["磐城太田"], "note": "「城」の部分一致は旧国名の「磐城」など城と無関係な駅名を多く含む"}
{"id": "tateyama-boso", "request": "房総の海沿いに行きたい", "from": "東京", "queries": ["館山", "海"], "expect": ["館山"], "reject": ["熱海", "東海"], "note": "地域名つきの要望。内房線はシステムプロンプトの例示。房総でない海沿いの駅を弾けるか"}
{"id": "quiet-scenery", "request": "静かで景色のいい駅に行きたい", "from": "東京", "queries": ["根府川", "新宿", "東京"], "expect": ["根府川"], "reject": ["新宿"], "note": "主観的な要望。繁華街の駅を弾けるか"}
{"id": "ambiguous-short", "request": "どこか行きたい", "queries": ["新宿", "根府川"], "expect": [], "reject": [], "note": "条件が無い発話。expect も reject も置かず、確率が横並びになるかだけを見る"}
{"id": "no-match-ski", "request": "スキー場のある駅に行きたい", "queries": ["新宿", "大手町"], "expectEmpty": true, "note": "合成。プールを意図的に無関係にした。閾値で全部落ちるべき(温泉地はスキー場が近いものがあるため検索語から外した)"}
{"id": "no-match-zoo", "request": "動物園に行きたい", "queries": ["大手町", "稲毛"], "expectEmpty": true, "note": "合成。同上"}
{"id": "no-match-airport", "request": "空港に行きたい", "queries": ["根府川", "熱海"], "expectEmpty": true, "note": "合成。要望は実在するが、プールには一致する駅が無い"}
{"id": "no-match-unrelated-onsen", "request": "美術館のある駅に行きたい", "queries": ["温泉", "鬼怒川温泉"], "expectEmpty": true, "note": "合成。温泉駅だけのプールに美術館の要望を当てる"}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"format": "biome format . --write",
"cf-typegen": "wrangler types",
"find-tts-cache": "tsx src/cli/find-tts-cache.ts",
"find-orphaned-tts": "tsx src/cli/find-orphaned-tts.ts"
"find-orphaned-tts": "tsx src/cli/find-orphaned-tts.ts",
"typesafe-spike": "tsx src/cli/typesafe-triage-spike.ts",
"typesafe-rerank-spike": "tsx src/cli/typesafe-rerank-spike.ts"
},
"engines": {
"node": ">=22"
Expand Down
186 changes: 186 additions & 0 deletions src/agent/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,3 +672,189 @@ describe('handleAgentChatStream', () => {
expect((classifyTopic as AnyFn).mock.calls[0][2].aborted).toBe(true);
});
});

/**
* リランクの組み込み。判定そのものは rerank.test.ts が見るので、ここでは
* 「注入されるか / 何回走るか / 失敗したときに今の挙動へ戻るか」だけを見る。
*/
describe('提案駅のリランクの組み込み', () => {
/** 検索を 1 回済ませた時点で prepareStep に渡されるメッセージ列(本番と同じ形) */
const messagesAfterSearch: AnyFn[] = [
{ role: 'user', content: '海が見える駅に行きたい' },
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'search_stations_by_name',
input: { name: '熱海' },
},
],
},
{
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: 'call-1',
toolName: 'search_stations_by_name',
output: {
type: 'json',
value: { stations: [{ id: 1, name: '熱海' }] },
},
},
],
},
];

/**
* ツールを 1 回実行して verified を埋めたあと prepareStep を呼び、
* その結果のメッセージ列(上書きしなければ undefined)を返す。
*/
const preparedMessages = async (
rerank: AnyFn | undefined,
stepNumber = 1
): Promise<AnyFn[] | undefined> => {
let injected: AnyFn[] | undefined;
const streamText: AnyFn = jest.fn(async (options: AnyFn) => {
await options.tools.search_stations_by_name.execute({ name: '熱海' }, {});
const prepared = await options.prepareStep({
stepNumber,
messages: messagesAfterSearch,
});
injected = prepared?.messages;
return streamResult({ output: { reply: 'ok', suggestions: [] } });
});
await runAgentTurn({
...baseParams,
streamText,
searchStations: jest
.fn()
.mockResolvedValue([station(1, '熱海'), station(2, '来宮')]),
currentStationName: '東京',
rerank: rerank as AnyFn,
});
return injected;
};

const injectedMessage = async (
rerank: AnyFn | undefined,
stepNumber = 1
): Promise<AnyFn | undefined> =>
(await preparedMessages(rerank, stepNumber))?.at(-1);

const injectedNote = async (
rerank: AnyFn | undefined,
stepNumber = 1
): Promise<string | undefined> =>
(await injectedMessage(rerank, stepNumber))?.content;

it('無効(rerank 未指定)なら何も注入しない', async () => {
await expect(injectedNote(undefined)).resolves.toBeUndefined();
});

it('選ばれた駅を順序付きで注入する', async () => {
const note = await injectedNote(
jest.fn().mockResolvedValue([station(1, '熱海')])
);
expect(note).toContain('提案してよい駅');
expect(note).toContain('1. 熱海');
expect(note).not.toContain('来宮');
});

// Gemini(Vertex)は会話の途中の system メッセージを受け付けず、リクエスト
// 組み立ての時点で UnsupportedFunctionalityError になる。注入は user で行う
it('注入は user ロールで行う', async () => {
const message = await injectedMessage(
jest.fn().mockResolvedValue([station(1, '熱海')])
);
expect(message?.role).toBe('user');
});

// ツール結果より前に置くと、モデルは提案集合を知る前に本文を書き始められる。
// 既存のメッセージ列を保ったまま末尾に足すことも併せて見る
it('既存のメッセージ列の末尾(ツール結果の後ろ)へ注入する', async () => {
const messages = await preparedMessages(
jest.fn().mockResolvedValue([station(1, '熱海')])
);
expect(messages?.slice(0, -1)).toEqual(messagesAfterSearch);
expect(messages?.at(-2)?.role).toBe('tool');
expect(messages?.at(-1)?.content).toContain('提案してよい駅');
});

it('要望に合う駅が無ければ、空配列にして正直に伝えるよう注入する', async () => {
const note = await injectedNote(jest.fn().mockResolvedValue([]));
expect(note).toContain('見つからなかった');
expect(note).toContain('空配列');
});

// 判定できなかったときに注入すると、モデルは提案を全部失う。今の挙動
// (モデルが自分で選ぶ)へ戻すのがフォールバック
it('判定できなかった(null)ときは何も注入しない', async () => {
await expect(
injectedNote(jest.fn().mockResolvedValue(null))
).resolves.toBeUndefined();
});

it('判定材料として直近のユーザ発話と現在駅を渡す', async () => {
const rerank = jest.fn().mockResolvedValue([station(1, '熱海')]);
await injectedNote(rerank);
expect(rerank).toHaveBeenCalledWith(
{ request: '海が見える駅に行きたい', currentStationName: '東京' },
[station(1, '熱海'), station(2, '来宮')]
);
});

// prepareStep はツール実行のたびに走る。都度判定すると最大 3 回ぶんの往復が
// 最初の delta までのレイテンシに積み上がる
it('prepareStep が何度呼ばれてもターンに 1 回だけ判定する', async () => {
const rerank = jest.fn().mockResolvedValue([station(1, '熱海')]);
const streamText: AnyFn = jest.fn(async (options: AnyFn) => {
await options.tools.search_stations_by_name.execute({ name: '熱海' }, {});
for (const stepNumber of [1, 2, 3]) {
await options.prepareStep({ stepNumber, messages: [] });
}
return streamResult({ output: { reply: 'ok', suggestions: [] } });
});
await runAgentTurn({
...baseParams,
streamText,
searchStations: jest.fn().mockResolvedValue([station(1, '熱海')]),
rerank: rerank as AnyFn,
});
expect(rerank).toHaveBeenCalledTimes(1);
});

it('ツール結果が無ければ判定しない(使い方の質問など)', async () => {
const rerank = jest.fn();
const streamText: AnyFn = jest.fn(async (options: AnyFn) => {
await options.prepareStep({ stepNumber: 1, messages: [] });
return streamResult({ output: { reply: 'ok', suggestions: [] } });
});
await runAgentTurn({
...baseParams,
streamText,
searchStations: jest.fn(),
rerank: rerank as AnyFn,
});
expect(rerank).not.toHaveBeenCalled();
});

it('イテレーション上限では注入の有無に関わらずツールを外す', async () => {
let prepared: AnyFn;
const streamText: AnyFn = jest.fn(async (options: AnyFn) => {
await options.tools.search_stations_by_name.execute({ name: '熱海' }, {});
prepared = await options.prepareStep({ stepNumber: 3, messages: [] });
return streamResult({ output: { reply: 'ok', suggestions: [] } });
});
await runAgentTurn({
...baseParams,
streamText,
searchStations: jest.fn().mockResolvedValue([station(1, '熱海')]),
rerank: jest.fn().mockResolvedValue([station(1, '熱海')]) as AnyFn,
});
expect(prepared.activeTools).toEqual([]);
expect(prepared.messages.at(-1).content).toContain('提案してよい駅');
});
});
Loading
Loading