From d99b66e78b5e58695b5714bd2d0328e1d841e1d2 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 13 Aug 2026 19:20:33 +0900 Subject: [PATCH 01/16] =?UTF-8?q?TTS=E3=82=92Azure=20Speech=E3=81=8B?= =?UTF-8?q?=E3=82=89OpenAI=20gpt-4o-mini-tts=E3=81=AE=E5=A5=B3=E6=80=A7?= =?UTF-8?q?=E5=A3=B0=E3=81=B8=E5=85=A8=E9=9D=A2=E7=A7=BB=E8=A1=8C=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .secrets.env.example | 1 - README.md | 57 ++++++- scripts/build-secrets-json.mjs | 1 - src/agent/prompt.test.ts | 4 +- src/cli/find-tts-cache.ts | 30 ++-- src/cli/lib/wrangler.ts | 4 + src/lib/azure/tts.test.ts | 56 ------- src/lib/azure/tts.ts | 108 ------------ src/lib/openai/tts.test.ts | 168 +++++++++++++++++++ src/lib/openai/tts.ts | 148 ++++++++++++++++ src/lib/ttsCache.test.ts | 127 ++++++++++++++ src/lib/ttsCache.ts | 72 +++++--- src/routes/tts.test.ts | 137 +++++++++++++++ src/routes/tts.ts | 297 ++++++++++++++++++++------------- src/types.ts | 40 +++-- src/utils/normalize.test.ts | 20 +++ src/utils/normalize.ts | 14 +- src/utils/ssml.ts | 23 +++ src/utils/ttsVoice.test.ts | 126 +++++++++----- src/utils/ttsVoice.ts | 98 ++++++++--- wrangler.jsonc | 26 +-- 21 files changed, 1133 insertions(+), 424 deletions(-) delete mode 100644 src/lib/azure/tts.test.ts delete mode 100644 src/lib/azure/tts.ts create mode 100644 src/lib/openai/tts.test.ts create mode 100644 src/lib/openai/tts.ts create mode 100644 src/lib/ttsCache.test.ts create mode 100644 src/routes/tts.test.ts diff --git a/.secrets.env.example b/.secrets.env.example index 474d447..7bd13a7 100644 --- a/.secrets.env.example +++ b/.secrets.env.example @@ -7,7 +7,6 @@ # ./scripts/put-secrets.sh --env production # prod(.secrets.prod.env を使うなら SECRETS_FILE=... を併用) SESSION_JWT_SECRET= -AZURE_SPEECH_KEY= # GOOGLE_PLAY_SA_KEY はここに 1 行 JSON を入れてもよいが、複数行 JSON は # GOOGLE_PLAY_SA_KEY_FILE=./sa.json ./scripts/put-secrets.sh の方が扱いやすい。 GOOGLE_PLAY_SA_KEY= diff --git a/README.md b/README.md index 7e7ff5b..7805d2a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ single Worker. ## Features -- **TTS synthesis** (`POST /tts`): synthesizes SSML into audio via Azure Speech and caches it in KV/R2. +- **TTS synthesis** (`POST /tts`): synthesizes plain text into audio via OpenAI `gpt-4o-mini-tts` and caches it in KV/R2. - **Session issuance** (`POST /auth/token`): issues a short-lived session JWT from an install ID (the replacement for Firebase anonymous auth). - **Feedback intake** (`POST /postFeedback`): enqueues feedback onto the triage queue. - **Image upload** (`POST /feedback/upload-image`): stores feedback images in R2 and returns a public URL. @@ -22,7 +22,7 @@ single Worker. - **R2** — audio binaries and feedback images - **Cloudflare Queues** — `feedback-triage` - **Workers AI** — feedback triage -- **Azure Speech** — TTS synthesis (SSML) +- **OpenAI** — TTS synthesis (`gpt-4o-mini-tts`) and the conversational agent - **Google Android Publisher API** — Google Play review retrieval (service-account JWT) - **TypeScript / Biome / Jest / Wrangler** @@ -60,8 +60,8 @@ wrangler queues create feedback-triage-dev ```bash wrangler secret put SESSION_JWT_SECRET # signing key for session JWTs (any long random string) -wrangler secret put AZURE_SPEECH_KEY # Azure Speech subscription key wrangler secret put GOOGLE_PLAY_SA_KEY # Android Publisher SA key JSON (single-line string) +wrangler secret put OPENAI_API_KEY # TTS synthesis and the conversational agent wrangler secret put OCTOKIT_PAT wrangler secret put DISCORD_CS_WEBHOOK_URL wrangler secret put DISCORD_CRASH_WEBHOOK_URL @@ -76,7 +76,7 @@ You can also bulk-load secrets with the helper scripts: copy ### Non-secret configuration (vars) -See `vars` in `wrangler.jsonc`. Configure the Azure region, voice names, AI model +See `vars` in `wrangler.jsonc`. Configure the TTS model, voice names, AI model name, package name, public upload URL (the R2 public domain), and so on per environment. @@ -103,10 +103,51 @@ format. A session JWT is obtained from `POST /auth/token` (body `{ "installId": "" }`). +### `POST /tts` + +Synthesis runs on OpenAI `gpt-4o-mini-tts`, which does **not** interpret SSML — +the client sends plain text and steers delivery with `instructions`. + +```json +{ + "data": { + "textJa": "次は、オオサキです", + "textEn": "The next station is Osaki, J-Y 24.", + "model": "gpt-4o-mini-tts", + "jaVoiceName": "nova", + "enVoiceName": "nova", + "instructionsJa": "…", + "instructionsEn": "…" + } +} +``` + +Every field is optional except that **at least one of `textJa` / `textEn` must +be present**. Synthesis is billed per character, so the app omits a language the +user has switched off; only the languages it asks for are synthesized, cached, +and returned. `model` and the voice names are validated against an allowlist — +anything unrecognized falls back to the KV config (`config:tts`) and then to the +`TTS_*` vars, so a client cannot name an arbitrary (expensive) model. + +The response carries only the requested languages: + +```json +{ + "result": { + "id": "", + "jaAudioContent": "", + "jaAudioMimeType": "audio/mpeg", + "enAudioContent": "", + "enAudioMimeType": "audio/mpeg" + } +} +``` + ## Testing strategy -Unit tests cover pure functions (SSML formatting, voice-name resolution, triage -JSON normalization, review parsing) with Jest. Runtime integration for HTTP / +Unit tests cover pure functions (TTS request building, voice/model resolution, +text validation, cache writes, triage JSON normalization, review parsing) with +Jest. Runtime integration for HTTP / queue / Cron is verified with `wrangler dev` / `wrangler dev --test-scheduled`. ## few-shot data @@ -142,13 +183,13 @@ the KV namespace and R2 bucket from `wrangler.jsonc`). ### `find-tts-cache` -Searches the TTS cache by SSML body and optionally deletes the matching KV +Searches the TTS cache by spoken text and optionally deletes the matching KV document and R2 audio. KV is read via `wrangler kv key list` / `wrangler kv bulk get` and deleted via `wrangler kv key delete`; R2 audio is removed via `wrangler r2 object delete`. ```bash -npm run find-tts-cache -- "東京" --field ssmlJa +npm run find-tts-cache -- "東京" --field textJa npm run find-tts-cache -- "東京" --delete npm run find-tts-cache -- "東京" --env production --delete ``` diff --git a/scripts/build-secrets-json.mjs b/scripts/build-secrets-json.mjs index b8f1573..c1cb636 100644 --- a/scripts/build-secrets-json.mjs +++ b/scripts/build-secrets-json.mjs @@ -8,7 +8,6 @@ import { readFileSync, writeFileSync } from 'node:fs'; const SECRET_NAMES = [ 'SESSION_JWT_SECRET', - 'AZURE_SPEECH_KEY', 'GOOGLE_PLAY_SA_KEY', 'APPSTORE_CONNECT_KEY', 'OCTOKIT_PAT', diff --git a/src/agent/prompt.test.ts b/src/agent/prompt.test.ts index b36a568..9b17449 100644 --- a/src/agent/prompt.test.ts +++ b/src/agent/prompt.test.ts @@ -72,9 +72,7 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain( 'search_stations_by_name の結果は現在駅からの直通到達性しか保証しない' ); - expect(prompt).toContain( - 'それだけを根拠に駅を乗換地点として扱わない' - ); + expect(prompt).toContain('それだけを根拠に駅を乗換地点として扱わない'); expect(prompt).toContain( '最終目的地への接続を確認できない場合は suggestions を空配列' ); diff --git a/src/cli/find-tts-cache.ts b/src/cli/find-tts-cache.ts index e4cebd3..d26e699 100644 --- a/src/cli/find-tts-cache.ts +++ b/src/cli/find-tts-cache.ts @@ -1,5 +1,5 @@ /** - * KV(TTS_KV) の voice:* メタを SSML 本文で検索し、必要なら KV ドキュメントと + * KV(TTS_KV) の voice:* メタを読み上げ本文で検索し、必要なら KV ドキュメントと * R2 上の音声ファイルを削除する。旧 Firestore+GCS 版の Cloudflare 移植。 * * KV の一覧・値取得・削除、R2 の削除、バケット名解決はすべて wrangler @@ -7,7 +7,7 @@ * ネームスペース ID、R2 認証情報を環境変数で渡す必要はない(要 `wrangler login`)。 * * 例: - * npm run find-tts-cache -- "東京" --field ssmlJa + * npm run find-tts-cache -- "東京" --field textJa * npm run find-tts-cache -- "東京" --delete * npm run find-tts-cache -- "東京" --env production --delete */ @@ -26,7 +26,7 @@ const R2_BINDING = 'TTS_BUCKET'; interface CliArgs { searchTerm: string; - field?: 'ssmlJa' | 'ssmlEn'; + field?: 'textJa' | 'textEn'; exact: boolean; delete: boolean; env?: string; @@ -34,12 +34,12 @@ interface CliArgs { function printUsage(): void { console.error( - 'Usage: npm run find-tts-cache -- [--field ssmlJa|ssmlEn] [--exact] [--delete] [--env ]' + 'Usage: npm run find-tts-cache -- [--field textJa|textEn] [--exact] [--delete] [--env ]' ); console.error(''); console.error('Options:'); console.error( - ' --field 検索対象フィールド(省略時は両方)' + ' --field 検索対象フィールド(省略時は両方)' ); console.error(' --exact 部分一致ではなく完全一致で検索'); console.error(' --delete KV ドキュメントと R2 音声を削除'); @@ -57,7 +57,7 @@ function parseArgs(argv: string[]): CliArgs | null { if (args.length === 0) return null; let searchTerm = ''; - let field: 'ssmlJa' | 'ssmlEn' | undefined; + let field: 'textJa' | 'textEn' | undefined; let exact = false; let deleteMode = false; let env: string | undefined; @@ -66,8 +66,8 @@ function parseArgs(argv: string[]): CliArgs | null { switch (args[i]) { case '--field': { const value = args[++i]; - if (value !== 'ssmlJa' && value !== 'ssmlEn') { - console.error('Error: --field は "ssmlJa" か "ssmlEn" を指定'); + if (value !== 'textJa' && value !== 'textEn') { + console.error('Error: --field は "textJa" か "textEn" を指定'); process.exit(1); } field = value; @@ -141,9 +141,14 @@ async function main(): Promise { if (typeof rec.id !== 'string' || rec.id.length === 0) { continue; } + // ssmlJa/ssmlEn は Azure 時代のレコード。旧エントリも掃除できるよう併せて見る const hit = field - ? matchValue(rec[field]) - : matchValue(rec.ssmlJa) || matchValue(rec.ssmlEn); + ? matchValue(rec[field]) || + matchValue(field === 'textJa' ? rec.ssmlJa : rec.ssmlEn) + : matchValue(rec.textJa) || + matchValue(rec.textEn) || + matchValue(rec.ssmlJa) || + matchValue(rec.ssmlEn); if (hit) matches.push(rec); } @@ -155,8 +160,9 @@ async function main(): Promise { console.log(`${matches.length}件のドキュメントが見つかりました:\n`); for (const rec of matches) { console.log(`ID: ${rec.id}`); - console.log(`SSML (JA): ${rec.ssmlJa ?? ''}`); - console.log(`SSML (EN): ${rec.ssmlEn ?? ''}`); + console.log(`Text (JA): ${rec.textJa ?? rec.ssmlJa ?? ''}`); + console.log(`Text (EN): ${rec.textEn ?? rec.ssmlEn ?? ''}`); + console.log(`Model: ${rec.model ?? ''}`); console.log(`Path (JA): ${rec.pathJa ?? ''}`); console.log(`Path (EN): ${rec.pathEn ?? ''}`); console.log(`Voice (JA): ${rec.voiceJa ?? ''}`); diff --git a/src/cli/lib/wrangler.ts b/src/cli/lib/wrangler.ts index 5a5da49..d264767 100644 --- a/src/cli/lib/wrangler.ts +++ b/src/cli/lib/wrangler.ts @@ -219,6 +219,10 @@ export function confirm(prompt: string): Promise { // --- 共有: voice メタの型 --- export interface VoiceCacheRecord { id: string; + model?: string; + textJa?: string; + textEn?: string; + /** Azure/SSML 時代のレコード。旧エントリを検索・削除できるよう残している */ ssmlJa?: string; ssmlEn?: string; pathJa?: string; diff --git a/src/lib/azure/tts.test.ts b/src/lib/azure/tts.test.ts deleted file mode 100644 index 6f79109..0000000 --- a/src/lib/azure/tts.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { buildAzureSsml } from './tts'; - -describe('buildAzureSsml', () => { - it('wraps standard neural voices with prosody/style when provided', () => { - const ssml = buildAzureSsml('東京', 'ja-JP', 'ja-JP-NanamiNeural', { - pitch: '+1st', - style: 'narration-relaxed', - styleDegree: '1.5', - }); - - expect(ssml).toContain(''); - expect(ssml).toContain( - '' - ); - expect(ssml).toContain('name="ja-JP-NanamiNeural"'); - }); - - it('omits prosody and express-as for HD (DragonHD) voices', () => { - const ssml = buildAzureSsml( - '東京', - 'ja-JP', - 'ja-JP-Nanami:DragonHDLatestNeural', - { pitch: '+1st', style: 'narration-relaxed' } - ); - - // HD は / 非対応のため出力しない - expect(ssml).not.toContain('東京'); - }); - - it('omits prosody for HD English voices as well', () => { - const ssml = buildAzureSsml( - 'Tokyo', - 'en-US', - 'en-US-Jenny:DragonHDLatestNeural', - { pitch: '+1st' } - ); - - expect(ssml).not.toContain(' { - const inner = '3 番線'; - const ssml = buildAzureSsml( - inner, - 'ja-JP', - 'ja-JP-Nanami:DragonHDLatestNeural', - {} - ); - - expect(ssml).toContain(inner); - }); -}); diff --git a/src/lib/azure/tts.ts b/src/lib/azure/tts.ts deleted file mode 100644 index fc4bcd8..0000000 --- a/src/lib/azure/tts.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Azure Speech(Cognitive Services TTS)でテキストを音声に変換する。 - * Azure は SSML 必須。クライアントが送る `` の中身を取り出し、 - * voice/lang/スタイル/プロソディを含む Azure 準拠 SSML に包み直して合成する。出力は MP3。 - */ -import { isAzureHdVoiceName } from '../../utils/ttsVoice'; -import { bytesToBase64 } from '../crypto'; - -// 音質。低ビットレートだと圧縮ノイズで機械っぽく聞こえるため既定を高めにする。 -const DEFAULT_OUTPUT_FORMAT = 'audio-48khz-192kbitrate-mono-mp3'; - -export interface TtsOptions { - /** X-Microsoft-OutputFormat。未指定なら高音質既定 */ - outputFormat?: string; - /** mstts:express-as の style(例: narration-relaxed, customerservice)。未指定なら付けない */ - style?: string; - /** style の強さ(0.01〜2。未指定なら付けない) */ - styleDegree?: string; - /** prosody pitch(例: -2%, +1st)。未指定なら付けない */ - pitch?: string; -} - -/** XML 属性値をエスケープする(", &, <, >, ' を含む値で不正 XML になるのを防ぐ)。 */ -const escapeXmlAttr = (value: string): string => - value - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>') - .replace(/'/g, '''); - -/** クライアント SSML から外側の を剥がして中身だけ返す。 */ -const extractSpeakInner = (ssml: string): string => { - const trimmed = ssml.trim(); - const match = trimmed.match(/^]*>([\s\S]*)<\/speak>$/i); - return (match ? match[1] : trimmed).trim(); -}; - -export const buildAzureSsml = ( - inner: string, - languageCode: string, - voiceName: string, - opts: TtsOptions -): string => { - let content = inner; - - // HD(DragonHD)ボイスは を非対応のため、 - // これらの装飾を付けると合成エラー・無視の原因になる。HD では出力しない。 - const isHd = isAzureHdVoiceName(voiceName); - - if (!isHd && opts.pitch) { - content = `${content}`; - } - - if (!isHd && opts.style) { - const degree = opts.styleDegree - ? ` styledegree="${escapeXmlAttr(opts.styleDegree)}"` - : ''; - content = `${content}`; - } - - return ( - '${content}` - ); -}; - -export interface SynthesizedAudio { - /** base64 エンコードされた MP3 */ - audioContent: string; - mimeType: 'audio/mpeg'; -} - -export const synthesizeSpeech = async ( - region: string, - subscriptionKey: string, - ssml: string, - languageCode: string, - voiceName: string, - opts: TtsOptions = {} -): Promise => { - const inner = extractSpeakInner(ssml); - const body = buildAzureSsml(inner, languageCode, voiceName, opts); - - const url = `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`; - const res = await fetch(url, { - method: 'POST', - headers: { - 'Ocp-Apim-Subscription-Key': subscriptionKey, - 'Content-Type': 'application/ssml+xml', - 'X-Microsoft-OutputFormat': opts.outputFormat || DEFAULT_OUTPUT_FORMAT, - 'User-Agent': 'trainlcd-worker', - }, - body, - signal: AbortSignal.timeout(30000), - }); - - if (!res.ok) { - const detail = await res.text().catch(() => ''); - throw new Error( - `Azure TTS returned ${res.status}: ${detail.slice(0, 500)}` - ); - } - - const buf = await res.arrayBuffer(); - return { audioContent: bytesToBase64(buf), mimeType: 'audio/mpeg' }; -}; diff --git a/src/lib/openai/tts.test.ts b/src/lib/openai/tts.test.ts new file mode 100644 index 0000000..26f0255 --- /dev/null +++ b/src/lib/openai/tts.test.ts @@ -0,0 +1,168 @@ +import { + buildSpeechRequestBody, + buildSpeechUrl, + normalizeResponseFormat, + parseSpeed, +} from './tts'; + +describe('buildSpeechUrl', () => { + it('targets the OpenAI API directly when no gateway is configured', () => { + expect(buildSpeechUrl()).toBe('https://api.openai.com/v1/audio/speech'); + expect(buildSpeechUrl('')).toBe('https://api.openai.com/v1/audio/speech'); + }); + + it('routes through the AI Gateway when configured', () => { + expect(buildSpeechUrl('https://gateway.example.com/v1/acct/gw')).toBe( + 'https://gateway.example.com/v1/acct/gw/openai/v1/audio/speech' + ); + }); + + it('tolerates trailing slashes on the gateway base url', () => { + expect(buildSpeechUrl('https://gateway.example.com/v1/acct/gw///')).toBe( + 'https://gateway.example.com/v1/acct/gw/openai/v1/audio/speech' + ); + }); +}); + +describe('buildSpeechRequestBody', () => { + it('sends the plain text as input with the model and voice', () => { + expect( + buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: '次は、オオサキです', + }) + ).toEqual({ + model: 'gpt-4o-mini-tts', + voice: 'nova', + input: '次は、オオサキです', + response_format: 'mp3', + }); + }); + + it('passes instructions through when provided', () => { + // gpt-4o-mini-tts は SSML 非対応で、読み方は instructions で指示する + const body = buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'The next station is Osaki.', + opts: { instructions: 'calm female announcer' }, + }); + expect(body.instructions).toBe('calm female announcer'); + }); + + it('omits optional fields that are not set', () => { + const body = buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: {}, + }); + expect(body).not.toHaveProperty('instructions'); + expect(body).not.toHaveProperty('speed'); + }); + + it('honors a custom response format and sends speed as a number', () => { + // OpenAI の speed は number。文字列で送るとスキーマ検証に弾かれる + const body = buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { responseFormat: 'wav', speed: 1.1 }, + }); + expect(body.response_format).toBe('wav'); + expect(body.speed).toBe(1.1); + expect(typeof body.speed).toBe('number'); + }); + + it('omits an out-of-range speed rather than sending an invalid value', () => { + for (const speed of [0.1, 4.5, Number.NaN, Number.POSITIVE_INFINITY]) { + const body = buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { speed }, + }); + expect(body).not.toHaveProperty('speed'); + } + }); + + it('falls back to mp3 for an unknown or mis-cased response format', () => { + // 環境変数由来の任意文字列をそのまま送ると OpenAI が 400 を返す + expect( + buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { responseFormat: 'MP3' }, + }).response_format + ).toBe('mp3'); + expect( + buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { responseFormat: 'wma' }, + }).response_format + ).toBe('mp3'); + }); + + it('drops instructions for models that do not support them', () => { + // tts-1 / tts-1-hd に instructions を送ると OpenAI が 400 を返し、 + // /tts 全体が失敗する + for (const model of ['tts-1', 'tts-1-hd']) { + const body = buildSpeechRequestBody({ + model, + voiceName: 'nova', + text: 'test', + opts: { instructions: 'calm female announcer' }, + }); + expect(body).not.toHaveProperty('instructions'); + } + + expect( + buildSpeechRequestBody({ + model: 'gpt-4o-mini-tts', + voiceName: 'nova', + text: 'test', + opts: { instructions: 'calm female announcer' }, + }).instructions + ).toBe('calm female announcer'); + }); +}); + +describe('normalizeResponseFormat', () => { + it('accepts the known formats', () => { + for (const format of ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']) { + expect(normalizeResponseFormat(format)).toBe(format); + } + }); + + it('normalizes case and falls back to mp3 for unknown values', () => { + expect(normalizeResponseFormat('WAV')).toBe('wav'); + expect(normalizeResponseFormat(' Opus ')).toBe('opus'); + expect(normalizeResponseFormat('wma')).toBe('mp3'); + expect(normalizeResponseFormat('')).toBe('mp3'); + expect(normalizeResponseFormat(undefined)).toBe('mp3'); + }); +}); + +describe('parseSpeed', () => { + it('parses a numeric string from the environment', () => { + expect(parseSpeed('1.1')).toBe(1.1); + expect(parseSpeed(' 0.25 ')).toBe(0.25); + expect(parseSpeed('4')).toBe(4); + }); + + it('accepts numbers as-is', () => { + expect(parseSpeed(1.5)).toBe(1.5); + }); + + it('rejects out-of-range and non-numeric values', () => { + expect(parseSpeed('0.24')).toBeUndefined(); + expect(parseSpeed('4.01')).toBeUndefined(); + expect(parseSpeed('fast')).toBeUndefined(); + expect(parseSpeed('')).toBeUndefined(); + expect(parseSpeed(undefined)).toBeUndefined(); + }); +}); diff --git a/src/lib/openai/tts.ts b/src/lib/openai/tts.ts new file mode 100644 index 0000000..ace45da --- /dev/null +++ b/src/lib/openai/tts.ts @@ -0,0 +1,148 @@ +/** + * OpenAI Audio Speech API(gpt-4o-mini-tts)でテキストを音声に変換する。 + * SSML は非対応で、代わりに `instructions` で声色・速度・間の取り方を指示する。 + * 出力は MP3。 + */ +import { bytesToBase64 } from '../crypto'; + +const OPENAI_API_BASE_URL = 'https://api.openai.com'; +const SPEECH_PATH = '/v1/audio/speech'; + +// 合成は数秒で返るが、詰まったときに Worker の CPU/実行時間を食い潰さないよう +// Azure 時代と同じ上限で打ち切る。 +const REQUEST_TIMEOUT_MS = 30_000; + +// AI Gateway 経由時も読み上げ本文をゲートウェイのログに保存させない(設計: プライバシー)。 +// agent/llm.ts と同じ方針。 +const GATEWAY_HEADERS = { 'cf-aig-collect-log-payload': 'false' } as const; + +export interface TtsOptions { + /** 読み方の指示(instructions 対応モデルのみ)。未指定なら付けない */ + instructions?: string; + /** 応答フォーマット。未指定なら mp3 */ + responseFormat?: string; + /** 読み上げ速度(0.25〜4.0)。未指定なら付けない。API は数値を要求する */ + speed?: number; +} + +export interface SynthesizeSpeechParams { + apiKey: string; + /** Cloudflare AI Gateway のベース URL。未指定なら OpenAI へ直行 */ + gatewayBaseUrl?: string; + model: string; + voiceName: string; + /** 読み上げるプレーンテキスト */ + text: string; + opts?: TtsOptions; +} + +export interface SynthesizedAudio { + /** base64 エンコードされた音声 */ + audioContent: string; + mimeType: string; +} + +// response_format と実際に返る Content-Type の対応。応答ヘッダーが欠けていても +// アプリ側が拡張子を判定できるよう、こちらで確定させる。 +const MIME_BY_FORMAT: Record = { + mp3: 'audio/mpeg', + opus: 'audio/opus', + aac: 'audio/aac', + flac: 'audio/flac', + wav: 'audio/wav', + pcm: 'audio/pcm;rate=24000', +}; + +// instructions を受け付けないモデル。旧 tts-1 系に instructions を送ると +// OpenAI が 400 を返し、/tts 全体が失敗するため送信対象から外す。 +const INSTRUCTIONS_UNSUPPORTED_MODELS = new Set(['tts-1', 'tts-1-hd']); + +export const modelSupportsInstructions = (model: string): boolean => + !INSTRUCTIONS_UNSUPPORTED_MODELS.has(model.trim().toLowerCase()); + +/** + * 応答フォーマットを既知の値へ正規化する。環境変数由来の任意文字列(`MP3` の + * ような大文字や誤字)をそのまま送ると OpenAI が 400 を返し、MIME も引けなくなる。 + */ +export const normalizeResponseFormat = (format?: string): string => { + const value = format?.trim().toLowerCase() || ''; + return value in MIME_BY_FORMAT ? value : 'mp3'; +}; + +/** + * 読み上げ速度を数値へ正規化する。環境変数は文字列なので、そのまま送ると + * API のスキーマ検証(number)に弾かれる。範囲外・非数は未指定として扱う。 + */ +export const parseSpeed = (speed?: string | number): number | undefined => { + if (speed === undefined || speed === null || speed === '') { + return undefined; + } + const parsed = typeof speed === 'number' ? speed : Number(speed.trim()); + if (!Number.isFinite(parsed)) { + return undefined; + } + return parsed >= 0.25 && parsed <= 4.0 ? parsed : undefined; +}; + +/** リクエストの送信先を組み立てる。Gateway 指定時は末尾スラッシュの揺れを吸収する。 */ +export const buildSpeechUrl = (gatewayBaseUrl?: string): string => { + const gateway = gatewayBaseUrl?.replace(/\/+$/, '') || ''; + return gateway + ? `${gateway}/openai${SPEECH_PATH}` + : `${OPENAI_API_BASE_URL}${SPEECH_PATH}`; +}; + +/** OpenAI へ送るリクエストボディを組み立てる。 */ +export const buildSpeechRequestBody = (params: { + model: string; + voiceName: string; + text: string; + opts?: TtsOptions; +}): Record => { + const { model, voiceName, text, opts = {} } = params; + const speed = parseSpeed(opts.speed); + return { + model, + voice: voiceName, + input: text, + response_format: normalizeResponseFormat(opts.responseFormat), + ...(opts.instructions && modelSupportsInstructions(model) + ? { instructions: opts.instructions } + : {}), + ...(speed !== undefined ? { speed } : {}), + }; +}; + +export const synthesizeSpeech = async ( + params: SynthesizeSpeechParams +): Promise => { + const { apiKey, gatewayBaseUrl, model, voiceName, text, opts = {} } = params; + + const res = await fetch(buildSpeechUrl(gatewayBaseUrl), { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': 'trainlcd-worker', + ...(gatewayBaseUrl ? GATEWAY_HEADERS : {}), + }, + body: JSON.stringify( + buildSpeechRequestBody({ model, voiceName, text, opts }) + ), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error( + `OpenAI TTS returned ${res.status}: ${detail.slice(0, 500)}` + ); + } + + const format = normalizeResponseFormat(opts.responseFormat); + const buf = await res.arrayBuffer(); + return { + audioContent: bytesToBase64(buf), + mimeType: MIME_BY_FORMAT[format], + }; +}; diff --git a/src/lib/ttsCache.test.ts b/src/lib/ttsCache.test.ts new file mode 100644 index 0000000..a7a52a1 --- /dev/null +++ b/src/lib/ttsCache.test.ts @@ -0,0 +1,127 @@ +import type { Env } from '../types'; +import { writeTtsCache } from './ttsCache'; + +const createEnv = () => { + const put = jest.fn().mockResolvedValue(undefined); + const kvPut = jest.fn().mockResolvedValue(undefined); + return { + env: { + TTS_BUCKET: { put }, + TTS_KV: { put: kvPut }, + } as unknown as Env, + put, + kvPut, + }; +}; + +const basePayload = { + id: 'abc123', + model: 'gpt-4o-mini-tts', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + jaAudioMimeType: 'audio/mpeg', + enAudioMimeType: 'audio/mpeg', + textJa: '次は、オオサキです', + textEn: 'The next station is Osaki.', + voiceJa: 'nova', + voiceEn: 'nova', +}; + +describe('writeTtsCache', () => { + it('stores both languages and records the metadata', async () => { + const { env, put, kvPut } = createEnv(); + + await writeTtsCache(basePayload, env); + + expect(put).toHaveBeenCalledTimes(2); + expect(put.mock.calls[0][0]).toBe('caches/tts/ja/abc123.mp3'); + expect(put.mock.calls[1][0]).toBe('caches/tts/en/abc123.mp3'); + + const meta = JSON.parse(kvPut.mock.calls[0][1]); + expect(kvPut.mock.calls[0][0]).toBe('voice:abc123'); + expect(meta).toEqual( + expect.objectContaining({ + id: 'abc123', + model: 'gpt-4o-mini-tts', + pathJa: 'caches/tts/ja/abc123.mp3', + pathEn: 'caches/tts/en/abc123.mp3', + textJa: '次は、オオサキです', + textEn: 'The next station is Osaki.', + }) + ); + }); + + it('stores only the language that was synthesized', async () => { + // ユーザーが英語を無効にしている場合、英語は合成もキャッシュもしない + const { env, put, kvPut } = createEnv(); + + await writeTtsCache( + { + ...basePayload, + enAudioContent: undefined, + enAudioMimeType: undefined, + textEn: '', + voiceEn: undefined, + }, + env + ); + + expect(put).toHaveBeenCalledTimes(1); + expect(put.mock.calls[0][0]).toBe('caches/tts/ja/abc123.mp3'); + + const meta = JSON.parse(kvPut.mock.calls[0][1]); + expect(meta.pathJa).toBe('caches/tts/ja/abc123.mp3'); + expect(meta).not.toHaveProperty('pathEn'); + expect(meta).not.toHaveProperty('textEn'); + }); + + it('picks the file extension from the mime type', async () => { + const { env, put } = createEnv(); + + await writeTtsCache( + { + ...basePayload, + jaAudioMimeType: 'audio/wav', + enAudioMimeType: 'audio/pcm;rate=24000', + }, + env + ); + + expect(put.mock.calls[0][0]).toBe('caches/tts/ja/abc123.wav'); + expect(put.mock.calls[1][0]).toBe('caches/tts/en/abc123.pcm'); + }); + + it.each([ + ['audio/opus', 'opus'], + ['audio/aac', 'aac'], + ['audio/flac', 'flac'], + ])('stores %s as .%s', async (mimeType, ext) => { + // TTS_RESPONSE_FORMAT は opus/aac/flac も受け付けるため拡張子も合わせる + const { env, put } = createEnv(); + + await writeTtsCache( + { ...basePayload, jaAudioMimeType: mimeType, enAudioMimeType: mimeType }, + env + ); + + expect(put.mock.calls[0][0]).toBe(`caches/tts/ja/abc123.${ext}`); + expect(put.mock.calls[1][0]).toBe(`caches/tts/en/abc123.${ext}`); + }); + + it('writes nothing when no audio was produced', async () => { + const { env, put, kvPut } = createEnv(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + + await writeTtsCache( + { + id: 'abc123', + model: 'gpt-4o-mini-tts', + }, + env + ); + + expect(put).not.toHaveBeenCalled(); + expect(kvPut).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); diff --git a/src/lib/ttsCache.ts b/src/lib/ttsCache.ts index c1c5b3e..4707601 100644 --- a/src/lib/ttsCache.ts +++ b/src/lib/ttsCache.ts @@ -2,14 +2,24 @@ * 合成済み TTS 音声を R2 に保存し、メタを KV に書き込む。 * Queues のメッセージ上限(128KB)に音声が収まらないため、キューを介さず * /tts ハンドラから ctx.waitUntil で直接呼ぶ。 + * + * 日英どちらか片方だけの合成もありうる(ユーザーが無効にしている言語は + * そもそも合成しない)ため、届いた言語だけを保存する。 */ import type { Env, TtsCachePayload } from '../types'; import { base64ToBytes } from './crypto'; -const getCacheFileExtension = (mimeType: string): 'mp3' | 'wav' | 'pcm' => { +type CacheFileExtension = 'mp3' | 'wav' | 'opus' | 'aac' | 'flac' | 'pcm'; + +// TTS_RESPONSE_FORMAT は opus / aac / flac も受け付けるため、R2 の拡張子も +// 形式に合わせる。判定できない場合のみ生 PCM 扱いにする。 +const getCacheFileExtension = (mimeType: string): CacheFileExtension => { const normalized = mimeType.toLowerCase(); if (normalized.includes('mpeg') || normalized.includes('mp3')) return 'mp3'; if (normalized.includes('wav')) return 'wav'; + if (normalized.includes('opus')) return 'opus'; + if (normalized.includes('aac')) return 'aac'; + if (normalized.includes('flac')) return 'flac'; return 'pcm'; }; @@ -23,13 +33,14 @@ export const writeTtsCache = async ( enAudioContent, jaAudioMimeType, enAudioMimeType, - ssmlJa, - ssmlEn, + textJa, + textEn, + model, voiceJa, voiceEn, } = payload; - if (!id || !jaAudioContent || !enAudioContent) { + if (!id || (!jaAudioContent && !enAudioContent)) { console.error('Invalid payload for tts cache', { hasId: !!id, hasJa: !!jaAudioContent, @@ -38,32 +49,49 @@ export const writeTtsCache = async ( return; } - const jaContentType = jaAudioMimeType || 'audio/pcm'; - const enContentType = enAudioMimeType || 'audio/pcm'; - const jaPath = `caches/tts/ja/${id}.${getCacheFileExtension(jaContentType)}`; - const enPath = `caches/tts/en/${id}.${getCacheFileExtension(enContentType)}`; + const jaContentType = jaAudioMimeType || 'audio/mpeg'; + const enContentType = enAudioMimeType || 'audio/mpeg'; + const jaPath = jaAudioContent + ? `caches/tts/ja/${id}.${getCacheFileExtension(jaContentType)}` + : undefined; + const enPath = enAudioContent + ? `caches/tts/en/${id}.${getCacheFileExtension(enContentType)}` + : undefined; await Promise.all([ - env.TTS_BUCKET.put(jaPath, base64ToBytes(jaAudioContent), { - httpMetadata: { contentType: jaContentType }, - }), - env.TTS_BUCKET.put(enPath, base64ToBytes(enAudioContent), { - httpMetadata: { contentType: enContentType }, - }), + jaAudioContent && jaPath + ? env.TTS_BUCKET.put(jaPath, base64ToBytes(jaAudioContent), { + httpMetadata: { contentType: jaContentType }, + }) + : null, + enAudioContent && enPath + ? env.TTS_BUCKET.put(enPath, base64ToBytes(enAudioContent), { + httpMetadata: { contentType: enContentType }, + }) + : null, ]); await env.TTS_KV.put( `voice:${id}`, JSON.stringify({ id, - ssmlJa, - pathJa: jaPath, - jaAudioMimeType: jaContentType, - voiceJa, - ssmlEn, - pathEn: enPath, - enAudioMimeType: enContentType, - voiceEn, + model, + ...(jaPath + ? { + textJa, + pathJa: jaPath, + jaAudioMimeType: jaContentType, + voiceJa, + } + : {}), + ...(enPath + ? { + textEn, + pathEn: enPath, + enAudioMimeType: enContentType, + voiceEn, + } + : {}), createdAt: new Date().toISOString(), }) ); diff --git a/src/routes/tts.test.ts b/src/routes/tts.test.ts new file mode 100644 index 0000000..27ca74b --- /dev/null +++ b/src/routes/tts.test.ts @@ -0,0 +1,137 @@ +import { CallableError } from '../lib/callable'; +import { utf8ByteLength } from '../utils/ssml'; +import { computeId, parseTtsText, resolveInstructions } from './tts'; + +describe('parseTtsText', () => { + it('returns the trimmed text', () => { + expect(parseTtsText(' 次は、オオサキです ', 'textJa')).toBe( + '次は、オオサキです' + ); + }); + + it('treats undefined/null/empty as "language not requested"', () => { + // 合成は文字数課金のため、アプリは無効な言語を送ってこない + expect(parseTtsText(undefined, 'textJa')).toBe(''); + expect(parseTtsText(null, 'textJa')).toBe(''); + expect(parseTtsText(' ', 'textJa')).toBe(''); + }); + + it('rejects non-string values', () => { + expect(() => parseTtsText(42, 'textJa')).toThrow(CallableError); + expect(() => parseTtsText({}, 'textEn')).toThrow(/must be a string/); + }); + + it('strips tags so stray SSML is never read aloud', () => { + // gpt-4o-mini-tts は SSML を解釈せずタグをそのまま読み上げてしまう + expect( + parseTtsText('次は大崎です', 'textJa') + ).toBe('次はオオサキです'); + }); + + it('leaves plain text untouched', () => { + expect(parseTtsText('The next station is Osaki, J-Y 24.', 'textEn')).toBe( + 'The next station is Osaki, J-Y 24.' + ); + }); + + it('rejects text beyond the byte limit', () => { + // 日本語は 1 文字 3 バイトなので 4000 バイト超はすぐ作れる + const long = 'あ'.repeat(1400); + expect(() => parseTtsText(long, 'textJa')).toThrow(/byte limit/); + }); + + it('measures the limit in bytes, not characters', () => { + // 1300 文字 = 3900 バイトなので通る + expect(parseTtsText('あ'.repeat(1300), 'textJa')).toHaveLength(1300); + }); +}); + +describe('resolveInstructions', () => { + it('prefers the requested instructions', () => { + expect(resolveInstructions('requested', 'configured', 'fallback')).toBe( + 'requested' + ); + }); + + it('falls back to KV config, then to the env default', () => { + expect(resolveInstructions(undefined, 'configured', 'fallback')).toBe( + 'configured' + ); + expect(resolveInstructions(undefined, undefined, 'fallback')).toBe( + 'fallback' + ); + expect(resolveInstructions(' ', ' ', 'fallback')).toBe('fallback'); + }); + + it('returns an empty string when nothing is configured', () => { + expect(resolveInstructions(undefined, undefined, undefined)).toBe(''); + }); + + it('ignores non-string requests', () => { + expect(resolveInstructions(42, undefined, 'fallback')).toBe('fallback'); + }); + + it('truncates rather than failing when the instructions are too long', () => { + // 読み方の指示は本文ではないため、長すぎても放送そのものは落とさない + const result = resolveInstructions('x'.repeat(5000), undefined, undefined); + expect(utf8ByteLength(result)).toBe(2000); + }); + + it('truncates by UTF-8 bytes, not characters', () => { + // 日本語は 1 文字 3 バイト。文字数で切ると上限を守れない + const result = resolveInstructions('あ'.repeat(3000), undefined, undefined); + expect(utf8ByteLength(result)).toBeLessThanOrEqual(2000); + expect(result).toHaveLength(666); + }); + + it('does not split surrogate pairs when truncating', () => { + const result = resolveInstructions('🚃'.repeat(1000), undefined, undefined); + expect(utf8ByteLength(result)).toBeLessThanOrEqual(2000); + expect(result).not.toMatch(/\uFFFD/); + expect([...result].every((char) => char === '🚃')).toBe(true); + }); +}); + +describe('computeId', () => { + const base = { + enVoiceName: 'nova', + instructionsEn: 'calm', + instructionsJa: '落ち着いて', + jaVoiceName: 'nova', + model: 'gpt-4o-mini-tts', + responseFormat: 'mp3', + speed: null as number | null, + textEn: 'The next station is Osaki.', + textJa: '次は、オオサキです', + }; + + it('is stable for identical input', async () => { + expect(await computeId(base)).toBe(await computeId(base)); + }); + + it.each([ + ['textJa', { textJa: '次は、シンジュクです' }], + ['textEn', { textEn: 'The next station is Shinjuku.' }], + ['model', { model: 'tts-1' }], + ['jaVoiceName', { jaVoiceName: 'shimmer' }], + ['enVoiceName', { enVoiceName: 'shimmer' }], + ['instructionsJa', { instructionsJa: '明るく' }], + ['instructionsEn', { instructionsEn: 'bright' }], + // responseFormat / speed はかつてネストしたオブジェクトに置いていたため、 + // JSON.stringify の配列 replacer に落とされて ID に反映されていなかった + ['responseFormat', { responseFormat: 'wav' }], + ['speed', { speed: 1.25 }], + ])('changes when %s changes', async (_name, override) => { + expect(await computeId({ ...base, ...override })).not.toBe( + await computeId(base) + ); + }); + + it('distinguishes single-language requests from bilingual ones', async () => { + // 片言語リクエストが両言語のキャッシュへヒットしないこと + const jaOnly = await computeId({ ...base, textEn: '' }); + const enOnly = await computeId({ ...base, textJa: '' }); + const both = await computeId(base); + expect(new Set([jaOnly, enOnly, both]).size).toBe(3); + }); +}); diff --git a/src/routes/tts.ts b/src/routes/tts.ts index be2b71f..ef3ee1e 100644 --- a/src/routes/tts.ts +++ b/src/routes/tts.ts @@ -1,28 +1,39 @@ -/** POST /tts — Azure Speech で音声合成し、KV/R2 キャッシュを介して返す(callable 互換)。 */ +/** POST /tts — OpenAI(gpt-4o-mini-tts) で音声合成し、KV/R2 キャッシュを介して返す(callable 互換)。 */ import { verifySessionToken } from '../lib/auth/session'; -import { synthesizeSpeech, type TtsOptions } from '../lib/azure/tts'; import { CallableError, callableSuccess, parseCallableData, } from '../lib/callable'; import { bytesToBase64, sha256Hex } from '../lib/crypto'; +import { + normalizeResponseFormat, + parseSpeed, + synthesizeSpeech, + type TtsOptions, +} from '../lib/openai/tts'; import { writeTtsCache } from '../lib/ttsCache'; import type { Env } from '../types'; import { normalizeRomanText } from '../utils/normalize'; -import { stripSsml, utf8ByteLength } from '../utils/ssml'; -import { resolveAzureVoiceName } from '../utils/ttsVoice'; +import { stripSsml, truncateToByteLimit, utf8ByteLength } from '../utils/ssml'; +import { resolveOpenAiVoiceName, resolveTtsModel } from '../utils/ttsVoice'; interface TtsRequest { - ssmlJa?: unknown; - ssmlEn?: unknown; + textJa?: unknown; + textEn?: unknown; + model?: unknown; jaVoiceName?: unknown; enVoiceName?: unknown; + instructionsJa?: unknown; + instructionsEn?: unknown; } interface TtsConfig { + model?: string; jaVoiceName?: string; enVoiceName?: string; + instructionsJa?: string; + instructionsEn?: string; } interface VoiceCacheMeta { @@ -33,8 +44,10 @@ interface VoiceCacheMeta { } const TEXT_BYTE_LIMIT = 4000; -const RAW_SSML_BYTE_LIMIT = 10000; -const HASH_VERSION = 12; +// 読み方の指示は声色の調整用で、長文を受ける必要はない。無制限に受けると +// リクエストサイズとキャッシュキーが無駄に膨らむため上限を設ける。 +const INSTRUCTIONS_BYTE_LIMIT = 2000; +const HASH_VERSION = 13; const TTS_CONFIG_CACHE_TTL_MS = 5 * 60 * 1000; let ttsConfigCache: { data: TtsConfig; fetchedAt: number } | null = null; @@ -57,26 +70,73 @@ const getTtsConfig = async (env: Env): Promise => { } }; -const computeId = async (payload: { +// JSON.stringify の第2引数に配列を渡すと「その名前のキーだけ」を全階層で +// 直列化する。ネストしたオプションは名前がリストに無いと丸ごと落ちるため、 +// キャッシュキーへ含めたい値はすべてトップレベルへ平坦化して渡すこと。 +export const computeId = async (payload: { enVoiceName: string; + instructionsEn: string; + instructionsJa: string; jaVoiceName: string; - ssmlEn: string; - ssmlJa: string; - ttsOptions: TtsOptions; + model: string; + responseFormat: string; + speed: number | null; + textEn: string; + textJa: string; }): Promise => { const obj = { ...payload, version: HASH_VERSION } as const; const hashPayload = JSON.stringify(obj, Object.keys(obj).sort()); return sha256Hex(hashPayload); }; -const requireString = (value: unknown, name: string): string => { - if (typeof value !== 'string' || value.length === 0) { +/** + * 読み上げ対象テキストを受け取り、検証済みのプレーンテキストを返す。 + * 未指定・空文字は「その言語を要求しない」を意味する(合成は文字数課金のため、 + * アプリはユーザーが無効にしている言語を送ってこない)。 + */ +export const parseTtsText = (value: unknown, name: string): string => { + if (value === undefined || value === null) { + return ''; + } + if (typeof value !== 'string') { throw new CallableError( 'invalid-argument', - `The function must be called with one argument "${name}" containing the message to add.` + `"${name}" must be a string if provided` ); } - return value; + // gpt-4o-mini-tts は SSML を解釈せずタグをそのまま読み上げるため、万一 + // タグが紛れ込んでも読ませない。プレーンテキストには実質作用しない。 + const stripped = stripSsml(value).trim(); + if (stripped.length === 0) { + return ''; + } + + const bytes = utf8ByteLength(stripped); + if (bytes > TEXT_BYTE_LIMIT) { + throw new CallableError( + 'invalid-argument', + `${name} exceeds ${TEXT_BYTE_LIMIT} byte limit (${bytes} bytes)` + ); + } + return stripped; +}; + +/** 読み方の指示を リクエスト → KV 設定 → 環境変数 の順で解決する。 */ +export const resolveInstructions = ( + requested: unknown, + configured: string | undefined, + fallback: string | undefined +): string => { + const value = + typeof requested === 'string' && requested.trim().length > 0 + ? requested.trim() + : configured?.trim() || fallback?.trim() || ''; + if (!value) { + return ''; + } + // 上限超過は弾かずに切り詰める。読み方の指示は本文ではないため、 + // これだけで放送そのものを失敗させる必要はない。 + return truncateToByteLimit(value, INSTRUCTIONS_BYTE_LIMIT); }; export const handleTts = async ( @@ -88,107 +148,96 @@ export const handleTts = async ( const data = await parseCallableData(req); - const ssmlJa = requireString(data.ssmlJa, 'ssmlJa'); - // 生入力を保持し、バイト数上限は正規化前の値で判定する(正規化での展開/削除で - // 本来通る入力を弾いたり、上限超え入力を通したりしないため)。 - const rawSsmlEn = requireString(data.ssmlEn, 'ssmlEn'); - const ssmlEn = normalizeRomanText(rawSsmlEn); - if (ssmlEn.trim().length === 0) { + const textJa = parseTtsText(data.textJa, 'textJa'); + // 英語は駅名の表記ゆれ(全角記号・略記・長音符・大文字表記)を吸収してから合成する + const textEn = normalizeRomanText(parseTtsText(data.textEn, 'textEn')).trim(); + + const wantsJa = textJa.length > 0; + const wantsEn = textEn.length > 0; + if (!wantsJa && !wantsEn) { throw new CallableError( 'invalid-argument', - 'The function must be called with one argument "ssmlEn" containing the message to add.' + 'The function must be called with at least one of "textJa" or "textEn" containing the text to speak.' + ); + } + + if (!env.OPENAI_API_KEY) { + throw new CallableError( + 'failed-precondition', + 'OPENAI_API_KEY is not configured' ); } const ttsConfig = await getTtsConfig(env); - const jaVoiceName = resolveAzureVoiceName( + const model = resolveTtsModel(data.model, ttsConfig.model, env.TTS_MODEL); + const jaVoiceName = resolveOpenAiVoiceName( data.jaVoiceName, ttsConfig.jaVoiceName, env.TTS_JA_VOICE_NAME ); - const enVoiceName = resolveAzureVoiceName( + const enVoiceName = resolveOpenAiVoiceName( data.enVoiceName, ttsConfig.enVoiceName, env.TTS_EN_VOICE_NAME ); + const instructionsJa = resolveInstructions( + data.instructionsJa, + ttsConfig.instructionsJa, + env.TTS_INSTRUCTIONS_JA + ); + const instructionsEn = resolveInstructions( + data.instructionsEn, + ttsConfig.instructionsEn, + env.TTS_INSTRUCTIONS_EN + ); - const strippedJa = stripSsml(ssmlJa); - const strippedEn = stripSsml(ssmlEn); - if (strippedJa.trim().length === 0) { - throw new CallableError( - 'invalid-argument', - 'ssmlJa contains no visible text after stripping SSML tags' - ); - } - if (strippedEn.trim().length === 0) { - throw new CallableError( - 'invalid-argument', - 'ssmlEn contains no visible text after stripping SSML tags' - ); - } - - const jaTextBytes = utf8ByteLength(strippedJa); - const enTextBytes = utf8ByteLength(strippedEn); - if (jaTextBytes > TEXT_BYTE_LIMIT) { - throw new CallableError( - 'invalid-argument', - `ssmlJa text exceeds ${TEXT_BYTE_LIMIT} byte limit (${jaTextBytes} bytes)` - ); - } - if (enTextBytes > TEXT_BYTE_LIMIT) { - throw new CallableError( - 'invalid-argument', - `ssmlEn text exceeds ${TEXT_BYTE_LIMIT} byte limit (${enTextBytes} bytes)` - ); - } - - // 可視テキストだけでなく生 SSML のバイト長にも上限を設け、タグ膨張入力を弾く - if ( - utf8ByteLength(ssmlJa) > RAW_SSML_BYTE_LIMIT || - utf8ByteLength(rawSsmlEn) > RAW_SSML_BYTE_LIMIT - ) { - throw new CallableError( - 'invalid-argument', - `raw SSML exceeds ${RAW_SSML_BYTE_LIMIT} byte limit` - ); - } - - // 合成オプションもキャッシュキーに含める(outputFormat/style/styleDegree/pitch を - // 変えたら別の音声になるため、同じ voice:${id} を再利用させない)。 - const ttsOptions: TtsOptions = { - outputFormat: env.AZURE_TTS_OUTPUT_FORMAT || undefined, - style: env.AZURE_TTS_STYLE || undefined, - styleDegree: env.AZURE_TTS_STYLE_DEGREE || undefined, - pitch: env.AZURE_TTS_PITCH || undefined, - }; + // 環境変数は文字列なので、送信前に正規化した値を作る。この正規化後の値を + // そのままキャッシュキーにも使い、設定変更が確実に別 ID になるようにする。 + const responseFormat = normalizeResponseFormat(env.TTS_RESPONSE_FORMAT); + const speed = parseSpeed(env.TTS_SPEED); + const ttsOptions: TtsOptions = { responseFormat, speed }; const id = await computeId({ enVoiceName, + instructionsEn, + instructionsJa, jaVoiceName, - ssmlEn, - ssmlJa, - ttsOptions, + model, + responseFormat, + speed: speed ?? null, + textEn, + textJa, }); // --- キャッシュ照会 --- + // id は「どの言語を要求したか」まで含めて決まるため、要求した言語のパスが + // 揃っていれば同じ組み合わせの再放送とみなせる。 const meta = await env.TTS_KV.get(`voice:${id}`, 'json'); - if (meta?.pathJa && meta.pathEn) { + if (meta && (!wantsJa || meta.pathJa) && (!wantsEn || meta.pathEn)) { try { const [jaObj, enObj] = await Promise.all([ - env.TTS_BUCKET.get(meta.pathJa), - env.TTS_BUCKET.get(meta.pathEn), + wantsJa && meta.pathJa ? env.TTS_BUCKET.get(meta.pathJa) : null, + wantsEn && meta.pathEn ? env.TTS_BUCKET.get(meta.pathEn) : null, ]); - if (jaObj && enObj) { + if ((!wantsJa || jaObj) && (!wantsEn || enObj)) { const [jaBuf, enBuf] = await Promise.all([ - jaObj.arrayBuffer(), - enObj.arrayBuffer(), + jaObj ? jaObj.arrayBuffer() : null, + enObj ? enObj.arrayBuffer() : null, ]); return callableSuccess({ id, - jaAudioContent: bytesToBase64(jaBuf), - enAudioContent: bytesToBase64(enBuf), - jaAudioMimeType: meta.jaAudioMimeType ?? 'audio/mpeg', - enAudioMimeType: meta.enAudioMimeType ?? 'audio/mpeg', + ...(jaBuf + ? { + jaAudioContent: bytesToBase64(jaBuf), + jaAudioMimeType: meta.jaAudioMimeType ?? 'audio/mpeg', + } + : {}), + ...(enBuf + ? { + enAudioContent: bytesToBase64(enBuf), + enAudioMimeType: meta.enAudioMimeType ?? 'audio/mpeg', + } + : {}), }); } } catch (e) { @@ -199,25 +248,30 @@ export const handleTts = async ( } } - // --- 合成(Azure) --- - // 音質・スタイル・プロソディは env で調整可能(ttsOptions は上で構築済み) + // --- 合成(OpenAI) --- + // 要求された言語だけ合成する(合成は文字数課金) + const gatewayBaseUrl = env.AI_GATEWAY_BASE_URL || undefined; const [jaAudio, enAudio] = await Promise.all([ - synthesizeSpeech( - env.AZURE_SPEECH_REGION, - env.AZURE_SPEECH_KEY, - ssmlJa, - 'ja-JP', - jaVoiceName, - ttsOptions - ), - synthesizeSpeech( - env.AZURE_SPEECH_REGION, - env.AZURE_SPEECH_KEY, - ssmlEn, - 'en-US', - enVoiceName, - ttsOptions - ), + wantsJa + ? synthesizeSpeech({ + apiKey: env.OPENAI_API_KEY, + gatewayBaseUrl, + model, + voiceName: jaVoiceName, + text: textJa, + opts: { ...ttsOptions, instructions: instructionsJa || undefined }, + }) + : null, + wantsEn + ? synthesizeSpeech({ + apiKey: env.OPENAI_API_KEY, + gatewayBaseUrl, + model, + voiceName: enVoiceName, + text: textEn, + opts: { ...ttsOptions, instructions: instructionsEn || undefined }, + }) + : null, ]); // キャッシュ書き込みは非同期(失敗してもユーザー応答に影響させない)。 @@ -226,14 +280,15 @@ export const handleTts = async ( writeTtsCache( { id, - jaAudioContent: jaAudio.audioContent, - enAudioContent: enAudio.audioContent, - jaAudioMimeType: jaAudio.mimeType, - enAudioMimeType: enAudio.mimeType, - ssmlJa, - ssmlEn, - voiceJa: jaVoiceName, - voiceEn: enVoiceName, + jaAudioContent: jaAudio?.audioContent, + enAudioContent: enAudio?.audioContent, + jaAudioMimeType: jaAudio?.mimeType, + enAudioMimeType: enAudio?.mimeType, + textJa, + textEn, + model, + voiceJa: wantsJa ? jaVoiceName : undefined, + voiceEn: wantsEn ? enVoiceName : undefined, }, env ).catch((err) => console.error('Failed to cache tts audio:', err)) @@ -241,9 +296,17 @@ export const handleTts = async ( return callableSuccess({ id, - jaAudioContent: jaAudio.audioContent, - enAudioContent: enAudio.audioContent, - jaAudioMimeType: jaAudio.mimeType, - enAudioMimeType: enAudio.mimeType, + ...(jaAudio + ? { + jaAudioContent: jaAudio.audioContent, + jaAudioMimeType: jaAudio.mimeType, + } + : {}), + ...(enAudio + ? { + enAudioContent: enAudio.audioContent, + enAudioMimeType: enAudio.mimeType, + } + : {}), }); }; diff --git a/src/types.ts b/src/types.ts index ecfa6ec..d46d3dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,10 +16,15 @@ export interface Env { // --- Vars(非機密。wrangler.jsonc の vars) --- GOOGLE_PLAY_PACKAGE_NAME: string; - AZURE_SPEECH_REGION: string; AI_TRIAGE_MODEL: string; + /** 合成に使う OpenAI TTS モデル(例: gpt-4o-mini-tts) */ + TTS_MODEL: string; + /** OpenAI TTS のボイス名(例: nova)。ボイスは多言語対応のため日英で同じ名前を使える */ TTS_JA_VOICE_NAME: string; TTS_EN_VOICE_NAME: string; + /** 読み方の指示(gpt-4o-mini-tts の instructions)の既定値 */ + TTS_INSTRUCTIONS_JA: string; + TTS_INSTRUCTIONS_EN: string; SESSION_TOKEN_TTL_SECONDS: string; UPLOAD_PUBLIC_BASE_URL: string; FEW_SHOT_KV_KEY: string; @@ -42,7 +47,6 @@ export interface Env { // --- Secrets(wrangler secret put で投入) --- SESSION_JWT_SECRET: string; - AZURE_SPEECH_KEY: string; /** Android Publisher 用 Google サービスアカウント鍵 JSON 文字列 */ GOOGLE_PLAY_SA_KEY: string; /** App Store Connect API 鍵 JSON 文字列 ({keyId, issuerId, privateKey}) */ @@ -58,11 +62,11 @@ export interface Env { /** LangSmith の API キー(dev 環境のトレーシング用・任意) */ LANGSMITH_API_KEY?: string; - // --- Azure TTS チューニング(任意。未設定なら高音質既定のみ適用) --- - AZURE_TTS_OUTPUT_FORMAT?: string; - AZURE_TTS_STYLE?: string; - AZURE_TTS_STYLE_DEGREE?: string; - AZURE_TTS_PITCH?: string; + // --- TTS チューニング(任意。未設定なら mp3・等速) --- + /** OpenAI TTS の response_format(mp3 / opus / aac / flac / wav / pcm) */ + TTS_RESPONSE_FORMAT?: string; + /** 読み上げ速度(0.25〜4.0) */ + TTS_SPEED?: string; // --- 任意のデバッグ変数(未設定可) --- REVIEWS_DEBUG?: string; @@ -71,17 +75,21 @@ export interface Env { APPSTORE_APP_ID?: string; } -/** TTS キャッシュ書き込みのペイロード(R2+KV へ直接保存。キューは介さない) */ +/** + * TTS キャッシュ書き込みのペイロード(R2+KV へ直接保存。キューは介さない)。 + * 片方の言語だけ合成することがあるため、言語ごとのフィールドは任意。 + */ export interface TtsCachePayload { id: string; - jaAudioContent: string; - enAudioContent: string; - jaAudioMimeType: string; - enAudioMimeType: string; - ssmlJa: string; - ssmlEn: string; - voiceJa: string; - voiceEn: string; + model: string; + jaAudioContent?: string; + enAudioContent?: string; + jaAudioMimeType?: string; + enAudioMimeType?: string; + textJa?: string; + textEn?: string; + voiceJa?: string; + voiceEn?: string; } /** feedback-triage キューのメッセージ */ diff --git a/src/utils/normalize.test.ts b/src/utils/normalize.test.ts index a0316aa..e8f3605 100644 --- a/src/utils/normalize.test.ts +++ b/src/utils/normalize.test.ts @@ -10,6 +10,26 @@ describe('utils/normalize.ts', () => { expect(normalizeRomanText('JR Kobe Line')).toBe('J-R Kobe Line'); }); + it('leaves hyphenated initialisms alone', () => { + // アプリ側が「JR」を J-R へ倒してから送ってくるため、ここで J-r へ + // 崩さないこと(= 二重に適用しても結果が変わらない) + expect(normalizeRomanText('J-R Kobe Line')).toBe('J-R Kobe Line'); + expect(normalizeRomanText(normalizeRomanText('JR Kobe Line'))).toBe( + 'J-R Kobe Line' + ); + expect(normalizeRomanText('Osaki, J-Y 24.')).toBe('Osaki, J-Y 24.'); + }); + + it('keeps hyphenated initialisms next to punctuation', () => { + // 文末やカンマの直前でも J-r に崩さない + expect(normalizeRomanText('Please transfer to the J-R.')).toBe( + 'Please transfer to the J-R.' + ); + expect(normalizeRomanText('Transfer to the J-R, and the subway.')).toBe( + 'Transfer to the J-R, and the subway.' + ); + }); + it.each(['Tokyo', 'tOkyo'])('text: %s', (text) => { expect(normalizeRomanText(text)).toBe('Tokyo'); }); diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts index 4e14ee5..90f68fb 100644 --- a/src/utils/normalize.ts +++ b/src/utils/normalize.ts @@ -1,9 +1,19 @@ import { removeMacron } from './removeMacron'; -const capitalizeSegment = (seg: string): string => - /[A-Z]/.test(seg) +// ハイフン区切りの頭字語(J-R / J-Y など)。アプリ側が「JR」を読み間違えられない +// 表記へ倒してから送ってくるため、これを capitalizeSegment に通して "J-r" へ +// 崩されないよう素通しする。文末・カンマ前("J-R." / "J-R,")も対象にするため、 +// セグメント全体一致ではなく後続が英数字でないことを先読みで判定する。 +const HYPHENATED_INITIALISM = /^[A-Z](?:-[A-Z])+(?=$|[^A-Za-z0-9])/; + +const capitalizeSegment = (seg: string): string => { + if (HYPHENATED_INITIALISM.test(seg)) { + return seg; + } + return /[A-Z]/.test(seg) ? seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase() : seg; +}; // テキストノード(SSML タグの外側)だけに掛ける正規化。タグやその属性値 // ( 等)を壊さないため、タグ部分には適用しない。 diff --git a/src/utils/ssml.ts b/src/utils/ssml.ts index de51b04..fa37a95 100644 --- a/src/utils/ssml.ts +++ b/src/utils/ssml.ts @@ -22,3 +22,26 @@ export const stripSsml = (text: string): string => /** UTF-8 バイト長。 */ export const utf8ByteLength = (s: string): number => new TextEncoder().encode(s).length; + +/** + * UTF-8 バイト数の上限に合わせて切り詰める。 + * 文字数で切ると日本語(1 文字 3 バイト)では上限を守れないため、コードポイント + * 単位で積んでバイト数を数える。壊れた文字やサロゲートペアの分割は起きない。 + */ +export const truncateToByteLimit = (text: string, limit: number): string => { + if (limit <= 0 || utf8ByteLength(text) <= limit) { + return text; + } + + let bytes = 0; + let truncated = ''; + for (const char of text) { + const charBytes = utf8ByteLength(char); + if (bytes + charBytes > limit) { + break; + } + bytes += charBytes; + truncated += char; + } + return truncated; +}; diff --git a/src/utils/ttsVoice.test.ts b/src/utils/ttsVoice.test.ts index ccb0f23..2805b51 100644 --- a/src/utils/ttsVoice.test.ts +++ b/src/utils/ttsVoice.test.ts @@ -1,66 +1,102 @@ import { - isAzureHdVoiceName, - isAzureVoiceName, - resolveAzureVoiceName, + DEFAULT_TTS_MODEL, + DEFAULT_TTS_VOICE, + isOpenAiVoiceName, + isTtsModel, + resolveOpenAiVoiceName, + resolveTtsModel, } from './ttsVoice'; -describe('ttsVoice (Azure)', () => { - it('accepts Azure neural voices', () => { - expect(isAzureVoiceName('ja-JP-NanamiNeural')).toBe(true); - expect(isAzureVoiceName('en-US-JennyNeural')).toBe(true); - expect(isAzureVoiceName('en-US-AvaMultilingualNeural')).toBe(true); +describe('ttsVoice (OpenAI)', () => { + it('accepts OpenAI voice presets', () => { + expect(isOpenAiVoiceName('nova')).toBe(true); + expect(isOpenAiVoiceName('shimmer')).toBe(true); + expect(isOpenAiVoiceName('coral')).toBe(true); + expect(isOpenAiVoiceName('alloy')).toBe(true); }); - it('accepts Azure HD (DragonHD) voices as valid voice names', () => { - expect(isAzureVoiceName('ja-JP-Nanami:DragonHDLatestNeural')).toBe(true); - expect(isAzureVoiceName('en-US-Jenny:DragonHDLatestNeural')).toBe(true); + it('accepts voice names case-insensitively and with surrounding spaces', () => { + expect(isOpenAiVoiceName('Nova')).toBe(true); + expect(isOpenAiVoiceName(' NOVA ')).toBe(true); }); - it('detects HD (DragonHD) voices', () => { - expect(isAzureHdVoiceName('ja-JP-Nanami:DragonHDLatestNeural')).toBe(true); - expect(isAzureHdVoiceName('en-US-Jenny:DragonHDLatestNeural')).toBe(true); - expect(isAzureHdVoiceName('en-US-Ava:DragonHDLatestNeural')).toBe(true); + it('rejects unknown voice ids', () => { + // Azure/Google 時代のボイス名がそのまま送られてきても弾く + expect(isOpenAiVoiceName('ja-JP-NanamiNeural')).toBe(false); + expect(isOpenAiVoiceName('ja-JP-Standard-B')).toBe(false); + expect(isOpenAiVoiceName('')).toBe(false); }); - it('treats standard neural voices as non-HD', () => { - expect(isAzureHdVoiceName('ja-JP-NanamiNeural')).toBe(false); - expect(isAzureHdVoiceName('en-US-JennyNeural')).toBe(false); - expect(isAzureHdVoiceName('')).toBe(false); + it('prefers a valid requested voice', () => { + expect(resolveOpenAiVoiceName('shimmer', 'coral', 'nova')).toBe('shimmer'); }); - it('rejects non-Azure voice ids', () => { - expect(isAzureVoiceName('ja-JP-Standard-B')).toBe(false); - expect(isAzureVoiceName('en-US-Chirp3-HD-Aoede')).toBe(false); - expect(isAzureVoiceName('')).toBe(false); + it('normalizes the resolved voice to lower case', () => { + expect(resolveOpenAiVoiceName('Shimmer', 'coral', 'nova')).toBe('shimmer'); }); - it('prefers a valid requested voice', () => { - expect( - resolveAzureVoiceName( - 'en-US-AriaNeural', - 'en-US-GuyNeural', - 'en-US-JennyNeural' - ) - ).toBe('en-US-AriaNeural'); + it('falls back to a configured voice when the request is invalid', () => { + expect(resolveOpenAiVoiceName('ja-JP-NanamiNeural', 'coral', 'nova')).toBe( + 'coral' + ); }); - it('falls back to a configured voice when the request is invalid', () => { + it('falls back to the default voice when both inputs are invalid', () => { expect( - resolveAzureVoiceName( - 'en-US-Standard-H', - 'en-US-GuyNeural', - 'en-US-JennyNeural' - ) - ).toBe('en-US-GuyNeural'); + resolveOpenAiVoiceName('ja-JP-NanamiNeural', 'en-US-JennyNeural', 'nova') + ).toBe('nova'); }); - it('falls back to the default voice when both inputs are invalid', () => { + it('falls back to the default voice for non-string inputs', () => { + expect(resolveOpenAiVoiceName(undefined, undefined, 'nova')).toBe('nova'); + expect(resolveOpenAiVoiceName(42, {}, 'nova')).toBe('nova'); + }); + + it('validates the env default too, so a stale Azure value never reaches OpenAI', () => { + // 環境変数の設定ミスをそのまま送ると OpenAI が 400 を返し /tts が落ちる expect( - resolveAzureVoiceName( - 'ja-JP-Standard-B', - 'ja-JP-Neural2-B', - 'ja-JP-NanamiNeural' - ) - ).toBe('ja-JP-NanamiNeural'); + resolveOpenAiVoiceName(undefined, undefined, 'ja-JP-NanamiNeural') + ).toBe(DEFAULT_TTS_VOICE); + expect(resolveOpenAiVoiceName(undefined, undefined, '')).toBe( + DEFAULT_TTS_VOICE + ); + }); +}); + +describe('resolveTtsModel', () => { + it('accepts the allowed TTS models', () => { + expect(isTtsModel('gpt-4o-mini-tts')).toBe(true); + expect(isTtsModel('tts-1')).toBe(true); + expect(isTtsModel('tts-1-hd')).toBe(true); + }); + + it('rejects models outside the allowlist', () => { + // クライアントに高額なモデルを名指しさせない + expect(isTtsModel('gpt-4o')).toBe(false); + expect(isTtsModel('gpt-5.6-luna')).toBe(false); + expect(isTtsModel('')).toBe(false); + }); + + it('prefers a valid requested model', () => { + expect(resolveTtsModel('tts-1-hd', 'tts-1', 'gpt-4o-mini-tts')).toBe( + 'tts-1-hd' + ); + }); + + it('falls back through config to the default for disallowed models', () => { + expect(resolveTtsModel('gpt-4o', 'tts-1', 'gpt-4o-mini-tts')).toBe('tts-1'); + expect(resolveTtsModel('gpt-4o', 'gpt-4o', 'gpt-4o-mini-tts')).toBe( + 'gpt-4o-mini-tts' + ); + expect(resolveTtsModel(undefined, undefined, 'gpt-4o-mini-tts')).toBe( + 'gpt-4o-mini-tts' + ); + }); + + it('validates the env default too', () => { + expect(resolveTtsModel(undefined, undefined, 'gpt-4o')).toBe( + DEFAULT_TTS_MODEL + ); + expect(resolveTtsModel(undefined, undefined, '')).toBe(DEFAULT_TTS_MODEL); }); }); diff --git a/src/utils/ttsVoice.ts b/src/utils/ttsVoice.ts index 4324cc4..a37cd09 100644 --- a/src/utils/ttsVoice.ts +++ b/src/utils/ttsVoice.ts @@ -1,42 +1,96 @@ /** - * Azure Speech のニューラルボイス名を扱うユーティリティ。 + * OpenAI TTS のボイス名を扱うユーティリティ。 * - * Azure のボイス id は `-Neural` 形式(例: `ja-JP-NanamiNeural`, - * `en-US-JennyNeural`, `en-US-AvaMultilingualNeural`)。Google の Standard/WaveNet の - * ような価格差はなく、ニューラルが標準ティアのため「コストガード」は不要だが、 - * クライアントから任意文字列が渡るため最低限の妥当性チェックは行う。 + * gpt-4o-mini-tts のボイスは固定の名前付きプリセット(`nova` など)で、Azure の + * ような `-Neural` 形式ではない。ボイスは多言語対応のため日英で + * 同じ名前を使える。クライアントから任意文字列が渡るため、未知の名前は + * そのまま OpenAI へ流さず既定値へ倒す(400 で放送を落とさないため)。 */ -// 標準ニューラル(ja-JP-NanamiNeural)と HD ボイス(ja-JP-Nanami:DragonHDLatestNeural)の両方を許可 -const AZURE_VOICE_PATTERN = /^[a-z]{2,3}-[A-Za-z]+-[A-Za-z0-9:]+Neural$/; -export const isAzureVoiceName = (voiceName: string): boolean => - AZURE_VOICE_PATTERN.test(voiceName); +// OpenAI Audio Speech API が受け付けるボイス。女性寄りは nova / shimmer / coral / sage。 +const OPENAI_VOICES = new Set([ + 'alloy', + 'ash', + 'ballad', + 'coral', + 'echo', + 'fable', + 'nova', + 'onyx', + 'sage', + 'shimmer', + 'verse', +]); -// HD(DragonHD)ボイス判定。HD ボイスは id に `:DragonHD...Neural` を含む -// (例: `ja-JP-Nanami:DragonHDLatestNeural`, `en-US-Jenny:DragonHDLatestNeural`)。 -// HD は を非対応のため、SSML 構築時に -// 未サポート要素を出し分ける用途で使う。 -const AZURE_HD_VOICE_PATTERN = /:DragonHD[A-Za-z0-9]*Neural$/i; +export const isOpenAiVoiceName = (voiceName: string): boolean => + OPENAI_VOICES.has(voiceName.trim().toLowerCase()); -export const isAzureHdVoiceName = (voiceName: string): boolean => - AZURE_HD_VOICE_PATTERN.test(voiceName); +// 環境変数の設定ミス(Azure 時代の値の残留など)でも合成を落とさないための +// 最終フォールバック。ここは検証済みの定数なので必ず OpenAI が受理する。 +export const DEFAULT_TTS_VOICE = 'nova'; +export const DEFAULT_TTS_MODEL = 'gpt-4o-mini-tts'; -export const resolveAzureVoiceName = ( +/** + * 使用するボイス名を決める。 + * 優先順位: リクエスト指定 → KV の設定 → 環境変数の既定値。 + * 前二者は妥当なボイス名のときだけ採用する。 + */ +export const resolveOpenAiVoiceName = ( requestedVoiceName: unknown, configuredVoiceName: unknown, defaultVoiceName: string ): string => { const requested = typeof requestedVoiceName === 'string' ? requestedVoiceName.trim() : ''; - if (requested && isAzureVoiceName(requested)) { - return requested; + if (requested && isOpenAiVoiceName(requested)) { + return requested.toLowerCase(); } const configured = typeof configuredVoiceName === 'string' ? configuredVoiceName.trim() : ''; - if (configured && isAzureVoiceName(configured)) { - return configured; + if (configured && isOpenAiVoiceName(configured)) { + return configured.toLowerCase(); + } + + // 環境変数由来の既定値も無検証で通さない。不正なら OpenAI が 400 を返し、 + // /tts 全体が失敗してしまうため、既知のボイスへ倒す。 + const fallback = defaultVoiceName?.trim() ?? ''; + return fallback && isOpenAiVoiceName(fallback) + ? fallback.toLowerCase() + : DEFAULT_TTS_VOICE; +}; + +// 合成に使ってよいモデル。クライアントの指定をそのまま OpenAI へ流すと、 +// 高額なモデルを名指しされて課金が膨らむため許可制にする。 +const TTS_MODELS = new Set(['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd']); + +export const isTtsModel = (model: string): boolean => + TTS_MODELS.has(model.trim().toLowerCase()); + +/** + * 使用するモデルを決める。ボイス名と同じく、リクエスト → KV 設定 → 環境変数の + * 順で、許可済みのモデル名のときだけ採用する。 + */ +export const resolveTtsModel = ( + requestedModel: unknown, + configuredModel: unknown, + defaultModel: string +): string => { + const requested = + typeof requestedModel === 'string' ? requestedModel.trim() : ''; + if (requested && isTtsModel(requested)) { + return requested.toLowerCase(); + } + + const configured = + typeof configuredModel === 'string' ? configuredModel.trim() : ''; + if (configured && isTtsModel(configured)) { + return configured.toLowerCase(); } - return defaultVoiceName; + // ボイス名と同様、環境変数由来の既定値も検証してから採用する + const fallback = defaultModel?.trim() ?? ''; + return fallback && isTtsModel(fallback) + ? fallback.toLowerCase() + : DEFAULT_TTS_MODEL; }; diff --git a/wrangler.jsonc b/wrangler.jsonc index 0eda631..f0c7c90 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -40,12 +40,15 @@ "vars": { "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", - "AZURE_SPEECH_REGION": "southeastasia", - "AZURE_TTS_OUTPUT_FORMAT": "audio-48khz-192kbitrate-mono-mp3", - // HD(DragonHD)ボイスは 非対応のため AZURE_TTS_RATE は設定しない "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", - "TTS_JA_VOICE_NAME": "ja-JP-Nanami:DragonHDLatestNeural", - "TTS_EN_VOICE_NAME": "en-US-Jenny:DragonHDOmniLatestNeural", + // --- TTS(/tts)--- + // gpt-4o-mini-tts は SSML 非対応。声色・速度・間の取り方は instructions で指示する。 + // ボイスは多言語対応のため、日英とも同じ女性声にして一人のアナウンサーに揃える。 + "TTS_MODEL": "gpt-4o-mini-tts", + "TTS_JA_VOICE_NAME": "nova", + "TTS_EN_VOICE_NAME": "nova", + "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、落ち着いた丁寧な女性の声で読み上げてください。一定の速さを保ち、句読点では短く間を取ります。駅名や路線名は一語ずつ明瞭に発音し、感情を込めすぎず、事務的で聞き取りやすい調子にしてください。", + "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a calm, polite female voice. Keep a steady pace, pause briefly at commas, and pronounce station and line names clearly. Stay neutral and business-like rather than expressive.", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads-dev.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", @@ -66,7 +69,7 @@ }, // secrets(`wrangler secret put ` で投入。コミットしない): - // SESSION_JWT_SECRET / AZURE_SPEECH_KEY / GOOGLE_PLAY_SA_KEY / APPSTORE_CONNECT_KEY / + // SESSION_JWT_SECRET / GOOGLE_PLAY_SA_KEY / APPSTORE_CONNECT_KEY / // OCTOKIT_PAT / DISCORD_CS_WEBHOOK_URL / DISCORD_CRASH_WEBHOOK_URL / DISCORD_REVIEW_WEBHOOK_URL / // ANTHROPIC_API_KEY / OPENAI_API_KEY / LANGSMITH_API_KEY @@ -96,12 +99,13 @@ "services": [{ "binding": "SAPI_BFF", "service": "sapi-bff" }], "vars": { "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", - "AZURE_SPEECH_REGION": "southeastasia", - "AZURE_TTS_OUTPUT_FORMAT": "audio-48khz-192kbitrate-mono-mp3", - // HD(DragonHD)ボイスは 非対応のため AZURE_TTS_RATE は設定しない "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", - "TTS_JA_VOICE_NAME": "ja-JP-Nanami:DragonHDLatestNeural", - "TTS_EN_VOICE_NAME": "en-US-Jenny:DragonHDLatestNeural", + // --- TTS(/tts)--- + "TTS_MODEL": "gpt-4o-mini-tts", + "TTS_JA_VOICE_NAME": "nova", + "TTS_EN_VOICE_NAME": "nova", + "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、落ち着いた丁寧な女性の声で読み上げてください。一定の速さを保ち、句読点では短く間を取ります。駅名や路線名は一語ずつ明瞭に発音し、感情を込めすぎず、事務的で聞き取りやすい調子にしてください。", + "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a calm, polite female voice. Keep a steady pace, pause briefly at commas, and pronounce station and line names clearly. Stay neutral and business-like rather than expressive.", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", From b2ea58bdf6dd6488f65d5d8542a98acdebc4d3e9 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 13 Aug 2026 20:39:46 +0900 Subject: [PATCH 02/16] =?UTF-8?q?TTS=E3=81=AE=E6=97=A2=E5=AE=9A=E3=83=9C?= =?UTF-8?q?=E3=82=A4=E3=82=B9=E3=82=92=E6=97=A5=E8=8B=B1=E3=81=A7=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=81=A6=E6=97=A9=E5=8F=A3=E5=AF=84=E3=82=8A=E3=81=AB?= =?UTF-8?q?=E8=AA=BF=E6=95=B4=E3=81=99=E3=82=8B=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * TTSの既定ボイスを日英で分けて早口寄りに調整する 日英とも nova だった既定ボイスを、日本語は shimmer、英語は coral に変更する。 どちらも多言語を読めるが、各言語で最も明瞭に聞こえるボイスを選んだ。 あわせて instructions を明るく張りのある調子へ書き換え、読み上げ速度を 通常より一段速い早口気味に指示する。gpt-4o-mini-tts は speed パラメータが 効かないため、速度指定は instructions で行う必要がある。 日英で別ボイスになったことで実態と食い違ったコメントも更新する。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ns9SudVEY9UzSUiaHoDy5s * 読み上げ速度の指示をさらに急ぎ目へ強める 「一段速い早口気味」では十分に速くならなかったため、ラッシュ時の自動放送を 引き合いに出して速さを指示し、句読点・文の切れ目での間と語尾の伸ばしを 明示的に抑える。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ns9SudVEY9UzSUiaHoDy5s --------- Co-authored-by: Claude Opus 5 --- README.md | 4 ++-- src/types.ts | 2 +- src/utils/ttsVoice.ts | 4 ++-- wrangler.jsonc | 19 ++++++++++--------- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7805d2a..26f1806 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ the client sends plain text and steers delivery with `instructions`. "textJa": "次は、オオサキです", "textEn": "The next station is Osaki, J-Y 24.", "model": "gpt-4o-mini-tts", - "jaVoiceName": "nova", - "enVoiceName": "nova", + "jaVoiceName": "shimmer", + "enVoiceName": "coral", "instructionsJa": "…", "instructionsEn": "…" } diff --git a/src/types.ts b/src/types.ts index d46d3dd..28388b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,7 +19,7 @@ export interface Env { AI_TRIAGE_MODEL: string; /** 合成に使う OpenAI TTS モデル(例: gpt-4o-mini-tts) */ TTS_MODEL: string; - /** OpenAI TTS のボイス名(例: nova)。ボイスは多言語対応のため日英で同じ名前を使える */ + /** OpenAI TTS のボイス名(例: shimmer)。ボイスは多言語対応だが日英で別のボイスを指定できる */ TTS_JA_VOICE_NAME: string; TTS_EN_VOICE_NAME: string; /** 読み方の指示(gpt-4o-mini-tts の instructions)の既定値 */ diff --git a/src/utils/ttsVoice.ts b/src/utils/ttsVoice.ts index a37cd09..52d8e7f 100644 --- a/src/utils/ttsVoice.ts +++ b/src/utils/ttsVoice.ts @@ -1,7 +1,7 @@ /** * OpenAI TTS のボイス名を扱うユーティリティ。 * - * gpt-4o-mini-tts のボイスは固定の名前付きプリセット(`nova` など)で、Azure の + * gpt-4o-mini-tts のボイスは固定の名前付きプリセット(`shimmer` など)で、Azure の * ような `-Neural` 形式ではない。ボイスは多言語対応のため日英で * 同じ名前を使える。クライアントから任意文字列が渡るため、未知の名前は * そのまま OpenAI へ流さず既定値へ倒す(400 で放送を落とさないため)。 @@ -27,7 +27,7 @@ export const isOpenAiVoiceName = (voiceName: string): boolean => // 環境変数の設定ミス(Azure 時代の値の残留など)でも合成を落とさないための // 最終フォールバック。ここは検証済みの定数なので必ず OpenAI が受理する。 -export const DEFAULT_TTS_VOICE = 'nova'; +export const DEFAULT_TTS_VOICE = 'shimmer'; export const DEFAULT_TTS_MODEL = 'gpt-4o-mini-tts'; /** diff --git a/wrangler.jsonc b/wrangler.jsonc index f0c7c90..8b45a13 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -43,12 +43,13 @@ "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", // --- TTS(/tts)--- // gpt-4o-mini-tts は SSML 非対応。声色・速度・間の取り方は instructions で指示する。 - // ボイスは多言語対応のため、日英とも同じ女性声にして一人のアナウンサーに揃える。 + // 日英とも明るくはっきりした女性声にする。同じボイスでも多言語を読めるが、 + // 各言語で最も明瞭に聞こえるボイスを選ぶため日英で別のボイスを割り当てている。 "TTS_MODEL": "gpt-4o-mini-tts", - "TTS_JA_VOICE_NAME": "nova", - "TTS_EN_VOICE_NAME": "nova", - "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、落ち着いた丁寧な女性の声で読み上げてください。一定の速さを保ち、句読点では短く間を取ります。駅名や路線名は一語ずつ明瞭に発音し、感情を込めすぎず、事務的で聞き取りやすい調子にしてください。", - "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a calm, polite female voice. Keep a steady pace, pause briefly at commas, and pronounce station and line names clearly. Stay neutral and business-like rather than expressive.", + "TTS_JA_VOICE_NAME": "shimmer", + "TTS_EN_VOICE_NAME": "coral", + "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、明るくはっきりとした女性の声で読み上げてください。トーンはやや高めに保ち、語尾まで明瞭に発音します。テンポはかなり速く、ラッシュ時の自動放送のように急いで一気に読み進めてください。句読点でも文の切れ目でもほとんど間を取らず、詰めて続けます。語尾や伸ばす音を長く引かないでください。速くても駅名や路線名は一語ずつくっきりと発音し、案内らしい張りのある調子にしてください。", + "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a bright, clear female voice. Keep the pitch slightly high and the delivery crisp and articulate. Speak fast — much quicker than normal narration, with the urgency of a rush-hour announcement — and push straight through without dragging. Take almost no pause at commas or sentence breaks, and do not draw out word endings. Even at that speed, pronounce station and line names distinctly, and sound professional and welcoming rather than flat.", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads-dev.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", @@ -102,10 +103,10 @@ "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", // --- TTS(/tts)--- "TTS_MODEL": "gpt-4o-mini-tts", - "TTS_JA_VOICE_NAME": "nova", - "TTS_EN_VOICE_NAME": "nova", - "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、落ち着いた丁寧な女性の声で読み上げてください。一定の速さを保ち、句読点では短く間を取ります。駅名や路線名は一語ずつ明瞭に発音し、感情を込めすぎず、事務的で聞き取りやすい調子にしてください。", - "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a calm, polite female voice. Keep a steady pace, pause briefly at commas, and pronounce station and line names clearly. Stay neutral and business-like rather than expressive.", + "TTS_JA_VOICE_NAME": "shimmer", + "TTS_EN_VOICE_NAME": "coral", + "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、明るくはっきりとした女性の声で読み上げてください。トーンはやや高めに保ち、語尾まで明瞭に発音します。やや速めの一定の速さで、句読点では短く間を取ります。駅名や路線名は一語ずつくっきりと発音し、案内らしい張りのある調子にしてください。", + "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a bright, clear female voice. Keep the pitch slightly high and the delivery crisp and articulate, at a steady, slightly brisk pace. Pause briefly at commas and pronounce station and line names distinctly. Sound professional and welcoming rather than flat.", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", From 7782d5c385bd723827a82973dacda88220fe2996 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 17 Aug 2026 11:20:20 +0900 Subject: [PATCH 03/16] =?UTF-8?q?AI=E3=83=81=E3=83=A3=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=81=AE=E3=83=A2=E3=83=87=E3=83=AB=E3=81=ABGemini=EF=BC=88Ver?= =?UTF-8?q?tex=20AI=EF=BC=89=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= =?UTF-8?q?=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AIチャットのモデルにGemini(Vertex AI)を追加する AGENT_MODEL に "google:" を追加し、対話本体を Gemini でも動かせるように する。dev は google:gemini-3.7-flash、本番は openai のまま据え置く。 Vertex AI は API キーではなく ADC(サービスアカウント)認証が前提だが Workers に ADC は無いため、鍵 JSON を GOOGLE_VERTEX_SA_KEY で受け取り、JWT 署名と トークン交換は @ai-sdk/google-vertex の edge 版に行わせる(google-auth-library は バンドルに含めない)。project は鍵の project_id を既定とし、location の既定は global。AI Gateway 経由では google-vertex-ai/v1beta1 のモデルパスを baseURL に 組み、直行時と API バージョンを揃える。 思考の抑制は providerOptions を自前で組まず AI SDK 共通の reasoning 設定に委ねる。 ただし 3 系へ 'none'(= thinkingLevel: minimal)を送ると Vertex が 400 "Thinking level is unsupported: THINKING_LEVEL_MINIMAL" を返すため、受理される 最小値の 'low' まで下げる。2.5 系は 'none'(thinkingBudget: 0)で完全に止める。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4jE3NdeZJ5Ny7Zjue9tzi * fix: apply CodeRabbit auto-fixes プロバイダ切り替えが「vars のみの変更」で済むのは、そのプロバイダのシークレットを 投入済みの場合に限ることを README に明記する。未投入だと resolveAgentModel が " is not configured" を投げて /agent/chat が全リクエスト失敗するため。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4jE3NdeZJ5Ny7Zjue9tzi --------- Co-authored-by: Claude Opus 5 --- .secrets.env.example | 6 +- README.md | 27 ++ jest.config.js | 1 + package-lock.json | 459 ++++++++++++++++++++++++++++++++- package.json | 1 + scripts/build-secrets-json.mjs | 36 +-- scripts/put-secrets.sh | 9 +- src/agent/handler.test.ts | 23 ++ src/agent/handler.ts | 12 +- src/agent/llm.test.ts | 177 +++++++++++++ src/agent/llm.ts | 116 ++++++++- src/types.ts | 12 +- test/stubs/ai-sdk-provider.ts | 35 ++- wrangler.jsonc | 13 +- 14 files changed, 880 insertions(+), 47 deletions(-) mode change 100644 => 100755 scripts/put-secrets.sh create mode 100644 src/agent/llm.test.ts diff --git a/.secrets.env.example b/.secrets.env.example index 7bd13a7..40f3689 100644 --- a/.secrets.env.example +++ b/.secrets.env.example @@ -18,7 +18,11 @@ DISCORD_REVIEW_WEBHOOK_URL= # --- AI エージェント(/agent/chat)--- # AGENT_MODEL が anthropic: のとき必須 ANTHROPIC_API_KEY= -# AGENT_MODEL が openai: のとき必須 +# AGENT_MODEL が openai: のとき必須(TTS でも使う) OPENAI_API_KEY= +# AGENT_MODEL が google: のとき必須(Vertex AI 用サービスアカウント鍵 JSON)。 +# 複数行 JSON は GOOGLE_VERTEX_SA_KEY_FILE=./secrets-vertex-sa.json ./scripts/put-secrets.sh が +# 扱いやすい(リポジトリ内に置くなら secrets*.json 名にすること。.gitignore 済み)。 +GOOGLE_VERTEX_SA_KEY= # LangSmith トレーシング(dev のみ・任意) LANGSMITH_API_KEY= diff --git a/README.md b/README.md index 26f1806..c93ba71 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ single Worker. - **Cloudflare Queues** — `feedback-triage` - **Workers AI** — feedback triage - **OpenAI** — TTS synthesis (`gpt-4o-mini-tts`) and the conversational agent +- **Anthropic / Google Gemini (Vertex AI)** — alternative back ends for the + conversational agent; the provider is selected by the `AGENT_MODEL` var - **Google Android Publisher API** — Google Play review retrieval (service-account JWT) - **TypeScript / Biome / Jest / Wrangler** @@ -62,6 +64,7 @@ wrangler queues create feedback-triage-dev wrangler secret put SESSION_JWT_SECRET # signing key for session JWTs (any long random string) wrangler secret put GOOGLE_PLAY_SA_KEY # Android Publisher SA key JSON (single-line string) wrangler secret put OPENAI_API_KEY # TTS synthesis and the conversational agent +wrangler secret put GOOGLE_VERTEX_SA_KEY # Vertex AI SA key JSON; only when AGENT_MODEL is "google:" wrangler secret put OCTOKIT_PAT wrangler secret put DISCORD_CS_WEBHOOK_URL wrangler secret put DISCORD_CRASH_WEBHOOK_URL @@ -80,6 +83,30 @@ See `vars` in `wrangler.jsonc`. Configure the TTS model, voice names, AI model name, package name, public upload URL (the R2 public domain), and so on per environment. +The conversational agent picks its provider from `AGENT_MODEL`, written as +`:`: + +| `AGENT_MODEL` | Provider | Required secret | +| ------------------------- | ----------------------- | ---------------------- | +| `openai:gpt-5.6-luna` | OpenAI | `OPENAI_API_KEY` | +| `anthropic:` | Anthropic | `ANTHROPIC_API_KEY` | +| `google:gemini-3.7-flash` | Google Vertex AI | `GOOGLE_VERTEX_SA_KEY` | + +Switching providers is a vars-only change (`wrangler deploy`) **as long as that +provider's secret is already set** — no code change is needed. If it is missing, +`/agent/chat` fails on every request (the model resolver throws +` is not configured`), so put the secret in before flipping +`AGENT_MODEL`. When `AI_GATEWAY_BASE_URL` is set, every provider is routed +through Cloudflare AI Gateway (`/anthropic/v1`, `/openai`, +`/google-vertex-ai/v1beta1`) with request bodies excluded from the gateway logs. + +Gemini runs on **Vertex AI**, which authenticates with Google Cloud credentials +(ADC) rather than an API key. Workers have no ADC, so `GOOGLE_VERTEX_SA_KEY` +holds a service-account key JSON (role: *Vertex AI User*) and the Worker signs a +JWT with Web Crypto to obtain an access token per request. Two optional vars go +with it: `GOOGLE_VERTEX_PROJECT` (defaults to the key's `project_id`) and +`GOOGLE_VERTEX_LOCATION` (defaults to `global`). + ## Develop & deploy ```bash diff --git a/jest.config.js b/jest.config.js index 8c6b161..0bcdfdf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,7 @@ module.exports = { moduleNameMapper: { '^ai$': '/test/stubs/ai-sdk.ts', '^@ai-sdk/(anthropic|openai)$': '/test/stubs/ai-sdk-provider.ts', + '^@ai-sdk/google-vertex/edge$': '/test/stubs/ai-sdk-provider.ts', '^langsmith$': '/test/stubs/langsmith.ts', '^langsmith/experimental/vercel$': '/test/stubs/langsmith.ts', }, diff --git a/package-lock.json b/package-lock.json index 4a53bb4..c62f54b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "functions", "dependencies": { "@ai-sdk/anthropic": "^4.0.23", + "@ai-sdk/google-vertex": "^5.0.54", "@ai-sdk/openai": "^4.0.23", "ai": "^7.0.41", "dayjs": "^1.11.9", @@ -30,13 +31,44 @@ } }, "node_modules/@ai-sdk/anthropic": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.23.tgz", - "integrity": "sha512-9sky++sOcQ3V38XWqPpiLIe4knjWREOUEv3ZOZZ9mTQepb2Ho60NUFGR/0NxQlTHEW9G0zxciEecySNE/iHdVQ==", + "version": "4.0.39", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.39.tgz", + "integrity": "sha512-JAMGtYeEuaBzqbsPO4fkho6vQyNoVhsHASM4o59wmJRU6Vh7prjOp490Kmc7YQTY+ioU1/xYzXvWOtxZBup0Xw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.4", - "@ai-sdk/provider-utils": "5.0.14" + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" }, "engines": { "node": ">=22" @@ -62,6 +94,104 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/google": { + "version": "4.0.44", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-4.0.44.tgz", + "integrity": "sha512-bmRTDg06jQD+eX8nf214pET9+Oe8O1+lUIRGbWsGXj9IN2UJkpl1O1x7cvtiboyTtKSLvSRdVtItUfSl8sQ2GA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google-vertex": { + "version": "5.0.54", + "resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-5.0.54.tgz", + "integrity": "sha512-vcOCAEnzMXjgNJzXiNWiIMXReMfwf0Sk+yIaBLBePgxhb0ep1Uv0mI7ekG3XrnqDvMAs7eVDlwER8gyPuhGTCA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/anthropic": "4.0.39", + "@ai-sdk/google": "4.0.44", + "@ai-sdk/openai-compatible": "3.0.30", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", + "google-auth-library": "^10.6.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/provider": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/google-vertex/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/openai": { "version": "4.0.23", "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.23.tgz", @@ -78,6 +208,53 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-3.0.30.tgz", + "integrity": "sha512-BB35G4fS/Ey5OHbWrVLxRLX1gkTlO+9I4YhlmdH1skNVoPaFXZlR6bQ+1C76d/ug7O6ocbIw4qcd2G4+GPdWEA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/provider": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.4.tgz", @@ -2503,6 +2680,15 @@ "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", "license": "Apache-2.0" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ai": { "version": "7.0.41", "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.41.tgz", @@ -2724,6 +2910,35 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -2810,6 +3025,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3021,6 +3242,15 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/dayjs": { "version": "1.11.9", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", @@ -3030,7 +3260,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3098,6 +3327,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3291,6 +3529,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3308,6 +3552,29 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -3367,6 +3634,18 @@ "node": ">=8" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3398,6 +3677,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3472,6 +3779,32 @@ "node": ">=4" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3506,6 +3839,19 @@ "dev": true, "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4340,6 +4686,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -4372,6 +4727,27 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -4552,8 +4928,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -4562,6 +4937,44 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4948,6 +5361,26 @@ "node": ">=10" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -5350,7 +5783,6 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -5429,6 +5861,15 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 7b0e9fa..3626987 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@ai-sdk/anthropic": "^4.0.23", + "@ai-sdk/google-vertex": "^5.0.54", "@ai-sdk/openai": "^4.0.23", "ai": "^7.0.41", "dayjs": "^1.11.9", diff --git a/scripts/build-secrets-json.mjs b/scripts/build-secrets-json.mjs index c1cb636..6be3c75 100644 --- a/scripts/build-secrets-json.mjs +++ b/scripts/build-secrets-json.mjs @@ -17,9 +17,13 @@ const SECRET_NAMES = [ // AI エージェント(/agent/chat) 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', + 'GOOGLE_VERTEX_SA_KEY', 'LANGSMITH_API_KEY', ]; +/** _FILE でファイル内容を投入できるシークレット(サービスアカウント鍵 JSON) */ +const FILE_BACKED_SECRETS = ['GOOGLE_PLAY_SA_KEY', 'GOOGLE_VERTEX_SA_KEY']; + const [, , secretsFile, outFile] = process.argv; if (!outFile) { process.stderr.write( @@ -53,23 +57,25 @@ try { if (e?.code !== 'ENOENT') throw e; } -// GOOGLE_PLAY_SA_KEY はファイル指定があれば中身をそのまま採用。 -// 指定は 環境変数 GOOGLE_PLAY_SA_KEY_FILE でも .secrets.env の GOOGLE_PLAY_SA_KEY_FILE 行でも可。 -const saFile = - process.env.GOOGLE_PLAY_SA_KEY_FILE ?? values.GOOGLE_PLAY_SA_KEY_FILE; -if (saFile) { - try { - values.GOOGLE_PLAY_SA_KEY = readFileSync(saFile, 'utf8'); - } catch (e) { - process.stderr.write( - `GOOGLE_PLAY_SA_KEY_FILE を読めません: ${saFile} (${e.message})\n` + - '(パスは functions/ からの相対、または絶対パスで指定してください)\n' - ); - process.exit(2); +// 鍵 JSON はファイル指定があれば中身をそのまま採用。 +// 指定は 環境変数 _FILE でも .secrets.env の _FILE 行でも可。 +for (const name of FILE_BACKED_SECRETS) { + const fileKey = `${name}_FILE`; + const saFile = process.env[fileKey] ?? values[fileKey]; + if (saFile) { + try { + values[name] = readFileSync(saFile, 'utf8'); + } catch (e) { + process.stderr.write( + `${fileKey} を読めません: ${saFile} (${e.message})\n` + + '(パスは functions/ からの相対、または絶対パスで指定してください)\n' + ); + process.exit(2); + } } + // 補助キーは secret として出力しない + delete values[fileKey]; } -// 補助キーは secret として出力しない -delete values.GOOGLE_PLAY_SA_KEY_FILE; const out = {}; for (const name of SECRET_NAMES) { diff --git a/scripts/put-secrets.sh b/scripts/put-secrets.sh old mode 100644 new mode 100755 index 8034b14..9d34e24 --- a/scripts/put-secrets.sh +++ b/scripts/put-secrets.sh @@ -2,8 +2,8 @@ # # Worker のシークレットを `wrangler secret bulk` で一括投入する。 # 値は KEY=VALUE 形式のファイル(既定: functions/.secrets.env, gitignore 済み)から読む。 -# GOOGLE_PLAY_SA_KEY は環境変数 GOOGLE_PLAY_SA_KEY_FILE にサービスアカウント鍵 JSON の -# パスを渡せば、その中身をそのまま投入する。 +# サービスアカウント鍵(GOOGLE_PLAY_SA_KEY / GOOGLE_VERTEX_SA_KEY)は環境変数 +# _FILE に鍵 JSON のパスを渡せば、その中身をそのまま投入する。 # # stdin パイプ(`echo ... | wrangler secret put`)は Windows/Git Bash で値が # 届かないことがあるため、Node で一時 JSON を生成して `secret bulk` に渡す方式にしている。 @@ -13,6 +13,7 @@ # ./scripts/put-secrets.sh --env production # production へ # SECRETS_FILE=.secrets.prod.env ./scripts/put-secrets.sh --env production # GOOGLE_PLAY_SA_KEY_FILE=./sa.json ./scripts/put-secrets.sh +# GOOGLE_VERTEX_SA_KEY_FILE=./secrets-vertex-sa.json ./scripts/put-secrets.sh # set -euo pipefail @@ -47,9 +48,9 @@ TMP_JSON=".secrets.bulk.$$.json" cleanup() { rm -f "$TMP_JSON"; } trap cleanup EXIT -# .secrets.env + GOOGLE_PLAY_SA_KEY_FILE から bulk 用 JSON を生成(エスケープは Node 任せ) +# .secrets.env + _FILE から bulk 用 JSON を生成(エスケープは Node 任せ) if ! node scripts/build-secrets-json.mjs "$SECRETS_FILE" "$TMP_JSON"; then - echo "投入対象のシークレットがありません($SECRETS_FILE / GOOGLE_PLAY_SA_KEY_FILE を確認)" >&2 + echo "投入対象のシークレットがありません($SECRETS_FILE / _FILE を確認)" >&2 exit 1 fi diff --git a/src/agent/handler.test.ts b/src/agent/handler.test.ts index 585b426..1a80d6e 100644 --- a/src/agent/handler.test.ts +++ b/src/agent/handler.test.ts @@ -354,6 +354,29 @@ describe('runAgentTurn', () => { expect(streamText.mock.calls[1][0].providerOptions).toEqual({ anthropic: { thinking: { type: 'disabled' } }, }); + // reasoning 設定は Gemini 専用(他社モデルでは付けない) + expect(streamText.mock.calls[0][0].reasoning).toBeUndefined(); + expect(streamText.mock.calls[1][0].reasoning).toBeUndefined(); + }); + + it('Gemini の思考は AI SDK 共通の reasoning 設定で抑制する', async () => { + const streamText: AnyFn = jest.fn(async () => + streamResult({ output: { reply: 'ok', suggestions: [] } }) + ); + await runAgentTurn({ + ...baseParams, + model: 'gemini-3.7-flash' as AnyFn, + streamText, + searchStations: jest.fn(), + }); + + const options = streamText.mock.calls[0][0]; + // 3 系が受理する最小値(thinkingLevel: low 相当) + expect(options.reasoning).toBe('low'); + // thinkingLevel / thinkingBudget の出し分けは SDK に任せ、自前では組まない + expect(options.providerOptions).toEqual({ + anthropic: { thinking: { type: 'disabled' } }, + }); }); }); diff --git a/src/agent/handler.ts b/src/agent/handler.ts index ec13d2e..fa8a3ee 100644 --- a/src/agent/handler.ts +++ b/src/agent/handler.ts @@ -20,7 +20,11 @@ import { } from '../lib/callable'; import type { Env } from '../types'; import { classifyTopic } from './gate'; -import { resolveAgentModel, resolveOpenAIReasoningOptions } from './llm'; +import { + resolveAgentModel, + resolveGoogleReasoningSetting, + resolveOpenAIReasoningOptions, +} from './llm'; import { buildContextMessage, buildSystemPrompt, loadAgentFaq } from './prompt'; import { type AgentChatResult, @@ -316,6 +320,7 @@ export const runAgentTurn = async ( const verified = new Map(); const budget = { remaining: MAX_TOOL_CALLS_PER_TURN }; const openaiReasoning = resolveOpenAIReasoningOptions(params.model); + const googleReasoning = resolveGoogleReasoningSetting(params.model); const result = await params.streamText({ model: params.model, @@ -357,6 +362,11 @@ export const runAgentTurn = async ( // 送るため、'none' 対応が確認できるモデル以外では指定自体を省略する ...(openaiReasoning ? { openai: openaiReasoning } : {}), }, + // Gemini の思考抑制だけは providerOptions ではなく AI SDK 共通の reasoning 設定で + // 指定する。世代ごとに送るパラメータ(thinkingLevel / thinkingBudget)も + // 受理される最小値も異なるため、その解決は llm.ts と SDK に任せる + // (google 以外のモデルでは付けない) + ...(googleReasoning ? { reasoning: googleReasoning } : {}), timeout: { stepMs: LLM_CALL_TIMEOUT_MS }, abortSignal: params.signal, // LLM 呼び出しはコスト重複を避けるため自動再試行しない(設計値) diff --git a/src/agent/llm.test.ts b/src/agent/llm.test.ts new file mode 100644 index 0000000..f44e275 --- /dev/null +++ b/src/agent/llm.test.ts @@ -0,0 +1,177 @@ +/** + * AGENT_MODEL からのプロバイダ解決テスト。 + * @ai-sdk/* は ESM 専用のため Jest ではスタブへ差し替わる(jest.config.js)。 + * スタブは生成時オプションをそのまま持ち回るため、AI Gateway の URL 組み立てと + * サービスアカウント資格情報の受け渡しを検証できる。 + */ +import type { LanguageModel } from 'ai'; +import type { Env } from '../types'; +import { + resolveAgentModel, + resolveGoogleReasoningSetting, + resolveOpenAIReasoningOptions, +} from './llm'; + +/** スタブ(test/stubs/ai-sdk-provider.ts)が返す形。実型には無いので読むときだけ被せる */ +type StubModel = { + modelId: string; + provider: string; + options?: { + baseURL?: string; + headers?: Record; + project?: string; + location?: string; + googleCredentials?: { + clientEmail?: string; + privateKey?: string; + privateKeyId?: string; + }; + }; +}; +const asStub = (model: LanguageModel): StubModel => + model as unknown as StubModel; + +const makeEnv = (env: Partial): Env => env as Env; + +/** サービスアカウント鍵 JSON(1 行 JSON 投入を想定し改行はエスケープ済み) */ +const SA_KEY = JSON.stringify({ + type: 'service_account', + project_id: 'sa-project', + private_key_id: 'kid-1', + private_key: + '-----BEGIN PRIVATE KEY-----\\nMIIB\\n-----END PRIVATE KEY-----\\n', + client_email: 'agent@sa-project.iam.gserviceaccount.com', +}); + +describe('resolveAgentModel', () => { + it('google: はサービスアカウント鍵から Vertex AI を解決する', () => { + const model = asStub( + resolveAgentModel( + makeEnv({ + AGENT_MODEL: 'google:gemini-3.7-flash', + GOOGLE_VERTEX_SA_KEY: SA_KEY, + }) + ) + ); + expect(model.modelId).toBe('gemini-3.7-flash'); + // プロジェクトは鍵の project_id を既定にし、ロケーションは global + expect(model.options?.project).toBe('sa-project'); + expect(model.options?.location).toBe('global'); + expect(model.options?.googleCredentials?.clientEmail).toBe( + 'agent@sa-project.iam.gserviceaccount.com' + ); + expect(model.options?.googleCredentials?.privateKeyId).toBe('kid-1'); + // 1 行 JSON でエスケープされたままの改行は復元してから SDK へ渡す + expect(model.options?.googleCredentials?.privateKey).toBe( + '-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n' + ); + // Gateway 未設定なら Vertex へ直行(baseURL を上書きしない) + expect(model.options?.baseURL).toBeUndefined(); + }); + + it('project / location の var が鍵と既定より優先される', () => { + const model = asStub( + resolveAgentModel( + makeEnv({ + AGENT_MODEL: 'google:gemini-3.7-flash', + GOOGLE_VERTEX_SA_KEY: SA_KEY, + GOOGLE_VERTEX_PROJECT: 'other-project', + GOOGLE_VERTEX_LOCATION: 'asia-northeast1', + }) + ) + ); + expect(model.options?.project).toBe('other-project'); + expect(model.options?.location).toBe('asia-northeast1'); + }); + + it('AI Gateway 経由では google-vertex-ai のモデルパスを baseURL にする', () => { + const model = asStub( + resolveAgentModel( + makeEnv({ + AGENT_MODEL: 'google:gemini-3.7-flash', + GOOGLE_VERTEX_SA_KEY: SA_KEY, + GOOGLE_VERTEX_LOCATION: 'asia-northeast1', + // 末尾スラッシュの揺れも吸収する + AI_GATEWAY_BASE_URL: 'https://gateway.example/v1/acct/gw/', + }) + ) + ); + expect(model.options?.baseURL).toBe( + 'https://gateway.example/v1/acct/gw/google-vertex-ai/v1beta1/projects/sa-project/locations/asia-northeast1/publishers/google' + ); + // 会話本文を Gateway のログに残さない設定は他プロバイダと同じ + expect(model.options?.headers).toEqual({ + 'cf-aig-collect-log-payload': 'false', + }); + }); + + it('google: で鍵が無ければエラーにする', () => { + expect(() => + resolveAgentModel(makeEnv({ AGENT_MODEL: 'google:gemini-3.7-flash' })) + ).toThrow('GOOGLE_VERTEX_SA_KEY is not configured'); + }); + + it('鍵の形式が不正ならエラーにする', () => { + expect(() => + resolveAgentModel( + makeEnv({ + AGENT_MODEL: 'google:gemini-3.7-flash', + GOOGLE_VERTEX_SA_KEY: 'not-json', + }) + ) + ).toThrow('GOOGLE_VERTEX_SA_KEY is not valid JSON'); + + expect(() => + resolveAgentModel( + makeEnv({ + AGENT_MODEL: 'google:gemini-3.7-flash', + GOOGLE_VERTEX_SA_KEY: JSON.stringify({ project_id: 'p' }), + }) + ) + ).toThrow(/client_email and private_key/); + }); + + it('project を鍵からも var からも決められなければエラーにする', () => { + expect(() => + resolveAgentModel( + makeEnv({ + AGENT_MODEL: 'google:gemini-3.7-flash', + GOOGLE_VERTEX_SA_KEY: JSON.stringify({ + client_email: 'a@b.iam.gserviceaccount.com', + private_key: 'pk', + }), + }) + ) + ).toThrow('GOOGLE_VERTEX_PROJECT is not configured'); + }); + + it('未対応のプロバイダ指定はエラーにする', () => { + expect(() => + resolveAgentModel(makeEnv({ AGENT_MODEL: 'gemini-3.7-flash' })) + ).toThrow(/unsupported AGENT_MODEL/); + }); +}); + +describe('resolveGoogleReasoningSetting', () => { + it('思考を完全に止められる 2.5 系では none を返す', () => { + expect(resolveGoogleReasoningSetting('gemini-2.5-flash')).toBe('none'); + }); + + it('3 系は受理される最小値の low まで下げる', () => { + // 'none' は thinkingLevel: minimal に変換され、Vertex に 400 で拒否される + expect(resolveGoogleReasoningSetting('gemini-3.7-flash')).toBe('low'); + expect(resolveGoogleReasoningSetting('gemini-3-flash-preview')).toBe('low'); + }); + + it('thinkingConfig 非対応の世代・他社モデルには何も指定しない', () => { + expect(resolveGoogleReasoningSetting('gemini-2.0-flash')).toBeUndefined(); + expect( + resolveGoogleReasoningSetting('gemini-flash-latest') + ).toBeUndefined(); + expect(resolveGoogleReasoningSetting('gpt-5.1')).toBeUndefined(); + }); + + it('OpenAI 向けの抑制指定は Gemini に反応しない', () => { + expect(resolveOpenAIReasoningOptions('gemini-3.7-flash')).toBeUndefined(); + }); +}); diff --git a/src/agent/llm.ts b/src/agent/llm.ts index d5854fa..4a805ba 100644 --- a/src/agent/llm.ts +++ b/src/agent/llm.ts @@ -1,10 +1,11 @@ /** * 対話本体の LLM プロバイダ解決。 - * AGENT_MODEL("anthropic:" | "openai:")でモデルを切り替え、 - * AI_GATEWAY_BASE_URL が設定されていれば Cloudflare AI Gateway を経由させる - * (ログ・コスト集計・レート制限を Cloudflare 側に集約)。 + * AGENT_MODEL("anthropic:" | "openai:" | "google:")で + * モデルを切り替え、AI_GATEWAY_BASE_URL が設定されていれば Cloudflare AI Gateway を + * 経由させる(ログ・コスト集計・レート制限を Cloudflare 側に集約)。 */ import { createAnthropic } from '@ai-sdk/anthropic'; +import { createVertex } from '@ai-sdk/google-vertex/edge'; import { createOpenAI } from '@ai-sdk/openai'; import type { LanguageModel } from 'ai'; import type { Env } from '../types'; @@ -12,6 +13,48 @@ import type { Env } from '../types'; // AI Gateway 経由時も会話本文をゲートウェイのログに保存させない(設計: プライバシー) const GATEWAY_HEADERS = { 'cf-aig-collect-log-payload': 'false' } as const; +/** Vertex のロケーション既定。global はモデルの提供範囲が最も広く、Gateway 経由でも中継される */ +const DEFAULT_VERTEX_LOCATION = 'global'; + +interface VertexCredentials { + clientEmail: string; + privateKey: string; + privateKeyId?: string; + projectId?: string; +} + +/** + * GOOGLE_VERTEX_SA_KEY(サービスアカウント鍵 JSON)を資格情報に変換する。 + * Vertex AI は API キーではなく ADC(サービスアカウント)認証が前提だが、Workers に + * ADC は無いため鍵の中身を渡し、JWT 署名とトークン交換は SDK の edge 版に行わせる。 + */ +const parseVertexCredentials = (keyJson: string): VertexCredentials => { + let parsed: Record; + try { + parsed = JSON.parse(keyJson) as Record; + } catch { + throw new Error('GOOGLE_VERTEX_SA_KEY is not valid JSON'); + } + const clientEmail = parsed.client_email; + const privateKey = parsed.private_key; + if (typeof clientEmail !== 'string' || typeof privateKey !== 'string') { + throw new Error( + 'GOOGLE_VERTEX_SA_KEY must contain client_email and private_key' + ); + } + return { + clientEmail, + // 1 行 JSON で投入された鍵は改行がエスケープされたままのことがあるため戻す + privateKey: privateKey.replace(/\\n/g, '\n'), + privateKeyId: + typeof parsed.private_key_id === 'string' + ? parsed.private_key_id + : undefined, + projectId: + typeof parsed.project_id === 'string' ? parsed.project_id : undefined, + }; +}; + /** AGENT_MODEL の指定から AI SDK のモデルを生成する。 */ export const resolveAgentModel = (env: Env): LanguageModel => { const spec = env.AGENT_MODEL ?? ''; @@ -49,13 +92,50 @@ export const resolveAgentModel = (env: Env): LanguageModel => { }); return openai(modelId); } + // Gemini は Vertex AI(サービスアカウント認証)経由で使う。 + // Google AI Studio の API キー方式は使わないため "google:" は Vertex を指す + case 'google': { + if (!env.GOOGLE_VERTEX_SA_KEY) { + throw new Error('GOOGLE_VERTEX_SA_KEY is not configured'); + } + const credentials = parseVertexCredentials(env.GOOGLE_VERTEX_SA_KEY); + // プロジェクトは鍵の project_id を既定にし、別プロジェクトを使うときだけ var で上書きする + const project = env.GOOGLE_VERTEX_PROJECT || credentials.projectId; + if (!project) { + throw new Error('GOOGLE_VERTEX_PROJECT is not configured'); + } + const location = env.GOOGLE_VERTEX_LOCATION || DEFAULT_VERTEX_LOCATION; + const vertex = createVertex({ + project, + location, + googleCredentials: { + clientEmail: credentials.clientEmail, + privateKey: credentials.privateKey, + privateKeyId: credentials.privateKeyId, + }, + ...(gateway + ? { + // Gateway は google-vertex-ai 配下のパスをそのまま Vertex へ中継する。 + // API バージョンは直行時の SDK 既定(v1beta1)に合わせ、Gateway の + // 有無で挙動が変わらないようにする + baseURL: `${gateway}/google-vertex-ai/v1beta1/projects/${project}/locations/${location}/publishers/google`, + headers: { ...GATEWAY_HEADERS }, + } + : {}), + }); + return vertex(modelId); + } default: throw new Error( - `unsupported AGENT_MODEL: "${spec}" (expected "anthropic:" or "openai:")` + `unsupported AGENT_MODEL: "${spec}" (expected "anthropic:", "openai:" or "google:")` ); } }; +/** LanguageModel(文字列指定・インスタンスのどちらも来る)からモデル ID を取り出す。 */ +const modelIdOf = (model: LanguageModel): string => + typeof model === 'string' ? model : model.modelId; + /** * reasoningEffort: 'none' を受け付ける OpenAI モデル(GPT-5.1 系以降の * マイナーバージョン付き GPT-5)。gpt-5 無印('minimal' まで)・o 系 @@ -72,9 +152,31 @@ const OPENAI_REASONING_NONE_MODELS = /^gpt-5\.[1-9]/; */ export const resolveOpenAIReasoningOptions = ( model: LanguageModel -): { reasoningEffort: 'none' } | undefined => { - const modelId = typeof model === 'string' ? model : model.modelId; - return OPENAI_REASONING_NONE_MODELS.test(modelId) +): { reasoningEffort: 'none' } | undefined => + OPENAI_REASONING_NONE_MODELS.test(modelIdOf(model)) ? { reasoningEffort: 'none' } : undefined; + +/** 思考を完全に止められる Gemini(2.5 系は thinkingBudget: 0 が通る) */ +const GEMINI_THINKING_OFF_MODELS = /^gemini-2\.5/; +/** thinkingLevel で思考量を指定する Gemini(3 系以降) */ +const GEMINI_THINKING_LEVEL_MODELS = /^gemini-[3-9]/; + +/** + * Gemini 向けの reasoning 抑制を AI SDK 共通の reasoning 設定として解決する。 + * Gemini は世代で送るパラメータが異なる(3 系: thinkingLevel / 2.5 系: + * thinkingBudget)ため、providerOptions.google を自前で組まず SDK に変換させる。 + * + * 3 系で 'none' を使わないのは、SDK が thinkingLevel: 'minimal' に変換する一方、 + * Vertex がこれを拒否する(400 "Thinking level is unsupported: + * THINKING_LEVEL_MINIMAL")ため。受理される最小値の 'low' まで下げる。 + * 2.0 系や gemini-flash-latest のようなエイリアスは指定自体を省略する。 + */ +export const resolveGoogleReasoningSetting = ( + model: LanguageModel +): 'none' | 'low' | undefined => { + const modelId = modelIdOf(model); + if (GEMINI_THINKING_OFF_MODELS.test(modelId)) return 'none'; + if (GEMINI_THINKING_LEVEL_MODELS.test(modelId)) return 'low'; + return undefined; }; diff --git a/src/types.ts b/src/types.ts index 28388b6..9f4dad9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,7 +30,7 @@ export interface Env { FEW_SHOT_KV_KEY: string; FEW_SHOT_LIMIT: string; FEW_SHOT_PER_EX_MAX: string; - /** 対話本体モデル("anthropic:" | "openai:") */ + /** 対話本体モデル("anthropic:" | "openai:" | "google:") */ AGENT_MODEL: string; /** トピックゲート用の Workers AI モデル */ AGENT_GATE_MODEL: string; @@ -40,6 +40,10 @@ export interface Env { AGENT_DAILY_TURN_LIMIT: string; /** Cloudflare AI Gateway のベース URL(空なら各社 API 直行) */ AI_GATEWAY_BASE_URL?: string; + /** Vertex AI のプロジェクト ID(未設定なら GOOGLE_VERTEX_SA_KEY の project_id) */ + GOOGLE_VERTEX_PROJECT?: string; + /** Vertex AI のロケーション(既定 "global"。例: asia-northeast1) */ + GOOGLE_VERTEX_LOCATION?: string; /** Service Binding 不使用時の sapi-bff GraphQL エンドポイント */ SAPI_BFF_GRAPHQL_URL?: string; /** "true" で LangSmith トレーシングを有効化(dev 環境のみ設定すること) */ @@ -59,6 +63,12 @@ export interface Env { ANTHROPIC_API_KEY?: string; /** 対話本体(GPT)の API キー。AGENT_MODEL が openai: のとき必須 */ OPENAI_API_KEY?: string; + /** + * 対話本体(Gemini / Vertex AI)用の Google サービスアカウント鍵 JSON 文字列。 + * AGENT_MODEL が google: のとき必須(Vertex AI は API キーではなくサービス + * アカウント認証のため。ロールは Vertex AI User 相当) + */ + GOOGLE_VERTEX_SA_KEY?: string; /** LangSmith の API キー(dev 環境のトレーシング用・任意) */ LANGSMITH_API_KEY?: string; diff --git a/test/stubs/ai-sdk-provider.ts b/test/stubs/ai-sdk-provider.ts index aa2828a..d0d6d1f 100644 --- a/test/stubs/ai-sdk-provider.ts +++ b/test/stubs/ai-sdk-provider.ts @@ -1,10 +1,33 @@ /** - * Jest 用の @ai-sdk/anthropic / @ai-sdk/openai スタブ(ESM 専用のため差し替え)。 - * モデル ID をそのまま返すだけの形だけ互換。 + * Jest 用の @ai-sdk/anthropic / @ai-sdk/openai / @ai-sdk/google-vertex スタブ + * (ESM 専用のため差し替え)。モデル ID と生成時オプションをそのまま返すだけの形だけ互換。 */ +type ProviderOptions = { + apiKey?: string; + baseURL?: string; + headers?: Record; + project?: string; + location?: string; + googleCredentials?: { + clientEmail?: string; + privateKey?: string; + privateKeyId?: string; + }; +}; + const createProvider = - () => - (modelId: string): { modelId: string } => ({ modelId }); + (provider: string, options?: unknown) => + ( + modelId: string + ): { modelId: string; provider: string; options?: unknown } => ({ + modelId, + provider, + options: options as ProviderOptions | undefined, + }); -export const createAnthropic = (_options?: unknown) => createProvider(); -export const createOpenAI = (_options?: unknown) => createProvider(); +export const createAnthropic = (options?: unknown) => + createProvider('anthropic', options); +export const createOpenAI = (options?: unknown) => + createProvider('openai', options); +export const createVertex = (options?: unknown) => + createProvider('google.vertex.chat', options); diff --git a/wrangler.jsonc b/wrangler.jsonc index 8b45a13..728076f 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -56,8 +56,14 @@ "FEW_SHOT_LIMIT": "12", "FEW_SHOT_PER_EX_MAX": "800", // --- AI エージェント(/agent/chat)--- - // モデルは "anthropic:" | "openai:"。比較検証で差し替える - "AGENT_MODEL": "openai:gpt-5.6-luna", + // モデルは "anthropic:" | "openai:" | "google:"。比較検証で差し替える + // ("google:" は Vertex AI 経由。API キーではなく GOOGLE_VERTEX_SA_KEY が必要) + "AGENT_MODEL": "google:gemini-3.7-flash", + // Vertex AI 用(AGENT_MODEL が google: のときのみ使う)。 + // GOOGLE_VERTEX_PROJECT 未設定なら GOOGLE_VERTEX_SA_KEY の project_id を使う。 + // ロケーションは "global" がモデルの提供範囲が最も広い。特定リージョンに寄せるなら + // "asia-northeast1" など(AI Gateway 経由でもどちらも中継される) + "GOOGLE_VERTEX_LOCATION": "global", "AGENT_GATE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", "AGENT_FAQ_KV_KEY": "config:agent-faq", // config:remote の agent_daily_turn_limit があればそちらが優先(デプロイなしで調整可) @@ -72,7 +78,7 @@ // secrets(`wrangler secret put ` で投入。コミットしない): // SESSION_JWT_SECRET / GOOGLE_PLAY_SA_KEY / APPSTORE_CONNECT_KEY / // OCTOKIT_PAT / DISCORD_CS_WEBHOOK_URL / DISCORD_CRASH_WEBHOOK_URL / DISCORD_REVIEW_WEBHOOK_URL / - // ANTHROPIC_API_KEY / OPENAI_API_KEY / LANGSMITH_API_KEY + // ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_VERTEX_SA_KEY / LANGSMITH_API_KEY "env": { "production": { @@ -114,6 +120,7 @@ "FEW_SHOT_PER_EX_MAX": "800", // --- AI エージェント(/agent/chat)--- "AGENT_MODEL": "openai:gpt-5.6-luna", + "GOOGLE_VERTEX_LOCATION": "global", "AGENT_GATE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", "AGENT_FAQ_KV_KEY": "config:agent-faq", // config:remote の agent_daily_turn_limit があればそちらが優先(デプロイなしで調整可) From bf4776dc3c746d366cca102acfde3ec40b52f29b Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 17 Aug 2026 11:54:32 +0900 Subject: [PATCH 04/16] =?UTF-8?q?TTS=E3=82=92OpenAI=E3=81=8B=E3=82=89Googl?= =?UTF-8?q?e=20Cloud=20Text-to-Speech=E3=81=B8=E5=85=A8=E9=9D=A2=E7=A7=BB?= =?UTF-8?q?=E8=A1=8C=E3=81=99=E3=82=8B=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * TTSをOpenAIからGoogle Cloud Text-to-Speechへ全面移行する 合成エンジンを OpenAI(gpt-4o-mini-tts) から Cloud Text-to-Speech へ差し替える。 既定ボイスは Firebase Functions 時代の Google TTS 実装と同じ ja-JP-Standard-B / en-US-Standard-G に揃え、Android の端末内蔵 TTS と同水準の音質にする。 Standard / Wavenet / Neural2 以外(Studio・Chirp3-HD・Gemini-TTS)は単価が 桁違いのためクライアントから名指しできないようにする。 認証は API キーではなくサービスアカウント(GOOGLE_TTS_SA_KEY)で、既存の getGoogleAccessToken を再利用する。用途ごとに別の鍵を使うようになるため、 トークンキャッシュのキーを scope 単体からサービスアカウント × scope に変える。 Cloud TTS は AI Gateway の対応プロバイダではないので Google へ直行する。 Standard 系には読み方のプロンプト指示が無いため TTS_INSTRUCTIONS_* は廃止し、 速さは TTS_SPEED(speakingRate)、高さは TTS_PITCH に置き換える。リクエストの model / instructions* は受け取っても無視するため、旧アプリからの呼び出しは そのまま通る。エンジン差し替えで同じ入力でも音声が変わるので、キャッシュキーの 版を 13 から 14 へ上げて旧キャッシュとは分離する。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4jE3NdeZJ5Ny7Zjue9tzi * 鍵ファイルが空のときに明示的なエラーにする _FILE で指定した鍵 JSON が空でも、これまでは値なしとして黙って捨てられ、 「投入対象のシークレットがありません」としか出ないため原因を追いにくかった。 失敗した `gcloud iam service-accounts keys create` は出力先ファイルを空のまま 残すため、この事故は実際に起こる。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4jE3NdeZJ5Ny7Zjue9tzi * fix: apply CodeRabbit auto-fixes ボイス名の検証を形式一致から実在ボイスの allowlist へ変更する。 形式だけを見ていたため ja-US-Standard-A や ja-JP-Standard-Z のような実在しない 名前が検証を通過し、Cloud TTS が 400 "Voice ... does not exist" を返して /tts 全体が失敗していた(未知の名前は既定値へ倒す、というこのモジュールの前提が 成り立っていなかった)。allowlist は voices.list で実在を確認した ja-JP 11 件・ en-US 29 件。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G4jE3NdeZJ5Ny7Zjue9tzi --------- Co-authored-by: Claude Opus 5 --- .secrets.env.example | 7 +- README.md | 46 +++++--- scripts/build-secrets-json.mjs | 22 +++- scripts/put-secrets.sh | 4 +- src/lib/google/accessToken.ts | Bin 2814 -> 3082 bytes src/lib/google/tts.test.ts | 134 ++++++++++++++++++++++++ src/lib/google/tts.ts | 158 ++++++++++++++++++++++++++++ src/lib/openai/tts.test.ts | 168 ------------------------------ src/lib/openai/tts.ts | 148 -------------------------- src/lib/ttsCache.test.ts | 6 +- src/routes/tts.test.ts | 67 ++---------- src/routes/tts.ts | 108 +++++++------------ src/types.ts | 20 ++-- src/utils/ttsVoice.test.ts | 161 +++++++++++++++------------- src/utils/ttsVoice.ts | 185 +++++++++++++++++++-------------- wrangler.jsonc | 30 +++--- 16 files changed, 625 insertions(+), 639 deletions(-) create mode 100644 src/lib/google/tts.test.ts create mode 100644 src/lib/google/tts.ts delete mode 100644 src/lib/openai/tts.test.ts delete mode 100644 src/lib/openai/tts.ts diff --git a/.secrets.env.example b/.secrets.env.example index 40f3689..185dd7c 100644 --- a/.secrets.env.example +++ b/.secrets.env.example @@ -18,7 +18,7 @@ DISCORD_REVIEW_WEBHOOK_URL= # --- AI エージェント(/agent/chat)--- # AGENT_MODEL が anthropic: のとき必須 ANTHROPIC_API_KEY= -# AGENT_MODEL が openai: のとき必須(TTS でも使う) +# AGENT_MODEL が openai: のとき必須 OPENAI_API_KEY= # AGENT_MODEL が google: のとき必須(Vertex AI 用サービスアカウント鍵 JSON)。 # 複数行 JSON は GOOGLE_VERTEX_SA_KEY_FILE=./secrets-vertex-sa.json ./scripts/put-secrets.sh が @@ -26,3 +26,8 @@ OPENAI_API_KEY= GOOGLE_VERTEX_SA_KEY= # LangSmith トレーシング(dev のみ・任意) LANGSMITH_API_KEY= +# --- TTS(/tts)--- +# Cloud Text-to-Speech を呼べるサービスアカウント鍵 JSON(必須)。 +# 複数行 JSON は GOOGLE_TTS_SA_KEY_FILE=./secrets-tts-sa.json ./scripts/put-secrets.sh が +# 扱いやすい(リポジトリ内に置くなら secrets*.json 名にすること。.gitignore 済み)。 +GOOGLE_TTS_SA_KEY= diff --git a/README.md b/README.md index c93ba71..7ff9293 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ single Worker. ## Features -- **TTS synthesis** (`POST /tts`): synthesizes plain text into audio via OpenAI `gpt-4o-mini-tts` and caches it in KV/R2. +- **TTS synthesis** (`POST /tts`): synthesizes plain text into audio via Google Cloud Text-to-Speech and caches it in KV/R2. - **Session issuance** (`POST /auth/token`): issues a short-lived session JWT from an install ID (the replacement for Firebase anonymous auth). - **Feedback intake** (`POST /postFeedback`): enqueues feedback onto the triage queue. - **Image upload** (`POST /feedback/upload-image`): stores feedback images in R2 and returns a public URL. @@ -22,7 +22,8 @@ single Worker. - **R2** — audio binaries and feedback images - **Cloudflare Queues** — `feedback-triage` - **Workers AI** — feedback triage -- **OpenAI** — TTS synthesis (`gpt-4o-mini-tts`) and the conversational agent +- **Google Cloud Text-to-Speech** — TTS synthesis (`Standard` voices, service-account auth) +- **OpenAI** — the conversational agent - **Anthropic / Google Gemini (Vertex AI)** — alternative back ends for the conversational agent; the provider is selected by the `AGENT_MODEL` var - **Google Android Publisher API** — Google Play review retrieval (service-account JWT) @@ -63,7 +64,8 @@ wrangler queues create feedback-triage-dev ```bash wrangler secret put SESSION_JWT_SECRET # signing key for session JWTs (any long random string) wrangler secret put GOOGLE_PLAY_SA_KEY # Android Publisher SA key JSON (single-line string) -wrangler secret put OPENAI_API_KEY # TTS synthesis and the conversational agent +wrangler secret put OPENAI_API_KEY # the conversational agent +wrangler secret put GOOGLE_TTS_SA_KEY # Cloud Text-to-Speech SA key JSON (for POST /tts) wrangler secret put GOOGLE_VERTEX_SA_KEY # Vertex AI SA key JSON; only when AGENT_MODEL is "google:" wrangler secret put OCTOKIT_PAT wrangler secret put DISCORD_CS_WEBHOOK_URL @@ -79,9 +81,16 @@ You can also bulk-load secrets with the helper scripts: copy ### Non-secret configuration (vars) -See `vars` in `wrangler.jsonc`. Configure the TTS model, voice names, AI model -name, package name, public upload URL (the R2 public domain), and so on per -environment. +See `vars` in `wrangler.jsonc`. Configure the TTS voice names and delivery +(`TTS_SPEED` / `TTS_PITCH`), AI model name, package name, public upload URL (the +R2 public domain), and so on per environment. + +Synthesis runs on Google Cloud Text-to-Speech, which authenticates with a service +account rather than an API key: `GOOGLE_TTS_SA_KEY` holds the key JSON and the +Worker signs a JWT with Web Crypto to obtain an access token. The Cloud +Text-to-Speech API must be enabled on that project. Unlike the agent providers, +TTS is not routed through Cloudflare AI Gateway (Cloud TTS is not a supported +gateway provider). The conversational agent picks its provider from `AGENT_MODEL`, written as `:`: @@ -132,19 +141,17 @@ A session JWT is obtained from `POST /auth/token` (body `{ "installId": "" ### `POST /tts` -Synthesis runs on OpenAI `gpt-4o-mini-tts`, which does **not** interpret SSML — -the client sends plain text and steers delivery with `instructions`. +Synthesis runs on Google Cloud Text-to-Speech. The client sends plain text; SSML +is **not** interpreted (stray tags are stripped server-side rather than read +aloud), and delivery is steered by the `TTS_SPEED` / `TTS_PITCH` vars. ```json { "data": { "textJa": "次は、オオサキです", "textEn": "The next station is Osaki, J-Y 24.", - "model": "gpt-4o-mini-tts", - "jaVoiceName": "shimmer", - "enVoiceName": "coral", - "instructionsJa": "…", - "instructionsEn": "…" + "jaVoiceName": "ja-JP-Standard-B", + "enVoiceName": "en-US-Standard-G" } } ``` @@ -152,9 +159,16 @@ the client sends plain text and steers delivery with `instructions`. Every field is optional except that **at least one of `textJa` / `textEn` must be present**. Synthesis is billed per character, so the app omits a language the user has switched off; only the languages it asks for are synthesized, cached, -and returned. `model` and the voice names are validated against an allowlist — -anything unrecognized falls back to the KV config (`config:tts`) and then to the -`TTS_*` vars, so a client cannot name an arbitrary (expensive) model. +and returned. Voice names are checked against an allowlist of voices that are +known to exist (`ja-JP` / `en-US` in the `Standard` / `Wavenet` / `Neural2` +families) — anything else falls back to the KV config (`config:tts`) and then to +the `TTS_*` vars. That keeps a client from naming an arbitrary (far more +expensive) voice such as `Studio`, `Chirp3-HD`, or a Gemini-TTS voice, and also +keeps a well-formed but non-existent name (`ja-JP-Standard-Z`) from reaching the +API, where it would fail the whole request with a 400. Using another locale +means adding its voices to the list in `src/utils/ttsVoice.ts`. The `model` / `instructions*` +fields of the previous OpenAI-based engine are accepted but ignored, so older +app builds keep working. The response carries only the requested languages: diff --git a/scripts/build-secrets-json.mjs b/scripts/build-secrets-json.mjs index 6be3c75..ca1d211 100644 --- a/scripts/build-secrets-json.mjs +++ b/scripts/build-secrets-json.mjs @@ -19,10 +19,16 @@ const SECRET_NAMES = [ 'OPENAI_API_KEY', 'GOOGLE_VERTEX_SA_KEY', 'LANGSMITH_API_KEY', + // TTS(/tts) + 'GOOGLE_TTS_SA_KEY', ]; /** _FILE でファイル内容を投入できるシークレット(サービスアカウント鍵 JSON) */ -const FILE_BACKED_SECRETS = ['GOOGLE_PLAY_SA_KEY', 'GOOGLE_VERTEX_SA_KEY']; +const FILE_BACKED_SECRETS = [ + 'GOOGLE_PLAY_SA_KEY', + 'GOOGLE_VERTEX_SA_KEY', + 'GOOGLE_TTS_SA_KEY', +]; const [, , secretsFile, outFile] = process.argv; if (!outFile) { @@ -63,8 +69,9 @@ for (const name of FILE_BACKED_SECRETS) { const fileKey = `${name}_FILE`; const saFile = process.env[fileKey] ?? values[fileKey]; if (saFile) { + let contents; try { - values[name] = readFileSync(saFile, 'utf8'); + contents = readFileSync(saFile, 'utf8'); } catch (e) { process.stderr.write( `${fileKey} を読めません: ${saFile} (${e.message})\n` + @@ -72,6 +79,17 @@ for (const name of FILE_BACKED_SECRETS) { ); process.exit(2); } + // 空ファイルを黙って捨てると「投入対象のシークレットがありません」としか出ず + // 原因を追いにくい。失敗した `gcloud ... keys create` は出力先を空のまま残すため、 + // 鍵ファイルが空になる事故は実際に起こる + if (contents.trim().length === 0) { + process.stderr.write( + `${fileKey} が空です: ${saFile}\n` + + '(鍵の発行に失敗していないか確認してください)\n' + ); + process.exit(2); + } + values[name] = contents; } // 補助キーは secret として出力しない delete values[fileKey]; diff --git a/scripts/put-secrets.sh b/scripts/put-secrets.sh index 9d34e24..98456e2 100755 --- a/scripts/put-secrets.sh +++ b/scripts/put-secrets.sh @@ -2,7 +2,8 @@ # # Worker のシークレットを `wrangler secret bulk` で一括投入する。 # 値は KEY=VALUE 形式のファイル(既定: functions/.secrets.env, gitignore 済み)から読む。 -# サービスアカウント鍵(GOOGLE_PLAY_SA_KEY / GOOGLE_VERTEX_SA_KEY)は環境変数 +# サービスアカウント鍵(GOOGLE_PLAY_SA_KEY / GOOGLE_VERTEX_SA_KEY / +# GOOGLE_TTS_SA_KEY)は環境変数 # _FILE に鍵 JSON のパスを渡せば、その中身をそのまま投入する。 # # stdin パイプ(`echo ... | wrangler secret put`)は Windows/Git Bash で値が @@ -14,6 +15,7 @@ # SECRETS_FILE=.secrets.prod.env ./scripts/put-secrets.sh --env production # GOOGLE_PLAY_SA_KEY_FILE=./sa.json ./scripts/put-secrets.sh # GOOGLE_VERTEX_SA_KEY_FILE=./secrets-vertex-sa.json ./scripts/put-secrets.sh +# GOOGLE_TTS_SA_KEY_FILE=./secrets-tts-sa.json ./scripts/put-secrets.sh # set -euo pipefail diff --git a/src/lib/google/accessToken.ts b/src/lib/google/accessToken.ts index 87e91418fbac75017ba373f565c8f4a35719db87..d8896fd949c3c775afcdaafe8d3122075eee7e56 100644 GIT binary patch delta 325 zcmew-+9fgJs2`VtmcsKXD_%BCdEB^O!7(qTC_ghrA)quVC$l&swdnnxj>pYw9yd>Z z+`RO0^PZ>u)1K~|{(evAFk87J~dGbP1Nj~nMdZfbno&{&+DUy!Qs zxN*Vb#)&}L$4zU2wgR<0Zk+kJsT-&rq_=6&Xsh-Tu(z^L1%Sl#*$aJoB020-w_W%F@ delta 64 zcmeB@_$NBy=){xK>#|C)7ANNyq)zr_ZJm6G Q#d7l{)|0GIp~c+k08@w=DgXcg diff --git a/src/lib/google/tts.test.ts b/src/lib/google/tts.test.ts new file mode 100644 index 0000000..c41cfcf --- /dev/null +++ b/src/lib/google/tts.test.ts @@ -0,0 +1,134 @@ +import { + buildSynthesizeRequestBody, + mimeTypeForFormat, + normalizeResponseFormat, + parsePitch, + parseSpeed, +} from './tts'; + +describe('buildSynthesizeRequestBody', () => { + it('sends the plain text with the voice and its locale', () => { + expect( + buildSynthesizeRequestBody({ + languageCode: 'ja-JP', + voiceName: 'ja-JP-Standard-B', + text: '次は、オオサキです', + }) + ).toEqual({ + input: { text: '次は、オオサキです' }, + voice: { languageCode: 'ja-JP', name: 'ja-JP-Standard-B' }, + audioConfig: { audioEncoding: 'MP3' }, + }); + }); + + it('omits speakingRate / pitch when they are not configured', () => { + const body = buildSynthesizeRequestBody({ + languageCode: 'en-US', + voiceName: 'en-US-Standard-G', + text: 'test', + opts: {}, + }); + expect(body.audioConfig).toEqual({ audioEncoding: 'MP3' }); + }); + + it('sends speakingRate / pitch as numbers', () => { + // 環境変数は文字列。そのまま送ると API のスキーマ検証に弾かれる + const body = buildSynthesizeRequestBody({ + languageCode: 'ja-JP', + voiceName: 'ja-JP-Standard-B', + text: 'test', + opts: { responseFormat: 'wav', speed: 1.15, pitch: -1.5 }, + }); + expect(body.audioConfig).toEqual({ + audioEncoding: 'LINEAR16', + speakingRate: 1.15, + pitch: -1.5, + }); + }); + + it('omits out-of-range values rather than sending an invalid request', () => { + for (const speed of [0.1, 4.5, Number.NaN, Number.POSITIVE_INFINITY]) { + const body = buildSynthesizeRequestBody({ + languageCode: 'ja-JP', + voiceName: 'ja-JP-Standard-B', + text: 'test', + opts: { speed }, + }); + expect(body.audioConfig).not.toHaveProperty('speakingRate'); + } + for (const pitch of [-20.5, 20.5, Number.NaN]) { + const body = buildSynthesizeRequestBody({ + languageCode: 'ja-JP', + voiceName: 'ja-JP-Standard-B', + text: 'test', + opts: { pitch }, + }); + expect(body.audioConfig).not.toHaveProperty('pitch'); + } + }); +}); + +describe('normalizeResponseFormat', () => { + it('accepts the formats Cloud TTS can return', () => { + for (const format of ['mp3', 'wav', 'opus']) { + expect(normalizeResponseFormat(format)).toBe(format); + } + }); + + it('normalizes case and falls back to mp3 for unknown values', () => { + expect(normalizeResponseFormat('WAV')).toBe('wav'); + expect(normalizeResponseFormat(' Opus ')).toBe('opus'); + // OpenAI 時代の設定値が残っていても 400 にせず mp3 で合成する + expect(normalizeResponseFormat('aac')).toBe('mp3'); + expect(normalizeResponseFormat('flac')).toBe('mp3'); + expect(normalizeResponseFormat('pcm')).toBe('mp3'); + expect(normalizeResponseFormat('')).toBe('mp3'); + expect(normalizeResponseFormat(undefined)).toBe('mp3'); + }); +}); + +describe('mimeTypeForFormat', () => { + it('maps each format to the container Cloud TTS actually returns', () => { + // LINEAR16 は RIFF ヘッダ付き、OGG_OPUS は Ogg コンテナで返る + expect(mimeTypeForFormat('mp3')).toBe('audio/mpeg'); + expect(mimeTypeForFormat('wav')).toBe('audio/wav'); + expect(mimeTypeForFormat('opus')).toBe('audio/ogg'); + expect(mimeTypeForFormat('unknown')).toBe('audio/mpeg'); + }); +}); + +describe('parseSpeed', () => { + it('parses a numeric string from the environment', () => { + expect(parseSpeed('1.1')).toBe(1.1); + expect(parseSpeed(' 0.25 ')).toBe(0.25); + expect(parseSpeed('4')).toBe(4); + }); + + it('accepts numbers as-is', () => { + expect(parseSpeed(1.5)).toBe(1.5); + }); + + it('rejects out-of-range and non-numeric values', () => { + expect(parseSpeed('0.24')).toBeUndefined(); + expect(parseSpeed('4.01')).toBeUndefined(); + expect(parseSpeed('fast')).toBeUndefined(); + expect(parseSpeed('')).toBeUndefined(); + expect(parseSpeed(undefined)).toBeUndefined(); + }); +}); + +describe('parsePitch', () => { + it('accepts the semitone range, including negatives', () => { + expect(parsePitch('-20')).toBe(-20); + expect(parsePitch('0')).toBe(0); + expect(parsePitch('20')).toBe(20); + expect(parsePitch(2.5)).toBe(2.5); + }); + + it('rejects out-of-range and non-numeric values', () => { + expect(parsePitch('-20.1')).toBeUndefined(); + expect(parsePitch('20.1')).toBeUndefined(); + expect(parsePitch('high')).toBeUndefined(); + expect(parsePitch(undefined)).toBeUndefined(); + }); +}); diff --git a/src/lib/google/tts.ts b/src/lib/google/tts.ts new file mode 100644 index 0000000..8a8a7c7 --- /dev/null +++ b/src/lib/google/tts.ts @@ -0,0 +1,158 @@ +/** + * Google Cloud Text-to-Speech API(text:synthesize)でテキストを音声に変換する。 + * + * ボイスは端末内蔵 TTS 相当の系統(Standard / Wavenet / Neural2)を使う。これらは + * 読み方のプロンプト指示を受け付けないため、声の調子は audioConfig の + * speakingRate / pitch で調整する。 + * + * 認証は API キーではなくサービスアカウント(OAuth2)。Cloud TTS は Cloudflare + * AI Gateway の対応プロバイダではないため、Google へ直行する。 + * 応答の audioContent は API 時点で base64 なので、そのまま返して再変換しない。 + */ +import { getGoogleAccessToken } from './accessToken'; + +const SYNTHESIZE_URL = 'https://texttospeech.googleapis.com/v1/text:synthesize'; +const TTS_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; + +// 合成は数秒で返るが、詰まったときに Worker の CPU/実行時間を食い潰さないよう +// OpenAI / Azure 時代と同じ上限で打ち切る。 +const REQUEST_TIMEOUT_MS = 30_000; + +/** + * 応答フォーマットと audioEncoding / MIME の対応。 + * Cloud TTS の LINEAR16 は RIFF ヘッダ付き(= WAV)、OGG_OPUS は Ogg コンテナで + * 返るため、拡張子判定に使える MIME をこちらで確定させる。 + * aac / flac / 生 PCM は Cloud TTS に無いので mp3 へ倒す。 + */ +const FORMATS = { + mp3: { encoding: 'MP3', mimeType: 'audio/mpeg' }, + wav: { encoding: 'LINEAR16', mimeType: 'audio/wav' }, + opus: { encoding: 'OGG_OPUS', mimeType: 'audio/ogg' }, +} as const; + +export type ResponseFormat = keyof typeof FORMATS; + +export interface TtsOptions { + /** 応答フォーマット。未指定なら mp3 */ + responseFormat?: string; + /** 読み上げ速度(0.25〜4.0)。未指定なら付けない(等速) */ + speed?: number; + /** 声の高さ(-20.0〜20.0 セミトーン)。未指定なら付けない */ + pitch?: number; +} + +export interface SynthesizeSpeechParams { + /** Cloud TTS を呼べるサービスアカウント鍵 JSON */ + saKeyJson: string; + /** ボイスのロケール(例: ja-JP)。ボイス名と食い違うと API が 400 を返す */ + languageCode: string; + voiceName: string; + /** 読み上げるプレーンテキスト */ + text: string; + opts?: TtsOptions; +} + +export interface SynthesizedAudio { + /** base64 エンコードされた音声 */ + audioContent: string; + mimeType: string; +} + +/** + * 応答フォーマットを既知の値へ正規化する。環境変数由来の任意文字列(`MP3` の + * ような大文字や、OpenAI 時代の aac / flac / pcm)をそのまま送ると Cloud TTS が + * 400 を返し、MIME も引けなくなる。 + */ +export const normalizeResponseFormat = (format?: string): ResponseFormat => { + const value = format?.trim().toLowerCase() ?? ''; + return value in FORMATS ? (value as ResponseFormat) : 'mp3'; +}; + +/** 正規化済みフォーマットに対応する MIME。 */ +export const mimeTypeForFormat = (format?: string): string => + FORMATS[normalizeResponseFormat(format)].mimeType; + +/** + * 読み上げ速度を数値へ正規化する。環境変数は文字列なので、そのまま送ると + * API のスキーマ検証(number)に弾かれる。範囲外・非数は未指定として扱う。 + */ +export const parseSpeed = (speed?: string | number): number | undefined => + parseAudioNumber(speed, 0.25, 4.0); + +/** 声の高さ(セミトーン)を数値へ正規化する。範囲外・非数は未指定として扱う。 */ +export const parsePitch = (pitch?: string | number): number | undefined => + parseAudioNumber(pitch, -20.0, 20.0); + +const parseAudioNumber = ( + value: string | number | undefined, + min: number, + max: number +): number | undefined => { + if (value === undefined || value === null || value === '') { + return undefined; + } + const parsed = typeof value === 'number' ? value : Number(value.trim()); + if (!Number.isFinite(parsed)) { + return undefined; + } + return parsed >= min && parsed <= max ? parsed : undefined; +}; + +/** Cloud TTS へ送るリクエストボディを組み立てる。 */ +export const buildSynthesizeRequestBody = (params: { + languageCode: string; + voiceName: string; + text: string; + opts?: TtsOptions; +}): Record => { + const { languageCode, voiceName, text, opts = {} } = params; + const speed = parseSpeed(opts.speed); + const pitch = parsePitch(opts.pitch); + return { + input: { text }, + voice: { languageCode, name: voiceName }, + audioConfig: { + audioEncoding: + FORMATS[normalizeResponseFormat(opts.responseFormat)].encoding, + ...(speed !== undefined ? { speakingRate: speed } : {}), + ...(pitch !== undefined ? { pitch } : {}), + }, + }; +}; + +export const synthesizeSpeech = async ( + params: SynthesizeSpeechParams +): Promise => { + const { saKeyJson, languageCode, voiceName, text, opts = {} } = params; + const accessToken = await getGoogleAccessToken(saKeyJson, TTS_SCOPE); + + const res = await fetch(SYNTHESIZE_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'trainlcd-worker', + }, + body: JSON.stringify( + buildSynthesizeRequestBody({ languageCode, voiceName, text, opts }) + ), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error( + `Google TTS returned ${res.status}: ${detail.slice(0, 500)}` + ); + } + + const json = (await res.json()) as { audioContent?: unknown }; + if (typeof json.audioContent !== 'string' || json.audioContent.length === 0) { + throw new Error('Google TTS response missing audioContent'); + } + + return { + audioContent: json.audioContent, + mimeType: mimeTypeForFormat(opts.responseFormat), + }; +}; diff --git a/src/lib/openai/tts.test.ts b/src/lib/openai/tts.test.ts deleted file mode 100644 index 26f0255..0000000 --- a/src/lib/openai/tts.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - buildSpeechRequestBody, - buildSpeechUrl, - normalizeResponseFormat, - parseSpeed, -} from './tts'; - -describe('buildSpeechUrl', () => { - it('targets the OpenAI API directly when no gateway is configured', () => { - expect(buildSpeechUrl()).toBe('https://api.openai.com/v1/audio/speech'); - expect(buildSpeechUrl('')).toBe('https://api.openai.com/v1/audio/speech'); - }); - - it('routes through the AI Gateway when configured', () => { - expect(buildSpeechUrl('https://gateway.example.com/v1/acct/gw')).toBe( - 'https://gateway.example.com/v1/acct/gw/openai/v1/audio/speech' - ); - }); - - it('tolerates trailing slashes on the gateway base url', () => { - expect(buildSpeechUrl('https://gateway.example.com/v1/acct/gw///')).toBe( - 'https://gateway.example.com/v1/acct/gw/openai/v1/audio/speech' - ); - }); -}); - -describe('buildSpeechRequestBody', () => { - it('sends the plain text as input with the model and voice', () => { - expect( - buildSpeechRequestBody({ - model: 'gpt-4o-mini-tts', - voiceName: 'nova', - text: '次は、オオサキです', - }) - ).toEqual({ - model: 'gpt-4o-mini-tts', - voice: 'nova', - input: '次は、オオサキです', - response_format: 'mp3', - }); - }); - - it('passes instructions through when provided', () => { - // gpt-4o-mini-tts は SSML 非対応で、読み方は instructions で指示する - const body = buildSpeechRequestBody({ - model: 'gpt-4o-mini-tts', - voiceName: 'nova', - text: 'The next station is Osaki.', - opts: { instructions: 'calm female announcer' }, - }); - expect(body.instructions).toBe('calm female announcer'); - }); - - it('omits optional fields that are not set', () => { - const body = buildSpeechRequestBody({ - model: 'gpt-4o-mini-tts', - voiceName: 'nova', - text: 'test', - opts: {}, - }); - expect(body).not.toHaveProperty('instructions'); - expect(body).not.toHaveProperty('speed'); - }); - - it('honors a custom response format and sends speed as a number', () => { - // OpenAI の speed は number。文字列で送るとスキーマ検証に弾かれる - const body = buildSpeechRequestBody({ - model: 'gpt-4o-mini-tts', - voiceName: 'nova', - text: 'test', - opts: { responseFormat: 'wav', speed: 1.1 }, - }); - expect(body.response_format).toBe('wav'); - expect(body.speed).toBe(1.1); - expect(typeof body.speed).toBe('number'); - }); - - it('omits an out-of-range speed rather than sending an invalid value', () => { - for (const speed of [0.1, 4.5, Number.NaN, Number.POSITIVE_INFINITY]) { - const body = buildSpeechRequestBody({ - model: 'gpt-4o-mini-tts', - voiceName: 'nova', - text: 'test', - opts: { speed }, - }); - expect(body).not.toHaveProperty('speed'); - } - }); - - it('falls back to mp3 for an unknown or mis-cased response format', () => { - // 環境変数由来の任意文字列をそのまま送ると OpenAI が 400 を返す - expect( - buildSpeechRequestBody({ - model: 'gpt-4o-mini-tts', - voiceName: 'nova', - text: 'test', - opts: { responseFormat: 'MP3' }, - }).response_format - ).toBe('mp3'); - expect( - buildSpeechRequestBody({ - model: 'gpt-4o-mini-tts', - voiceName: 'nova', - text: 'test', - opts: { responseFormat: 'wma' }, - }).response_format - ).toBe('mp3'); - }); - - it('drops instructions for models that do not support them', () => { - // tts-1 / tts-1-hd に instructions を送ると OpenAI が 400 を返し、 - // /tts 全体が失敗する - for (const model of ['tts-1', 'tts-1-hd']) { - const body = buildSpeechRequestBody({ - model, - voiceName: 'nova', - text: 'test', - opts: { instructions: 'calm female announcer' }, - }); - expect(body).not.toHaveProperty('instructions'); - } - - expect( - buildSpeechRequestBody({ - model: 'gpt-4o-mini-tts', - voiceName: 'nova', - text: 'test', - opts: { instructions: 'calm female announcer' }, - }).instructions - ).toBe('calm female announcer'); - }); -}); - -describe('normalizeResponseFormat', () => { - it('accepts the known formats', () => { - for (const format of ['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']) { - expect(normalizeResponseFormat(format)).toBe(format); - } - }); - - it('normalizes case and falls back to mp3 for unknown values', () => { - expect(normalizeResponseFormat('WAV')).toBe('wav'); - expect(normalizeResponseFormat(' Opus ')).toBe('opus'); - expect(normalizeResponseFormat('wma')).toBe('mp3'); - expect(normalizeResponseFormat('')).toBe('mp3'); - expect(normalizeResponseFormat(undefined)).toBe('mp3'); - }); -}); - -describe('parseSpeed', () => { - it('parses a numeric string from the environment', () => { - expect(parseSpeed('1.1')).toBe(1.1); - expect(parseSpeed(' 0.25 ')).toBe(0.25); - expect(parseSpeed('4')).toBe(4); - }); - - it('accepts numbers as-is', () => { - expect(parseSpeed(1.5)).toBe(1.5); - }); - - it('rejects out-of-range and non-numeric values', () => { - expect(parseSpeed('0.24')).toBeUndefined(); - expect(parseSpeed('4.01')).toBeUndefined(); - expect(parseSpeed('fast')).toBeUndefined(); - expect(parseSpeed('')).toBeUndefined(); - expect(parseSpeed(undefined)).toBeUndefined(); - }); -}); diff --git a/src/lib/openai/tts.ts b/src/lib/openai/tts.ts deleted file mode 100644 index ace45da..0000000 --- a/src/lib/openai/tts.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * OpenAI Audio Speech API(gpt-4o-mini-tts)でテキストを音声に変換する。 - * SSML は非対応で、代わりに `instructions` で声色・速度・間の取り方を指示する。 - * 出力は MP3。 - */ -import { bytesToBase64 } from '../crypto'; - -const OPENAI_API_BASE_URL = 'https://api.openai.com'; -const SPEECH_PATH = '/v1/audio/speech'; - -// 合成は数秒で返るが、詰まったときに Worker の CPU/実行時間を食い潰さないよう -// Azure 時代と同じ上限で打ち切る。 -const REQUEST_TIMEOUT_MS = 30_000; - -// AI Gateway 経由時も読み上げ本文をゲートウェイのログに保存させない(設計: プライバシー)。 -// agent/llm.ts と同じ方針。 -const GATEWAY_HEADERS = { 'cf-aig-collect-log-payload': 'false' } as const; - -export interface TtsOptions { - /** 読み方の指示(instructions 対応モデルのみ)。未指定なら付けない */ - instructions?: string; - /** 応答フォーマット。未指定なら mp3 */ - responseFormat?: string; - /** 読み上げ速度(0.25〜4.0)。未指定なら付けない。API は数値を要求する */ - speed?: number; -} - -export interface SynthesizeSpeechParams { - apiKey: string; - /** Cloudflare AI Gateway のベース URL。未指定なら OpenAI へ直行 */ - gatewayBaseUrl?: string; - model: string; - voiceName: string; - /** 読み上げるプレーンテキスト */ - text: string; - opts?: TtsOptions; -} - -export interface SynthesizedAudio { - /** base64 エンコードされた音声 */ - audioContent: string; - mimeType: string; -} - -// response_format と実際に返る Content-Type の対応。応答ヘッダーが欠けていても -// アプリ側が拡張子を判定できるよう、こちらで確定させる。 -const MIME_BY_FORMAT: Record = { - mp3: 'audio/mpeg', - opus: 'audio/opus', - aac: 'audio/aac', - flac: 'audio/flac', - wav: 'audio/wav', - pcm: 'audio/pcm;rate=24000', -}; - -// instructions を受け付けないモデル。旧 tts-1 系に instructions を送ると -// OpenAI が 400 を返し、/tts 全体が失敗するため送信対象から外す。 -const INSTRUCTIONS_UNSUPPORTED_MODELS = new Set(['tts-1', 'tts-1-hd']); - -export const modelSupportsInstructions = (model: string): boolean => - !INSTRUCTIONS_UNSUPPORTED_MODELS.has(model.trim().toLowerCase()); - -/** - * 応答フォーマットを既知の値へ正規化する。環境変数由来の任意文字列(`MP3` の - * ような大文字や誤字)をそのまま送ると OpenAI が 400 を返し、MIME も引けなくなる。 - */ -export const normalizeResponseFormat = (format?: string): string => { - const value = format?.trim().toLowerCase() || ''; - return value in MIME_BY_FORMAT ? value : 'mp3'; -}; - -/** - * 読み上げ速度を数値へ正規化する。環境変数は文字列なので、そのまま送ると - * API のスキーマ検証(number)に弾かれる。範囲外・非数は未指定として扱う。 - */ -export const parseSpeed = (speed?: string | number): number | undefined => { - if (speed === undefined || speed === null || speed === '') { - return undefined; - } - const parsed = typeof speed === 'number' ? speed : Number(speed.trim()); - if (!Number.isFinite(parsed)) { - return undefined; - } - return parsed >= 0.25 && parsed <= 4.0 ? parsed : undefined; -}; - -/** リクエストの送信先を組み立てる。Gateway 指定時は末尾スラッシュの揺れを吸収する。 */ -export const buildSpeechUrl = (gatewayBaseUrl?: string): string => { - const gateway = gatewayBaseUrl?.replace(/\/+$/, '') || ''; - return gateway - ? `${gateway}/openai${SPEECH_PATH}` - : `${OPENAI_API_BASE_URL}${SPEECH_PATH}`; -}; - -/** OpenAI へ送るリクエストボディを組み立てる。 */ -export const buildSpeechRequestBody = (params: { - model: string; - voiceName: string; - text: string; - opts?: TtsOptions; -}): Record => { - const { model, voiceName, text, opts = {} } = params; - const speed = parseSpeed(opts.speed); - return { - model, - voice: voiceName, - input: text, - response_format: normalizeResponseFormat(opts.responseFormat), - ...(opts.instructions && modelSupportsInstructions(model) - ? { instructions: opts.instructions } - : {}), - ...(speed !== undefined ? { speed } : {}), - }; -}; - -export const synthesizeSpeech = async ( - params: SynthesizeSpeechParams -): Promise => { - const { apiKey, gatewayBaseUrl, model, voiceName, text, opts = {} } = params; - - const res = await fetch(buildSpeechUrl(gatewayBaseUrl), { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'User-Agent': 'trainlcd-worker', - ...(gatewayBaseUrl ? GATEWAY_HEADERS : {}), - }, - body: JSON.stringify( - buildSpeechRequestBody({ model, voiceName, text, opts }) - ), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - - if (!res.ok) { - const detail = await res.text().catch(() => ''); - throw new Error( - `OpenAI TTS returned ${res.status}: ${detail.slice(0, 500)}` - ); - } - - const format = normalizeResponseFormat(opts.responseFormat); - const buf = await res.arrayBuffer(); - return { - audioContent: bytesToBase64(buf), - mimeType: MIME_BY_FORMAT[format], - }; -}; diff --git a/src/lib/ttsCache.test.ts b/src/lib/ttsCache.test.ts index a7a52a1..ed7d841 100644 --- a/src/lib/ttsCache.test.ts +++ b/src/lib/ttsCache.test.ts @@ -16,7 +16,7 @@ const createEnv = () => { const basePayload = { id: 'abc123', - model: 'gpt-4o-mini-tts', + model: 'google-cloud-tts', jaAudioContent: 'QQ==', enAudioContent: 'QQ==', jaAudioMimeType: 'audio/mpeg', @@ -42,7 +42,7 @@ describe('writeTtsCache', () => { expect(meta).toEqual( expect.objectContaining({ id: 'abc123', - model: 'gpt-4o-mini-tts', + model: 'google-cloud-tts', pathJa: 'caches/tts/ja/abc123.mp3', pathEn: 'caches/tts/en/abc123.mp3', textJa: '次は、オオサキです', @@ -115,7 +115,7 @@ describe('writeTtsCache', () => { await writeTtsCache( { id: 'abc123', - model: 'gpt-4o-mini-tts', + model: 'google-cloud-tts', }, env ); diff --git a/src/routes/tts.test.ts b/src/routes/tts.test.ts index 27ca74b..3ba15de 100644 --- a/src/routes/tts.test.ts +++ b/src/routes/tts.test.ts @@ -1,6 +1,5 @@ import { CallableError } from '../lib/callable'; -import { utf8ByteLength } from '../utils/ssml'; -import { computeId, parseTtsText, resolveInstructions } from './tts'; +import { computeId, parseTtsText } from './tts'; describe('parseTtsText', () => { it('returns the trimmed text', () => { @@ -22,7 +21,7 @@ describe('parseTtsText', () => { }); it('strips tags so stray SSML is never read aloud', () => { - // gpt-4o-mini-tts は SSML を解釈せずタグをそのまま読み上げてしまう + // Cloud TTS の input.text は SSML を解釈せずタグをそのまま読み上げてしまう expect( parseTtsText('次は大崎です', 'textJa') ).toBe('次はオオサキです'); @@ -46,59 +45,11 @@ describe('parseTtsText', () => { }); }); -describe('resolveInstructions', () => { - it('prefers the requested instructions', () => { - expect(resolveInstructions('requested', 'configured', 'fallback')).toBe( - 'requested' - ); - }); - - it('falls back to KV config, then to the env default', () => { - expect(resolveInstructions(undefined, 'configured', 'fallback')).toBe( - 'configured' - ); - expect(resolveInstructions(undefined, undefined, 'fallback')).toBe( - 'fallback' - ); - expect(resolveInstructions(' ', ' ', 'fallback')).toBe('fallback'); - }); - - it('returns an empty string when nothing is configured', () => { - expect(resolveInstructions(undefined, undefined, undefined)).toBe(''); - }); - - it('ignores non-string requests', () => { - expect(resolveInstructions(42, undefined, 'fallback')).toBe('fallback'); - }); - - it('truncates rather than failing when the instructions are too long', () => { - // 読み方の指示は本文ではないため、長すぎても放送そのものは落とさない - const result = resolveInstructions('x'.repeat(5000), undefined, undefined); - expect(utf8ByteLength(result)).toBe(2000); - }); - - it('truncates by UTF-8 bytes, not characters', () => { - // 日本語は 1 文字 3 バイト。文字数で切ると上限を守れない - const result = resolveInstructions('あ'.repeat(3000), undefined, undefined); - expect(utf8ByteLength(result)).toBeLessThanOrEqual(2000); - expect(result).toHaveLength(666); - }); - - it('does not split surrogate pairs when truncating', () => { - const result = resolveInstructions('🚃'.repeat(1000), undefined, undefined); - expect(utf8ByteLength(result)).toBeLessThanOrEqual(2000); - expect(result).not.toMatch(/\uFFFD/); - expect([...result].every((char) => char === '🚃')).toBe(true); - }); -}); - describe('computeId', () => { const base = { - enVoiceName: 'nova', - instructionsEn: 'calm', - instructionsJa: '落ち着いて', - jaVoiceName: 'nova', - model: 'gpt-4o-mini-tts', + enVoiceName: 'en-US-Standard-G', + jaVoiceName: 'ja-JP-Standard-B', + pitch: null as number | null, responseFormat: 'mp3', speed: null as number | null, textEn: 'The next station is Osaki.', @@ -112,15 +63,13 @@ describe('computeId', () => { it.each([ ['textJa', { textJa: '次は、シンジュクです' }], ['textEn', { textEn: 'The next station is Shinjuku.' }], - ['model', { model: 'tts-1' }], - ['jaVoiceName', { jaVoiceName: 'shimmer' }], - ['enVoiceName', { enVoiceName: 'shimmer' }], - ['instructionsJa', { instructionsJa: '明るく' }], - ['instructionsEn', { instructionsEn: 'bright' }], + ['jaVoiceName', { jaVoiceName: 'ja-JP-Wavenet-A' }], + ['enVoiceName', { enVoiceName: 'en-US-Wavenet-F' }], // responseFormat / speed はかつてネストしたオブジェクトに置いていたため、 // JSON.stringify の配列 replacer に落とされて ID に反映されていなかった ['responseFormat', { responseFormat: 'wav' }], ['speed', { speed: 1.25 }], + ['pitch', { pitch: 1.5 }], ])('changes when %s changes', async (_name, override) => { expect(await computeId({ ...base, ...override })).not.toBe( await computeId(base) diff --git a/src/routes/tts.ts b/src/routes/tts.ts index ef3ee1e..f678c14 100644 --- a/src/routes/tts.ts +++ b/src/routes/tts.ts @@ -1,4 +1,4 @@ -/** POST /tts — OpenAI(gpt-4o-mini-tts) で音声合成し、KV/R2 キャッシュを介して返す(callable 互換)。 */ +/** POST /tts — Google Cloud TTS で音声合成し、KV/R2 キャッシュを介して返す(callable 互換)。 */ import { verifySessionToken } from '../lib/auth/session'; import { CallableError, @@ -8,34 +8,40 @@ import { import { bytesToBase64, sha256Hex } from '../lib/crypto'; import { normalizeResponseFormat, + parsePitch, parseSpeed, synthesizeSpeech, type TtsOptions, -} from '../lib/openai/tts'; +} from '../lib/google/tts'; import { writeTtsCache } from '../lib/ttsCache'; import type { Env } from '../types'; import { normalizeRomanText } from '../utils/normalize'; -import { stripSsml, truncateToByteLimit, utf8ByteLength } from '../utils/ssml'; -import { resolveOpenAiVoiceName, resolveTtsModel } from '../utils/ttsVoice'; +import { stripSsml, utf8ByteLength } from '../utils/ssml'; +import { + languageCodeFromVoiceName, + resolveGoogleVoiceName, +} from '../utils/ttsVoice'; +/** + * model / instructions* は OpenAI(gpt-4o-mini-tts) 時代のフィールド。Cloud TTS の + * Standard 系ボイスにはモデル指定も読み方のプロンプト指示も無いため受け取っても + * 使わないが、旧バージョンのアプリが送ってきても壊れないよう型としては残す。 + */ interface TtsRequest { textJa?: unknown; textEn?: unknown; - model?: unknown; jaVoiceName?: unknown; enVoiceName?: unknown; - instructionsJa?: unknown; - instructionsEn?: unknown; } interface TtsConfig { - model?: string; jaVoiceName?: string; enVoiceName?: string; - instructionsJa?: string; - instructionsEn?: string; } +/** キャッシュメタに残す合成エンジン名(find-tts-cache の表示用)。 */ +const TTS_ENGINE = 'google-cloud-tts'; + interface VoiceCacheMeta { pathJa?: string; pathEn?: string; @@ -44,10 +50,9 @@ interface VoiceCacheMeta { } const TEXT_BYTE_LIMIT = 4000; -// 読み方の指示は声色の調整用で、長文を受ける必要はない。無制限に受けると -// リクエストサイズとキャッシュキーが無駄に膨らむため上限を設ける。 -const INSTRUCTIONS_BYTE_LIMIT = 2000; -const HASH_VERSION = 13; +// 合成エンジンの入れ替え(OpenAI → Google Cloud TTS)で同じ入力でも音声が変わるため、 +// 旧キャッシュへヒットしないよう版を上げる +const HASH_VERSION = 14; const TTS_CONFIG_CACHE_TTL_MS = 5 * 60 * 1000; let ttsConfigCache: { data: TtsConfig; fetchedAt: number } | null = null; @@ -75,10 +80,8 @@ const getTtsConfig = async (env: Env): Promise => { // キャッシュキーへ含めたい値はすべてトップレベルへ平坦化して渡すこと。 export const computeId = async (payload: { enVoiceName: string; - instructionsEn: string; - instructionsJa: string; jaVoiceName: string; - model: string; + pitch: number | null; responseFormat: string; speed: number | null; textEn: string; @@ -104,7 +107,7 @@ export const parseTtsText = (value: unknown, name: string): string => { `"${name}" must be a string if provided` ); } - // gpt-4o-mini-tts は SSML を解釈せずタグをそのまま読み上げるため、万一 + // Cloud TTS の input.text は SSML を解釈せずタグをそのまま読み上げるため、万一 // タグが紛れ込んでも読ませない。プレーンテキストには実質作用しない。 const stripped = stripSsml(value).trim(); if (stripped.length === 0) { @@ -121,24 +124,6 @@ export const parseTtsText = (value: unknown, name: string): string => { return stripped; }; -/** 読み方の指示を リクエスト → KV 設定 → 環境変数 の順で解決する。 */ -export const resolveInstructions = ( - requested: unknown, - configured: string | undefined, - fallback: string | undefined -): string => { - const value = - typeof requested === 'string' && requested.trim().length > 0 - ? requested.trim() - : configured?.trim() || fallback?.trim() || ''; - if (!value) { - return ''; - } - // 上限超過は弾かずに切り詰める。読み方の指示は本文ではないため、 - // これだけで放送そのものを失敗させる必要はない。 - return truncateToByteLimit(value, INSTRUCTIONS_BYTE_LIMIT); -}; - export const handleTts = async ( req: Request, env: Env, @@ -161,48 +146,38 @@ export const handleTts = async ( ); } - if (!env.OPENAI_API_KEY) { + if (!env.GOOGLE_TTS_SA_KEY) { throw new CallableError( 'failed-precondition', - 'OPENAI_API_KEY is not configured' + 'GOOGLE_TTS_SA_KEY is not configured' ); } const ttsConfig = await getTtsConfig(env); - const model = resolveTtsModel(data.model, ttsConfig.model, env.TTS_MODEL); - const jaVoiceName = resolveOpenAiVoiceName( + const jaVoiceName = resolveGoogleVoiceName( data.jaVoiceName, ttsConfig.jaVoiceName, - env.TTS_JA_VOICE_NAME + env.TTS_JA_VOICE_NAME, + 'ja' ); - const enVoiceName = resolveOpenAiVoiceName( + const enVoiceName = resolveGoogleVoiceName( data.enVoiceName, ttsConfig.enVoiceName, - env.TTS_EN_VOICE_NAME - ); - const instructionsJa = resolveInstructions( - data.instructionsJa, - ttsConfig.instructionsJa, - env.TTS_INSTRUCTIONS_JA - ); - const instructionsEn = resolveInstructions( - data.instructionsEn, - ttsConfig.instructionsEn, - env.TTS_INSTRUCTIONS_EN + env.TTS_EN_VOICE_NAME, + 'en' ); // 環境変数は文字列なので、送信前に正規化した値を作る。この正規化後の値を // そのままキャッシュキーにも使い、設定変更が確実に別 ID になるようにする。 const responseFormat = normalizeResponseFormat(env.TTS_RESPONSE_FORMAT); const speed = parseSpeed(env.TTS_SPEED); - const ttsOptions: TtsOptions = { responseFormat, speed }; + const pitch = parsePitch(env.TTS_PITCH); + const ttsOptions: TtsOptions = { responseFormat, speed, pitch }; const id = await computeId({ enVoiceName, - instructionsEn, - instructionsJa, jaVoiceName, - model, + pitch: pitch ?? null, responseFormat, speed: speed ?? null, textEn, @@ -248,28 +223,25 @@ export const handleTts = async ( } } - // --- 合成(OpenAI) --- + // --- 合成(Google Cloud TTS) --- // 要求された言語だけ合成する(合成は文字数課金) - const gatewayBaseUrl = env.AI_GATEWAY_BASE_URL || undefined; const [jaAudio, enAudio] = await Promise.all([ wantsJa ? synthesizeSpeech({ - apiKey: env.OPENAI_API_KEY, - gatewayBaseUrl, - model, + saKeyJson: env.GOOGLE_TTS_SA_KEY, + languageCode: languageCodeFromVoiceName(jaVoiceName), voiceName: jaVoiceName, text: textJa, - opts: { ...ttsOptions, instructions: instructionsJa || undefined }, + opts: ttsOptions, }) : null, wantsEn ? synthesizeSpeech({ - apiKey: env.OPENAI_API_KEY, - gatewayBaseUrl, - model, + saKeyJson: env.GOOGLE_TTS_SA_KEY, + languageCode: languageCodeFromVoiceName(enVoiceName), voiceName: enVoiceName, text: textEn, - opts: { ...ttsOptions, instructions: instructionsEn || undefined }, + opts: ttsOptions, }) : null, ]); @@ -286,7 +258,7 @@ export const handleTts = async ( enAudioMimeType: enAudio?.mimeType, textJa, textEn, - model, + model: TTS_ENGINE, voiceJa: wantsJa ? jaVoiceName : undefined, voiceEn: wantsEn ? enVoiceName : undefined, }, diff --git a/src/types.ts b/src/types.ts index 9f4dad9..5887ae9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,14 +17,12 @@ export interface Env { // --- Vars(非機密。wrangler.jsonc の vars) --- GOOGLE_PLAY_PACKAGE_NAME: string; AI_TRIAGE_MODEL: string; - /** 合成に使う OpenAI TTS モデル(例: gpt-4o-mini-tts) */ - TTS_MODEL: string; - /** OpenAI TTS のボイス名(例: shimmer)。ボイスは多言語対応だが日英で別のボイスを指定できる */ + /** + * Google Cloud TTS のボイス名(例: ja-JP-Standard-B)。ロケールを含むため + * 日英で別々に指定する。Standard / Wavenet / Neural2 のみ受け付ける + */ TTS_JA_VOICE_NAME: string; TTS_EN_VOICE_NAME: string; - /** 読み方の指示(gpt-4o-mini-tts の instructions)の既定値 */ - TTS_INSTRUCTIONS_JA: string; - TTS_INSTRUCTIONS_EN: string; SESSION_TOKEN_TTL_SECONDS: string; UPLOAD_PUBLIC_BASE_URL: string; FEW_SHOT_KV_KEY: string; @@ -69,14 +67,18 @@ export interface Env { * アカウント認証のため。ロールは Vertex AI User 相当) */ GOOGLE_VERTEX_SA_KEY?: string; + /** Cloud TTS(/tts)用の Google サービスアカウント鍵 JSON 文字列 */ + GOOGLE_TTS_SA_KEY?: string; /** LangSmith の API キー(dev 環境のトレーシング用・任意) */ LANGSMITH_API_KEY?: string; - // --- TTS チューニング(任意。未設定なら mp3・等速) --- - /** OpenAI TTS の response_format(mp3 / opus / aac / flac / wav / pcm) */ + // --- TTS チューニング(任意。未設定なら mp3・等速・標準の高さ) --- + /** Cloud TTS の audioEncoding に対応する形式(mp3 / wav / opus) */ TTS_RESPONSE_FORMAT?: string; - /** 読み上げ速度(0.25〜4.0) */ + /** 読み上げ速度(speakingRate。0.25〜4.0) */ TTS_SPEED?: string; + /** 声の高さ(pitch。-20.0〜20.0 セミトーン) */ + TTS_PITCH?: string; // --- 任意のデバッグ変数(未設定可) --- REVIEWS_DEBUG?: string; diff --git a/src/utils/ttsVoice.test.ts b/src/utils/ttsVoice.test.ts index 2805b51..a9c19cf 100644 --- a/src/utils/ttsVoice.test.ts +++ b/src/utils/ttsVoice.test.ts @@ -1,102 +1,121 @@ import { - DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE, - isOpenAiVoiceName, - isTtsModel, - resolveOpenAiVoiceName, - resolveTtsModel, + isGoogleVoiceName, + languageCodeFromVoiceName, + resolveGoogleVoiceName, } from './ttsVoice'; -describe('ttsVoice (OpenAI)', () => { - it('accepts OpenAI voice presets', () => { - expect(isOpenAiVoiceName('nova')).toBe(true); - expect(isOpenAiVoiceName('shimmer')).toBe(true); - expect(isOpenAiVoiceName('coral')).toBe(true); - expect(isOpenAiVoiceName('alloy')).toBe(true); +describe('isGoogleVoiceName', () => { + it('accepts the voice families we allow', () => { + expect(isGoogleVoiceName('ja-JP-Standard-B', 'ja')).toBe(true); + expect(isGoogleVoiceName('ja-JP-Wavenet-A', 'ja')).toBe(true); + expect(isGoogleVoiceName('ja-JP-Neural2-B', 'ja')).toBe(true); + expect(isGoogleVoiceName(' en-US-Standard-G ', 'en')).toBe(true); }); - it('accepts voice names case-insensitively and with surrounding spaces', () => { - expect(isOpenAiVoiceName('Nova')).toBe(true); - expect(isOpenAiVoiceName(' NOVA ')).toBe(true); + it('rejects families that are far more expensive per character', () => { + // クライアントに高単価のボイスを名指しさせない + expect(isGoogleVoiceName('ja-JP-Chirp3-HD-Aoede', 'ja')).toBe(false); + expect(isGoogleVoiceName('en-US-Studio-O', 'en')).toBe(false); + expect(isGoogleVoiceName('Kore', 'en')).toBe(false); }); - it('rejects unknown voice ids', () => { - // Azure/Google 時代のボイス名がそのまま送られてきても弾く - expect(isOpenAiVoiceName('ja-JP-NanamiNeural')).toBe(false); - expect(isOpenAiVoiceName('ja-JP-Standard-B')).toBe(false); - expect(isOpenAiVoiceName('')).toBe(false); + it('rejects well-formed but non-existent voices', () => { + // 形式だけの検証では通ってしまい、Cloud TTS が + // 400 "Voice ... does not exist" を返して /tts が落ちる + expect(isGoogleVoiceName('ja-US-Standard-A', 'ja')).toBe(false); + expect(isGoogleVoiceName('ja-JP-Standard-Z', 'ja')).toBe(false); + // 系統ごとに欠番がある(ja-JP の Neural2 は A、en-US の Neural2 は B が無い) + expect(isGoogleVoiceName('ja-JP-Neural2-A', 'ja')).toBe(false); + expect(isGoogleVoiceName('en-US-Neural2-B', 'en')).toBe(false); + // 実在するが未対応のロケール。使うなら allowlist へ追加する + expect(isGoogleVoiceName('en-GB-Standard-A', 'en')).toBe(false); }); - it('prefers a valid requested voice', () => { - expect(resolveOpenAiVoiceName('shimmer', 'coral', 'nova')).toBe('shimmer'); + it('rejects a voice whose language does not match the text', () => { + // ja のテキストに en のボイスを渡すと Cloud TTS が 400 を返す + expect(isGoogleVoiceName('en-US-Standard-G', 'ja')).toBe(false); + expect(isGoogleVoiceName('ja-JP-Standard-B', 'en')).toBe(false); }); - it('normalizes the resolved voice to lower case', () => { - expect(resolveOpenAiVoiceName('Shimmer', 'coral', 'nova')).toBe('shimmer'); + it('rejects names from the previous engines', () => { + expect(isGoogleVoiceName('shimmer', 'ja')).toBe(false); + expect(isGoogleVoiceName('ja-JP-NanamiNeural', 'ja')).toBe(false); + expect(isGoogleVoiceName('', 'ja')).toBe(false); }); +}); - it('falls back to a configured voice when the request is invalid', () => { - expect(resolveOpenAiVoiceName('ja-JP-NanamiNeural', 'coral', 'nova')).toBe( - 'coral' - ); +describe('languageCodeFromVoiceName', () => { + it('derives the locale from the voice name', () => { + // voice.name と languageCode の食い違いは 400 になるため名前から導出する + expect(languageCodeFromVoiceName('ja-JP-Standard-B')).toBe('ja-JP'); + expect(languageCodeFromVoiceName('en-GB-Wavenet-A')).toBe('en-GB'); }); +}); - it('falls back to the default voice when both inputs are invalid', () => { +describe('resolveGoogleVoiceName', () => { + it('prefers a valid requested voice', () => { expect( - resolveOpenAiVoiceName('ja-JP-NanamiNeural', 'en-US-JennyNeural', 'nova') - ).toBe('nova'); + resolveGoogleVoiceName( + 'ja-JP-Wavenet-A', + 'ja-JP-Standard-A', + 'ja-JP-Standard-B', + 'ja' + ) + ).toBe('ja-JP-Wavenet-A'); }); - it('falls back to the default voice for non-string inputs', () => { - expect(resolveOpenAiVoiceName(undefined, undefined, 'nova')).toBe('nova'); - expect(resolveOpenAiVoiceName(42, {}, 'nova')).toBe('nova'); - }); - - it('validates the env default too, so a stale Azure value never reaches OpenAI', () => { - // 環境変数の設定ミスをそのまま送ると OpenAI が 400 を返し /tts が落ちる + it('falls back to the KV config, then to the env default', () => { + expect( + resolveGoogleVoiceName( + 'ja-JP-Chirp3-HD-Aoede', + 'ja-JP-Standard-A', + 'ja-JP-Standard-B', + 'ja' + ) + ).toBe('ja-JP-Standard-A'); expect( - resolveOpenAiVoiceName(undefined, undefined, 'ja-JP-NanamiNeural') - ).toBe(DEFAULT_TTS_VOICE); - expect(resolveOpenAiVoiceName(undefined, undefined, '')).toBe( - DEFAULT_TTS_VOICE + resolveGoogleVoiceName(undefined, undefined, 'ja-JP-Standard-B', 'ja') + ).toBe('ja-JP-Standard-B'); + expect(resolveGoogleVoiceName(42, {}, 'en-US-Standard-G', 'en')).toBe( + 'en-US-Standard-G' ); }); -}); - -describe('resolveTtsModel', () => { - it('accepts the allowed TTS models', () => { - expect(isTtsModel('gpt-4o-mini-tts')).toBe(true); - expect(isTtsModel('tts-1')).toBe(true); - expect(isTtsModel('tts-1-hd')).toBe(true); - }); - it('rejects models outside the allowlist', () => { - // クライアントに高額なモデルを名指しさせない - expect(isTtsModel('gpt-4o')).toBe(false); - expect(isTtsModel('gpt-5.6-luna')).toBe(false); - expect(isTtsModel('')).toBe(false); + it('validates the env default too, so a stale OpenAI value never reaches Google', () => { + // 環境変数の設定ミスをそのまま送ると Google が 400 を返し /tts が落ちる + expect(resolveGoogleVoiceName(undefined, undefined, 'shimmer', 'ja')).toBe( + DEFAULT_TTS_VOICE.ja + ); + expect(resolveGoogleVoiceName(undefined, undefined, undefined, 'en')).toBe( + DEFAULT_TTS_VOICE.en + ); }); - it('prefers a valid requested model', () => { - expect(resolveTtsModel('tts-1-hd', 'tts-1', 'gpt-4o-mini-tts')).toBe( - 'tts-1-hd' - ); + it('falls back for well-formed but non-existent voices', () => { + // 形式が正しいだけの名前を通すと Cloud TTS が 400 を返す + expect( + resolveGoogleVoiceName('ja-US-Standard-A', undefined, undefined, 'ja') + ).toBe(DEFAULT_TTS_VOICE.ja); + expect( + resolveGoogleVoiceName('ja-JP-Standard-Z', undefined, undefined, 'ja') + ).toBe(DEFAULT_TTS_VOICE.ja); }); - it('falls back through config to the default for disallowed models', () => { - expect(resolveTtsModel('gpt-4o', 'tts-1', 'gpt-4o-mini-tts')).toBe('tts-1'); - expect(resolveTtsModel('gpt-4o', 'gpt-4o', 'gpt-4o-mini-tts')).toBe( - 'gpt-4o-mini-tts' - ); - expect(resolveTtsModel(undefined, undefined, 'gpt-4o-mini-tts')).toBe( - 'gpt-4o-mini-tts' - ); + it('has defaults that are themselves allowed voices', () => { + // 既定値が allowlist から外れると、全リクエストが 400 になる + expect(isGoogleVoiceName(DEFAULT_TTS_VOICE.ja, 'ja')).toBe(true); + expect(isGoogleVoiceName(DEFAULT_TTS_VOICE.en, 'en')).toBe(true); }); - it('validates the env default too', () => { - expect(resolveTtsModel(undefined, undefined, 'gpt-4o')).toBe( - DEFAULT_TTS_MODEL - ); - expect(resolveTtsModel(undefined, undefined, '')).toBe(DEFAULT_TTS_MODEL); + it('never returns a voice from the wrong language', () => { + expect( + resolveGoogleVoiceName( + 'ja-JP-Standard-B', + 'ja-JP-Wavenet-A', + 'ja-JP-Neural2-B', + 'en' + ) + ).toBe(DEFAULT_TTS_VOICE.en); }); }); diff --git a/src/utils/ttsVoice.ts b/src/utils/ttsVoice.ts index 52d8e7f..0215440 100644 --- a/src/utils/ttsVoice.ts +++ b/src/utils/ttsVoice.ts @@ -1,96 +1,123 @@ /** - * OpenAI TTS のボイス名を扱うユーティリティ。 + * Google Cloud TTS のボイス名を扱うユーティリティ。 * - * gpt-4o-mini-tts のボイスは固定の名前付きプリセット(`shimmer` など)で、Azure の - * ような `-Neural` 形式ではない。ボイスは多言語対応のため日英で - * 同じ名前を使える。クライアントから任意文字列が渡るため、未知の名前は - * そのまま OpenAI へ流さず既定値へ倒す(400 で放送を落とさないため)。 + * ボイス名は `<言語>-<地域>-<系統>-<記号>`(例: ja-JP-Standard-B)で、Azure と同じく + * ロケールを含む。言語ごとに別のボイスを指定する必要があるため、日英で共通の名前は + * 使えない(OpenAI の `shimmer` のような多言語プリセットとは異なる)。 + * + * クライアントから任意文字列が渡るため、未知の名前はそのまま Google へ流さず + * 既定値へ倒す(400 で放送を落とさないため)。 */ -// OpenAI Audio Speech API が受け付けるボイス。女性寄りは nova / shimmer / coral / sage。 -const OPENAI_VOICES = new Set([ - 'alloy', - 'ash', - 'ballad', - 'coral', - 'echo', - 'fable', - 'nova', - 'onyx', - 'sage', - 'shimmer', - 'verse', -]); - -export const isOpenAiVoiceName = (voiceName: string): boolean => - OPENAI_VOICES.has(voiceName.trim().toLowerCase()); - -// 環境変数の設定ミス(Azure 時代の値の残留など)でも合成を落とさないための -// 最終フォールバック。ここは検証済みの定数なので必ず OpenAI が受理する。 -export const DEFAULT_TTS_VOICE = 'shimmer'; -export const DEFAULT_TTS_MODEL = 'gpt-4o-mini-tts'; +export type TtsLanguage = 'ja' | 'en'; /** - * 使用するボイス名を決める。 - * 優先順位: リクエスト指定 → KV の設定 → 環境変数の既定値。 - * 前二者は妥当なボイス名のときだけ採用する。 + * 使用を許すボイス名(`voices.list` で実在を確認済み)。 + * + * 形式(`<言語>-<地域>-<系統>-<記号>`)だけを検証すると `ja-US-Standard-A` や + * `ja-JP-Standard-Z` のような実在しない名前を通してしまい、Cloud TTS が + * 400("Voice ... does not exist")を返して放送そのものが落ちる。既定値へ倒す + * ためには実在する名前だけを許可する必要がある。 + * + * 系統は Standard / Wavenet / Neural2 に限定する。Android の端末内蔵 TTS と + * 同水準の音質を狙う系統で、Studio / Chirp3-HD / Gemini-TTS は単価が桁違いなので + * 名指しされても受け付けない。 + * + * ロケールは実際に使う ja-JP / en-US のみ。他ロケール(en-GB など)や Google が + * 後から追加したボイスを使うときは、`voices.list` で実在を確認してここへ足す。 */ -export const resolveOpenAiVoiceName = ( - requestedVoiceName: unknown, - configuredVoiceName: unknown, - defaultVoiceName: string -): string => { - const requested = - typeof requestedVoiceName === 'string' ? requestedVoiceName.trim() : ''; - if (requested && isOpenAiVoiceName(requested)) { - return requested.toLowerCase(); - } - - const configured = - typeof configuredVoiceName === 'string' ? configuredVoiceName.trim() : ''; - if (configured && isOpenAiVoiceName(configured)) { - return configured.toLowerCase(); - } +const ALLOWED_VOICES: Record> = { + ja: new Set([ + 'ja-JP-Standard-A', + 'ja-JP-Standard-B', + 'ja-JP-Standard-C', + 'ja-JP-Standard-D', + 'ja-JP-Wavenet-A', + 'ja-JP-Wavenet-B', + 'ja-JP-Wavenet-C', + 'ja-JP-Wavenet-D', + // Neural2 の ja-JP は A が無い + 'ja-JP-Neural2-B', + 'ja-JP-Neural2-C', + 'ja-JP-Neural2-D', + ]), + en: new Set([ + 'en-US-Standard-A', + 'en-US-Standard-B', + 'en-US-Standard-C', + 'en-US-Standard-D', + 'en-US-Standard-E', + 'en-US-Standard-F', + 'en-US-Standard-G', + 'en-US-Standard-H', + 'en-US-Standard-I', + 'en-US-Standard-J', + 'en-US-Wavenet-A', + 'en-US-Wavenet-B', + 'en-US-Wavenet-C', + 'en-US-Wavenet-D', + 'en-US-Wavenet-E', + 'en-US-Wavenet-F', + 'en-US-Wavenet-G', + 'en-US-Wavenet-H', + 'en-US-Wavenet-I', + 'en-US-Wavenet-J', + // Neural2 の en-US は B が無い + 'en-US-Neural2-A', + 'en-US-Neural2-C', + 'en-US-Neural2-D', + 'en-US-Neural2-E', + 'en-US-Neural2-F', + 'en-US-Neural2-G', + 'en-US-Neural2-H', + 'en-US-Neural2-I', + 'en-US-Neural2-J', + ]), +}; - // 環境変数由来の既定値も無検証で通さない。不正なら OpenAI が 400 を返し、 - // /tts 全体が失敗してしまうため、既知のボイスへ倒す。 - const fallback = defaultVoiceName?.trim() ?? ''; - return fallback && isOpenAiVoiceName(fallback) - ? fallback.toLowerCase() - : DEFAULT_TTS_VOICE; +// 環境変数の設定ミス(OpenAI 時代の "shimmer" の残留など)でも合成を落とさない +// ための最終フォールバック。ここは実在を確認済みの女性ボイス。 +export const DEFAULT_TTS_VOICE: Record = { + ja: 'ja-JP-Standard-B', + en: 'en-US-Standard-G', }; -// 合成に使ってよいモデル。クライアントの指定をそのまま OpenAI へ流すと、 -// 高額なモデルを名指しされて課金が膨らむため許可制にする。 -const TTS_MODELS = new Set(['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd']); +/** ボイス名がその言語向けの許可済みボイスか。 */ +export const isGoogleVoiceName = ( + voiceName: string, + language: TtsLanguage +): boolean => ALLOWED_VOICES[language].has(voiceName.trim()); -export const isTtsModel = (model: string): boolean => - TTS_MODELS.has(model.trim().toLowerCase()); +/** + * ボイス名からロケール(languageCode)を取り出す。Cloud TTS は voice.name と + * voice.languageCode の食い違いを 400 で弾くため、必ず名前から導出する。 + */ +export const languageCodeFromVoiceName = (voiceName: string): string => + voiceName.trim().split('-').slice(0, 2).join('-'); /** - * 使用するモデルを決める。ボイス名と同じく、リクエスト → KV 設定 → 環境変数の - * 順で、許可済みのモデル名のときだけ採用する。 + * 使用するボイス名を決める。 + * 優先順位: リクエスト指定 → KV の設定 → 環境変数の既定値。 + * いずれも「その言語向けの許可済みボイス」のときだけ採用する。 */ -export const resolveTtsModel = ( - requestedModel: unknown, - configuredModel: unknown, - defaultModel: string +export const resolveGoogleVoiceName = ( + requestedVoiceName: unknown, + configuredVoiceName: unknown, + defaultVoiceName: string | undefined, + language: TtsLanguage ): string => { - const requested = - typeof requestedModel === 'string' ? requestedModel.trim() : ''; - if (requested && isTtsModel(requested)) { - return requested.toLowerCase(); + const candidates = [ + requestedVoiceName, + configuredVoiceName, + defaultVoiceName, + ]; + for (const candidate of candidates) { + const value = typeof candidate === 'string' ? candidate.trim() : ''; + // 環境変数由来の既定値も無検証では通さない。不正なら Google が 400 を返し、 + // /tts 全体が失敗してしまうため、既知のボイスへ倒す。 + if (value && isGoogleVoiceName(value, language)) { + return value; + } } - - const configured = - typeof configuredModel === 'string' ? configuredModel.trim() : ''; - if (configured && isTtsModel(configured)) { - return configured.toLowerCase(); - } - - // ボイス名と同様、環境変数由来の既定値も検証してから採用する - const fallback = defaultModel?.trim() ?? ''; - return fallback && isTtsModel(fallback) - ? fallback.toLowerCase() - : DEFAULT_TTS_MODEL; + return DEFAULT_TTS_VOICE[language]; }; diff --git a/wrangler.jsonc b/wrangler.jsonc index 728076f..06a3d8e 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -42,14 +42,16 @@ "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", // --- TTS(/tts)--- - // gpt-4o-mini-tts は SSML 非対応。声色・速度・間の取り方は instructions で指示する。 - // 日英とも明るくはっきりした女性声にする。同じボイスでも多言語を読めるが、 - // 各言語で最も明瞭に聞こえるボイスを選ぶため日英で別のボイスを割り当てている。 - "TTS_MODEL": "gpt-4o-mini-tts", - "TTS_JA_VOICE_NAME": "shimmer", - "TTS_EN_VOICE_NAME": "coral", - "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、明るくはっきりとした女性の声で読み上げてください。トーンはやや高めに保ち、語尾まで明瞭に発音します。テンポはかなり速く、ラッシュ時の自動放送のように急いで一気に読み進めてください。句読点でも文の切れ目でもほとんど間を取らず、詰めて続けます。語尾や伸ばす音を長く引かないでください。速くても駅名や路線名は一語ずつくっきりと発音し、案内らしい張りのある調子にしてください。", - "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a bright, clear female voice. Keep the pitch slightly high and the delivery crisp and articulate. Speak fast — much quicker than normal narration, with the urgency of a rush-hour announcement — and push straight through without dragging. Take almost no pause at commas or sentence breaks, and do not draw out word endings. Even at that speed, pronounce station and line names distinctly, and sound professional and welcoming rather than flat.", + // Google Cloud TTS。ボイス名はロケール込みで、日英それぞれに指定する。 + // 既定は旧 Google TTS 実装(Firebase Functions 時代)と同じ組み合わせ。 + // Standard 系は端末内蔵 TTS と同水準の音質で単価も最安。より高品質にするなら + // 末尾記号はそのまま Wavenet / Neural2 へ差し替える(例: ja-JP-Wavenet-B)。 + // 読み方のプロンプト指示は Standard 系に無いため、速さは TTS_SPEED + // (speakingRate)、高さは TTS_PITCH で調整する。 + "TTS_JA_VOICE_NAME": "ja-JP-Standard-B", + "TTS_EN_VOICE_NAME": "en-US-Standard-G", + // 車内自動放送らしく早口寄りにする(1.0 が等速。旧実装は無指定=1.0 だった) + "TTS_SPEED": "1.15", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads-dev.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", @@ -78,7 +80,8 @@ // secrets(`wrangler secret put ` で投入。コミットしない): // SESSION_JWT_SECRET / GOOGLE_PLAY_SA_KEY / APPSTORE_CONNECT_KEY / // OCTOKIT_PAT / DISCORD_CS_WEBHOOK_URL / DISCORD_CRASH_WEBHOOK_URL / DISCORD_REVIEW_WEBHOOK_URL / - // ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_VERTEX_SA_KEY / LANGSMITH_API_KEY + // ANTHROPIC_API_KEY / OPENAI_API_KEY / GOOGLE_VERTEX_SA_KEY / LANGSMITH_API_KEY / + // GOOGLE_TTS_SA_KEY "env": { "production": { @@ -108,11 +111,10 @@ "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", // --- TTS(/tts)--- - "TTS_MODEL": "gpt-4o-mini-tts", - "TTS_JA_VOICE_NAME": "shimmer", - "TTS_EN_VOICE_NAME": "coral", - "TTS_INSTRUCTIONS_JA": "鉄道の車内自動放送のアナウンサーとして、明るくはっきりとした女性の声で読み上げてください。トーンはやや高めに保ち、語尾まで明瞭に発音します。やや速めの一定の速さで、句読点では短く間を取ります。駅名や路線名は一語ずつくっきりと発音し、案内らしい張りのある調子にしてください。", - "TTS_INSTRUCTIONS_EN": "Read this as an automated train announcement in a bright, clear female voice. Keep the pitch slightly high and the delivery crisp and articulate, at a steady, slightly brisk pace. Pause briefly at commas and pronounce station and line names distinctly. Sound professional and welcoming rather than flat.", + // Google Cloud TTS。本番は dev より控えめの「やや速め」に合わせる + "TTS_JA_VOICE_NAME": "ja-JP-Standard-B", + "TTS_EN_VOICE_NAME": "en-US-Standard-G", + "TTS_SPEED": "1.1", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", From 16a6518bde686c1294154fb80a119d04cc04de20 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 17 Aug 2026 16:42:37 +0900 Subject: [PATCH 05/16] =?UTF-8?q?=E6=9C=AC=E7=95=AA=E3=81=AE=20AGENT=5FMOD?= =?UTF-8?q?EL=20=E3=82=92=20Gemini=EF=BC=88Vertex=20AI=EF=BC=89=E3=81=B8?= =?UTF-8?q?=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88=E3=82=8B=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index 06a3d8e..d0e4527 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -121,7 +121,7 @@ "FEW_SHOT_LIMIT": "12", "FEW_SHOT_PER_EX_MAX": "800", // --- AI エージェント(/agent/chat)--- - "AGENT_MODEL": "openai:gpt-5.6-luna", + "AGENT_MODEL": "google:gemini-3.7-flash", "GOOGLE_VERTEX_LOCATION": "global", "AGENT_GATE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", "AGENT_FAQ_KV_KEY": "config:agent-faq", From 335bbf4721e9072b53faf1e4687ca617a74ba00c Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sat, 22 Aug 2026 18:21:19 +0900 Subject: [PATCH 06/16] =?UTF-8?q?staging=20=E3=81=AE=E9=A7=85=E6=A4=9C?= =?UTF-8?q?=E7=B4=A2=E3=82=92=20sapi-bff-stg=20=E3=81=8B=E3=82=89=20statio?= =?UTF-8?q?napi-stg=20=E3=81=B8=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88?= =?UTF-8?q?=E3=82=8B=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * staging の駅検索を sapi-bff-stg から stationapi-stg へ切り替える BFF 廃止に伴い、staging の Service Binding を stationapi-stg へ移す。 GraphQL のクエリとレスポンス構造は sapi-bff と同一(stationsByName / stationGroupStations とも引数・フィールド名が変わらない)ため、 クエリ側の処理には手を入れていない。 本番の route 移管はまだ済んでいないので、production は SAPI_BFF → sapi-bff のまま残し、STATION_API を優先して 未設定なら SAPI_BFF へ落ちる順序にした。 なお stationapi は GraphQL をサブドメイン直下(POST /)で受けるため、 STATION_API 経由の POST 先は /graphql ではなくルート直下にしている。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PM76omHQmBawZ3EfBsuU7z * STATION_API 経由のテストで POST であることも検証する CodeRabbit の指摘(PR #7)。テスト名は「サブドメイン直下へ POST する」 なのに URL しか見ておらず、method はスイート全体でも未検証だった。 リクエスト本文の variables は既存テストがカバー済みのため触っていない。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PM76omHQmBawZ3EfBsuU7z --------- Co-authored-by: Claude Opus 5 --- src/agent/tools.test.ts | 16 ++++++++++++++++ src/agent/tools.ts | 21 ++++++++++++++------- src/types.ts | 4 +++- wrangler.jsonc | 5 +++-- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/agent/tools.test.ts b/src/agent/tools.test.ts index 35c6d77..5ba0961 100644 --- a/src/agent/tools.test.ts +++ b/src/agent/tools.test.ts @@ -140,6 +140,22 @@ describe('searchStationsByName', () => { }); }); + it('STATION_API があれば優先し、サブドメイン直下へ POST する', async () => { + const stationApiFetch = jest + .fn() + .mockResolvedValue(gqlResponse([gqlStation(1)])); + const bffFetch = jest.fn(); + const env = { + STATION_API: { fetch: stationApiFetch }, + SAPI_BFF: { fetch: bffFetch }, + } as unknown as Env; + const result = await searchStationsByName(env, '鎌倉', undefined); + expect(result[0].stationId).toBe(1); + expect(bffFetch).not.toHaveBeenCalled(); + expect(stationApiFetch.mock.calls[0][0]).toBe('https://stationapi/'); + expect(stationApiFetch.mock.calls[0][1].method).toBe('POST'); + }); + it('失敗時に 1 回だけ再試行する', async () => { const fetchMock = jest .fn() diff --git a/src/agent/tools.ts b/src/agent/tools.ts index c6dabe4..9a2744c 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,7 +1,7 @@ /** - * 駅検索ツール — sapi-bff(BFF ルートワーカー)の GraphQL stationsByName で - * 駅名の実在確認を行う。Service Binding(SAPI_BFF)を優先し、 - * 未設定なら SAPI_BFF_GRAPHQL_URL への fetch にフォールバックする。 + * 駅検索ツール — stationapi の GraphQL stationsByName で + * 駅名の実在確認を行う。Service Binding(STATION_API、未移行の環境は SAPI_BFF)を + * 優先し、未設定なら SAPI_BFF_GRAPHQL_URL への fetch にフォールバックする。 * 検索結果は verified マップへ蓄積し、最終応答のサーバ側検証 * (validate.ts の sanitizeSuggestions)の突合元になる。 */ @@ -15,12 +15,12 @@ import { /** stationsByName へ渡す件数(設計値。全量を返すとツール結果でトークンを浪費する) */ const STATION_SEARCH_LIMIT = 10; -/** sapi-bff 呼び出しの 1 試行あたり期限 */ +/** 駅検索 API 呼び出しの 1 試行あたり期限 */ const TOOL_TIMEOUT_MS = 5_000; /** 1 ターン合計のツール呼び出し上限 */ export const MAX_TOOL_CALLS_PER_TURN = 5; /** - * ツール 1 回あたりの sapi-bff 呼び出し上限(表記ゆれ候補 + 一過性エラーの再試行の合計)。 + * ツール 1 回あたりの駅検索 API 呼び出し上限(表記ゆれ候補 + 一過性エラーの再試行の合計)。 * 1 試行 5 秒のため、全体 25 秒の予算内に収まる値にする。 */ const MAX_SEARCH_ATTEMPTS = 3; @@ -102,6 +102,11 @@ const postGraphQL = async ( body, signal, }; + if (env.STATION_API) { + // Service Binding はホスト名を解決しないため URL はダミーでよい。 + // stationapi は GraphQL をサブドメイン直下(POST /)で受ける + return env.STATION_API.fetch('https://stationapi/', init); + } if (env.SAPI_BFF) { // Service Binding はホスト名を解決しないため URL はダミーでよい return env.SAPI_BFF.fetch('https://sapi-bff/graphql', init); @@ -109,7 +114,9 @@ const postGraphQL = async ( if (env.SAPI_BFF_GRAPHQL_URL) { return fetch(env.SAPI_BFF_GRAPHQL_URL, init); } - throw new Error('SAPI_BFF binding or SAPI_BFF_GRAPHQL_URL is required'); + throw new Error( + 'STATION_API / SAPI_BFF binding or SAPI_BFF_GRAPHQL_URL is required' + ); }; const queryStationsOnce = async ( @@ -341,7 +348,7 @@ export interface StationSearchToolResult { } export interface StationSearchToolOptions { - /** 駅名 → 実在駅リスト(sapi-bff 呼び出し。テストでは差し替え可能) */ + /** 駅名 → 実在駅リスト(駅検索 API 呼び出し。テストでは差し替え可能) */ search: (name: string) => Promise; /** このターンで実在確認済みの駅(stationId → 駅)。突合検証の元データ */ verified: Map; diff --git a/src/types.ts b/src/types.ts index 5887ae9..6443733 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,9 @@ export interface Env { TTS_BUCKET: R2Bucket; UPLOAD_BUCKET: R2Bucket; FEEDBACK_QUEUE: Queue; - /** sapi-bff(BFF ルートワーカー)への Service Binding。エージェントの駅検索に使う */ + /** stationapi(GraphQL ワーカー)への Service Binding。エージェントの駅検索に使う */ + STATION_API?: Fetcher; + /** sapi-bff(BFF ルートワーカー)への Service Binding。stationapi 未移行の環境用 */ SAPI_BFF?: Fetcher; // --- Vars(非機密。wrangler.jsonc の vars) --- diff --git a/wrangler.jsonc b/wrangler.jsonc index d0e4527..162dcd7 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -35,8 +35,9 @@ ] }, - // エージェントの駅検索(stationsByName)は同一アカウントの sapi-bff を Service Binding で呼ぶ - "services": [{ "binding": "SAPI_BFF", "service": "sapi-bff-stg" }], + // エージェントの駅検索(stationsByName)は同一アカウントの stationapi を Service Binding で呼ぶ。 + // staging は BFF 廃止に伴い stationapi-stg へ移行済み(本番は sapi-bff のまま) + "services": [{ "binding": "STATION_API", "service": "stationapi-stg" }], "vars": { "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", From d7dcbb3f35666b8b778521c6d5788317ec409ac1 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sun, 23 Aug 2026 14:45:29 +0900 Subject: [PATCH 07/16] =?UTF-8?q?=E6=9C=AC=E7=95=AA=E3=81=AE=E9=A7=85?= =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E3=82=92=20sapi-bff=20=E3=81=8B=E3=82=89=20s?= =?UTF-8?q?tationapi=20=E3=81=B8=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88?= =?UTF-8?q?=E3=82=8B=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 本番の駅検索を sapi-bff から stationapi へ切り替える BFF 廃止に伴い、production の Service Binding を SAPI_BFF → sapi-bff から STATION_API → stationapi へ移す。staging(#7)と揃い、両環境とも stationapi を直に叩く構成になったため、SAPI_BFF への分岐は不要になった。 postGraphQL から SAPI_BFF 分岐を落とし、Service Binding 不使用時の フォールバック環境変数も SAPI_BFF_GRAPHQL_URL から STATION_API_GRAPHQL_URL へ改名する(wrangler.jsonc では未設定のため リネームによる実害はない)。 分岐が消えたことで「STATION_API があれば優先し、サブドメイン直下へ POST する」テストは意味を失うので削除するが、stationapi が GraphQL を POST / でのみ受けるという前提の検証は残す必要があるため、POST 先 URL と method のアサーションは既存の Service Binding テストへ畳み込んでいる。 なお Service Binding は対象 Worker が存在しないとデプロイに失敗するため、 stationapi(production)のデプロイ完了後にこの変更をデプロイし、 BFF の削除はその後に行うこと。 Closes #8 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011sXt1cqwoy71GakDQMGC3G * fix: apply CodeRabbit auto-fixes STATION_API と STATION_API_GRAPHQL_URL の優先順位を回帰テストで固定する。 SAPI_BFF 分岐の削除に伴い「STATION_API があれば優先」テストを落としたが、 残る 2 分岐(Service Binding / URL フォールバック)の順序を確かめる テストが無くなっていた。既存テストは片方だけを設定するため、postGraphQL の 分岐順を入れ替えても全て通ってしまう。 グローバル fetch はスパイに実装を持たせ、分岐順が壊れた場合でも実 ネットワークへ出ないようにする(jest.config.js に restoreMocks が無いため finally で明示的に戻す)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011sXt1cqwoy71GakDQMGC3G --------- Co-authored-by: Claude Opus 5 --- src/agent/tools.test.ts | 42 ++++++++++++++++++++++++++--------------- src/agent/tools.ts | 16 +++++----------- src/types.ts | 6 ++---- wrangler.jsonc | 4 ++-- 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/agent/tools.test.ts b/src/agent/tools.test.ts index 5ba0961..6fa3d57 100644 --- a/src/agent/tools.test.ts +++ b/src/agent/tools.test.ts @@ -115,7 +115,7 @@ describe('buildStationNameVariants', () => { describe('searchStationsByName', () => { const makeEnv = (fetchImpl: jest.Mock): Env => - ({ SAPI_BFF: { fetch: fetchImpl } }) as unknown as Env; + ({ STATION_API: { fetch: fetchImpl } }) as unknown as Env; const queriedNames = (fetchMock: jest.Mock): string[] => fetchMock.mock.calls.map( @@ -138,22 +138,34 @@ describe('searchStationsByName', () => { limit: 10, fromStationGroupId: 1130205, }); + // stationapi は GraphQL をサブドメイン直下(POST /)でのみ受ける + expect(fetchMock.mock.calls[0][0]).toBe('https://stationapi/'); + expect(init.method).toBe('POST'); }); - it('STATION_API があれば優先し、サブドメイン直下へ POST する', async () => { - const stationApiFetch = jest + it('STATION_API があれば STATION_API_GRAPHQL_URL より優先する', async () => { + const bindingFetch = jest .fn() .mockResolvedValue(gqlResponse([gqlStation(1)])); - const bffFetch = jest.fn(); - const env = { - STATION_API: { fetch: stationApiFetch }, - SAPI_BFF: { fetch: bffFetch }, - } as unknown as Env; - const result = await searchStationsByName(env, '鎌倉', undefined); - expect(result[0].stationId).toBe(1); - expect(bffFetch).not.toHaveBeenCalled(); - expect(stationApiFetch.mock.calls[0][0]).toBe('https://stationapi/'); - expect(stationApiFetch.mock.calls[0][1].method).toBe('POST'); + // 分岐順が入れ替わっても実ネットワークへ出ないようにしておく + const globalFetch = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(gqlResponse([gqlStation(99)])); + try { + const result = await searchStationsByName( + { + STATION_API: { fetch: bindingFetch }, + STATION_API_GRAPHQL_URL: 'https://gql.example/graphql', + } as unknown as Env, + '鎌倉', + undefined + ); + expect(result[0].stationId).toBe(1); + expect(bindingFetch.mock.calls[0][0]).toBe('https://stationapi/'); + expect(globalFetch).not.toHaveBeenCalled(); + } finally { + globalFetch.mockRestore(); + } }); it('失敗時に 1 回だけ再試行する', async () => { @@ -197,7 +209,7 @@ describe('searchStationsByName', () => { it('バインディングも URL も無ければエラー', async () => { await expect( searchStationsByName({} as unknown as Env, '海', undefined) - ).rejects.toThrow('SAPI_BFF'); + ).rejects.toThrow('STATION_API'); }); it('0 件なら表記ゆれ候補で引き直す(分かち書きローマ字の救済)', async () => { @@ -276,7 +288,7 @@ describe('searchStationsByName', () => { describe('fetchStationByGroupId', () => { const makeEnv = (fetchImpl: jest.Mock): Env => - ({ SAPI_BFF: { fetch: fetchImpl } }) as unknown as Env; + ({ STATION_API: { fetch: fetchImpl } }) as unknown as Env; const groupResponse = (stations: unknown[]) => new Response(JSON.stringify({ data: { stationGroupStations: stations } }), { diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 9a2744c..cfe9aa2 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,7 +1,7 @@ /** * 駅検索ツール — stationapi の GraphQL stationsByName で - * 駅名の実在確認を行う。Service Binding(STATION_API、未移行の環境は SAPI_BFF)を - * 優先し、未設定なら SAPI_BFF_GRAPHQL_URL への fetch にフォールバックする。 + * 駅名の実在確認を行う。Service Binding(STATION_API)を優先し、 + * 未設定なら STATION_API_GRAPHQL_URL への fetch にフォールバックする。 * 検索結果は verified マップへ蓄積し、最終応答のサーバ側検証 * (validate.ts の sanitizeSuggestions)の突合元になる。 */ @@ -107,16 +107,10 @@ const postGraphQL = async ( // stationapi は GraphQL をサブドメイン直下(POST /)で受ける return env.STATION_API.fetch('https://stationapi/', init); } - if (env.SAPI_BFF) { - // Service Binding はホスト名を解決しないため URL はダミーでよい - return env.SAPI_BFF.fetch('https://sapi-bff/graphql', init); + if (env.STATION_API_GRAPHQL_URL) { + return fetch(env.STATION_API_GRAPHQL_URL, init); } - if (env.SAPI_BFF_GRAPHQL_URL) { - return fetch(env.SAPI_BFF_GRAPHQL_URL, init); - } - throw new Error( - 'STATION_API / SAPI_BFF binding or SAPI_BFF_GRAPHQL_URL is required' - ); + throw new Error('STATION_API binding or STATION_API_GRAPHQL_URL is required'); }; const queryStationsOnce = async ( diff --git a/src/types.ts b/src/types.ts index 6443733..79556db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,8 +13,6 @@ export interface Env { FEEDBACK_QUEUE: Queue; /** stationapi(GraphQL ワーカー)への Service Binding。エージェントの駅検索に使う */ STATION_API?: Fetcher; - /** sapi-bff(BFF ルートワーカー)への Service Binding。stationapi 未移行の環境用 */ - SAPI_BFF?: Fetcher; // --- Vars(非機密。wrangler.jsonc の vars) --- GOOGLE_PLAY_PACKAGE_NAME: string; @@ -44,8 +42,8 @@ export interface Env { GOOGLE_VERTEX_PROJECT?: string; /** Vertex AI のロケーション(既定 "global"。例: asia-northeast1) */ GOOGLE_VERTEX_LOCATION?: string; - /** Service Binding 不使用時の sapi-bff GraphQL エンドポイント */ - SAPI_BFF_GRAPHQL_URL?: string; + /** Service Binding 不使用時の stationapi GraphQL エンドポイント */ + STATION_API_GRAPHQL_URL?: string; /** "true" で LangSmith トレーシングを有効化(dev 環境のみ設定すること) */ LANGSMITH_TRACING?: string; diff --git a/wrangler.jsonc b/wrangler.jsonc index 162dcd7..fdf6f13 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -36,7 +36,7 @@ }, // エージェントの駅検索(stationsByName)は同一アカウントの stationapi を Service Binding で呼ぶ。 - // staging は BFF 廃止に伴い stationapi-stg へ移行済み(本番は sapi-bff のまま) + // BFF 廃止に伴い staging・本番とも stationapi へ移行済み "services": [{ "binding": "STATION_API", "service": "stationapi-stg" }], "vars": { @@ -107,7 +107,7 @@ { "queue": "feedback-triage", "max_batch_size": 5, "max_retries": 3 } ] }, - "services": [{ "binding": "SAPI_BFF", "service": "sapi-bff" }], + "services": [{ "binding": "STATION_API", "service": "stationapi" }], "vars": { "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", From 737001856a717b33d94e599bf79fa24293983e94 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 27 Aug 2026 13:11:00 +0900 Subject: [PATCH 08/16] =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E3=82=92=E5=8E=9F=E5=9B=A0=E3=83=AA?= =?UTF-8?q?=E3=83=9D=E3=82=B8=E3=83=88=E3=83=AA=E3=81=B8=E6=8C=AF=E3=82=8A?= =?UTF-8?q?=E5=88=86=E3=81=91=E3=80=81=E3=83=88=E3=83=AA=E3=82=A2=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=81=AE=E8=B5=B7=E7=A5=A8=E5=93=81=E8=B3=AA=E3=82=92?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=81=99=E3=82=8B=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * フィードバックを原因リポジトリへ振り分け、トリアージの起票品質を改善する 原因が特定できたフィードバックを該当する公開リポジトリにも起票できるようにし、 あわせて Issue #11 で報告された Spam 誤判定とタイトル破損を修正する。 ## 公開リポジトリへの振り分け - トリアージに原因コンポーネントの判定(component / componentConfidence)を追加 - component が特定でき信頼度 0.7 以上なら、TrainLCD/MobileApp・StationAPI・ Functions・Website のいずれかにスタブ Issue を起票する - 公開リポジトリなのでフィードバックの内容は一切載せず、管理 Issue 番号と チケットID だけを持たせる。管理 Issue 側にもコメントで相互リンクを残す - クラッシュ・スパム・スパム疑い・質問・称賛・トリアージ失敗は振り分けない - 追跡のため、管理 Issue の本文にもチケットID を追加 ## Spam 誤判定の修正(不具合A) - looksLikeSpam の「停車駅/方面」「駅名・路線名の併記」への単独加点を撤回し、 放送定型句との共起を必須にした。本アプリのドメイン語彙そのものであり、 正確な報告ほどスパム判定される構造だったため - 「違います」「反映されない」「ほしい」など、報告で頻出する言い回しを追加 - モデルの非スパム判定をヒューリスティックが無条件に上書きするのをやめ、 モデルの confidence が高い場合は分類を維持して ❓ Unknown Type で人手確認に回す - 感謝・称賛を 💩 Spam に分類していたのをやめ、praise カテゴリ(💚 Praise)を新設 ## タイトル破損の修正(不具合B) - AI_TRIAGE_MODEL を @cf/google/gemma-4-26b-a4b-it に変更(日本語生成品質) - gemma-4 は response を返さず choices[0].message.content のみのため、 両形式を受ける pickModelResponse を追加。従来の取り出し方だと全件失敗する - 推論トレースで JSON が途中で切れる(finish_reason: length)ため max_tokens を 2048 に - category / triageLevel / component を JSON Schema の required に追加。 optional だったため、モデルが省略した非スパムが軒並み question に落ちていた - 未取得・文字化け・生成ループ・助詞連続などの破損タイトルを検知し、 要約失敗マーカー付きで起票する。破損タイトルを要約へ伝播させない - 破損の理由とモデル名をログに残し、破損率を計測できるようにした Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013z8ZwcVqMewc7fZx2gAzxF * fix: CodeRabbit 指摘対応(助詞連続の誤検知・README の条件・推論例外の握り) - particle_run が「のでは」「ものには」など正常な日本語を破損と誤検知していた。 誤検知するとトリアージ結果ごと破棄されるため、同一助詞の 3 連続に限定する - 公開リポジトリへの起票条件を README と実装で一致させる(reportType・ トリアージ成否・スパム疑い・対象カテゴリ・信頼度のすべてを明記) - env.AI.run の例外を試行ループ内で捕捉する。JSON Mode を満たせない場合や AI 側の一時障害で throw すると queue の再試行を使い切ってフィードバックが 消えるため、生成失敗として扱い「要約失敗」で起票して原文を残す なお AI_TRIAGE_MODEL の JSON Mode 非対応の指摘は、実機検証で反証済みのため モデルは変更しない(詳細は PR のレビュー返信を参照)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013z8ZwcVqMewc7fZx2gAzxF * fix: 信頼度を 0..1 に制限する モデルが "90"(パーセント表記のつもり)や負値を返した場合、componentConfidence が そのまま閾値判定を通過し、公開リポジトリへ内容を出すべきでないフィードバックが 起票されうる状態だった。 - TRIAGE_JSON_SCHEMA の confidence / componentConfidence に minimum: 0 と maximum: 1 を追加 - coerceReport でも範囲を検証し、範囲外は値を信用せず既定値へ倒す (componentConfidence は 0 = 公開起票しない、confidence は 0.5 = 従来の既定) 制約追加後も gemma-4 が JSON Mode を満たすことは実機で確認済み。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013z8ZwcVqMewc7fZx2gAzxF * fix: 信頼度は数値のみ受け付ける Number() 任せの変換だと componentConfidence: true や [1] が 1 に化け、 component と対象カテゴリが揃うと公開リポジトリへの起票条件を通過してしまう。 数値、または数値だけの文字列に限定し、それ以外は既定値へ倒す。 - boolean・配列・オブジェクト・null・空文字は既定値(componentConfidence は 0) - 範囲検証のテストで category を省いていたため question 扱いで弾かれ、 信頼度の検証に到達していなかった。category: 'bug' を明示して実際に 信頼度のガードを検証するようにした(ガードを外すと落ちることを確認済み) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013z8ZwcVqMewc7fZx2gAzxF * few-shot に functions / website の例を追加し、唯一例の抽出漏れを防ぐ 原因コンポーネントの判定を入れたが、few-shot に functions / website の例が 1 件も無く、この 2 リポジトリへは実質振り分けられない状態だった。両方の例を 追加する。 few-shot は毎回 FEW_SHOT_LIMIT 件をランダム抽出するため、カテゴリや コンポーネント唯一の例(praise / functions / website)は抽出から漏れると モデルがその値を出さなくなる。weight を付けて残りやすくし、あわせて FEW_SHOT_LIMIT を 12 から 16 に引き上げる。README に weight / disabled と FEW_SHOT_LIMIT の関係を追記した。 KV の実データ(dev / production)にも同じ方針で再投入済み。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H4r9HW8s5VHfRoFEJN8bAU --------- Co-authored-by: Claude Opus 5 --- README.md | 87 +++- fewshot.example.jsonl | 10 +- src/consumers/feedbackTriage.test.ts | 435 ++++++++++++++++++- src/consumers/feedbackTriage.ts | 601 +++++++++++++++++++++++---- src/models/ai.ts | 18 + wrangler.jsonc | 12 +- 6 files changed, 1079 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 7ff9293..c559661 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ single Worker. - **Feedback intake** (`POST /postFeedback`): enqueues feedback onto the triage queue. - **Image upload** (`POST /feedback/upload-image`): stores feedback images in R2 and returns a public URL. - **App config delivery** (`GET /config/maintenance`, `GET /config/remote`): maintenance status and GPS thresholds (the replacement for Remote Config). -- **Feedback triage** (queue `feedback-triage`): summarizes and classifies feedback with Workers AI, then creates a GitHub Issue and notifies Discord. +- **Feedback triage** (queue `feedback-triage`): summarizes and classifies feedback with Workers AI, then creates a GitHub Issue and notifies Discord. Actionable feedback whose root-cause component the AI identifies confidently also gets a linked stub Issue in the matching public repo (see [public repo routing](#public-repo-routing) for the exact conditions). - **TTS cache writes**: synthesized audio is written directly from the `/tts` handler to R2 + KV (no queue is used, because audio does not fit within the 128 KB Queues limit). - **Review notifications** (Cron, hourly): notifies Discord of new App Store / Google Play reviews. @@ -202,6 +202,16 @@ example per line (see `fewshot.example.jsonl`): {"input": "user body text", "output": "{\"title\":...,\"isSpam\":false,...}"} ``` +The `output` of each example must include `component` / `componentConfidence` +as well; the model imitates the examples, so examples without those fields make +it omit them and public repo routing never fires. + +Optional per-example fields: `weight` (a value above 1 makes the example more +likely to survive sampling) and `disabled: true` (skips the line). Only +`FEW_SHOT_LIMIT` examples are sampled per request, so a component or category +with a single example — `praise`, `functions`, `website` — must carry a +`weight`, otherwise it drops out of the prompt and the model never produces it. + Upload (the file is stored verbatim as a single KV value): ```bash @@ -214,6 +224,81 @@ wrangler kv key put --binding CONFIG_KV "config:fewshot" --path fewshot.jsonl -- If it is not present, triage fails hard with `FEW_SHOT_NOT_AVAILABLE` (a fail-hard guard that prevents mis-training). +## Triage safeguards + +Two guards keep bad triage output from degrading the backlog +(`src/consumers/feedbackTriage.ts`): + +- **Gratitude is never spam.** Praise-only feedback gets `category: "praise"` + (`💚 Praise`, P3) instead of `💩 Spam` — there is nothing to fix, but it is + still a real message from a real user. Spam means content unrelated to + improving the app: announcement transcripts, unrelated chit-chat, ads. +- **Spam heuristic is advisory, not authoritative.** `looksLikeSpam()` only + scores announcement-transcript signals when an actual announcement phrase is + present — "停車駅" / "方面" / station enumerations are core domain vocabulary + and appear in legitimate data reports. When the heuristic and the model + disagree and the model is confident (`confidence` ≥ + `SPAM_OVERRIDE_MAX_CONFIDENCE`), the model wins and the Issue is tagged + `❓ Unknown Type` for a human check instead of being buried as spam. +- **Broken titles never reach the backlog.** `findBrokenTitleReason()` rejects + titles that are missing, mojibake, foreign-script, looping, or a run of + particles. Those are filed with the `要約失敗` marker plus `❓ Unknown Type`, + and a `console.warn` records the reason so the corruption rate can be measured + with `wrangler tail`. A broken title is never propagated into the summary. + +`AI_TRIAGE_MODEL` therefore needs decent Japanese generation quality — it writes +the Issue title and summary. Before switching it, verify three things against the +real prompt (`wrangler dev` with the AI binding hits the live API even locally): + +1. **JSON schema mode is supported** — `wrangler ai models schema ` must + list `response_format` / `json_schema`. +2. **The response shape** — some models return `response`, others only + `choices[0].message.content`. `pickModelResponse()` accepts both; a model + returning neither would fail every message. +3. **`max_tokens` headroom** — reasoning models spend most of the budget on the + trace before the JSON. Watch for `finish_reason: "length"`, which truncates + the JSON and looks like a parse failure. + +Measured with the real prompt + few-shot (2026-08): `@cf/google/gemma-4-26b-a4b-it` +completes in 5–17 s at 26–62 neurons per feedback, versus ~1 s and ~4.7 neurons +for the old 8B model. Queue consumers use `max_batch_size: 5`, so a batch stays +well inside the invocation limit. + +## Public repo routing + +Feedback Issues are always created in the private `TrainLCD/Issues` repo with +the full report (original text, device info, reporter UID, stacktrace, image). + +On top of that, the worker opens a stub Issue in the matching public repo — but +only when **every** condition in `resolvePublicIssueRepo()` holds: + +1. `reportType` is `feedback` (crash reports are never routed — their stacktraces + are not vetted for public disclosure), +2. triage succeeded (`triageFailed === false`) and the report is not spam, +3. the heuristic did not flag it for human review (`needsSpamReview !== true`), +4. `category` is one of `PUBLIC_ISSUE_CATEGORIES` — `bug`, `improvement`, + `feature_request` (so `question` and `praise` are excluded), and +5. `component` ≠ unknown with `componentConfidence` ≥ + `PUBLIC_ISSUE_MIN_CONFIDENCE` (0.7). + +The component then selects the repo: + +| `component` | repo | +| ------------- | -------------------- | +| `mobile_app` | `TrainLCD/MobileApp` | +| `station_api` | `TrainLCD/StationAPI`| +| `functions` | `TrainLCD/Functions` | +| `website` | `TrainLCD/Website` | + +Because those repos are public, the stub carries **no feedback content at all** — +no original text, no AI summary or title, no device info. It only links back to +the private ticket (`TrainLCD/Issues#` and the ticket ID), and the +private Issue gets a comment pointing at the public one so both sides are +traceable. + +`OCTOKIT_PAT` therefore needs write access to those four repos on top of +`TrainLCD/Issues`. + ## Maintenance CLI Maintenance tools that operate on KV (TTS_KV) and R2 (the audio bucket). Both diff --git a/fewshot.example.jsonl b/fewshot.example.jsonl index 4f1eac6..f4d0018 100644 --- a/fewshot.example.jsonl +++ b/fewshot.example.jsonl @@ -1,3 +1,7 @@ -{"input": "電車の音声案内が途中で止まってしまうことがあります。Androidの最新版です。", "output": "{\"title\": \"自動アナウンスが途中で停止する不具合\", \"summary\": \"オートモード中に自動音声が途中で再生されなくなる事象の報告。Android端末で発生。\", \"isSpam\": false, \"labels\": [\"bug\"], \"category\": \"bug\", \"triageLevel\": \"high\", \"confidence\": 0.8, \"reason\": \"特定機能(TTS)が使えない不具合\"}"} -{"input": "路線図にダークモードがほしいです。", "output": "{\"title\": \"路線図のダークモード対応要望\", \"summary\": \"路線図表示にダークモードを追加してほしいという新機能要望。\", \"isSpam\": false, \"labels\": [\"feature\", \"ui\"], \"category\": \"feature_request\", \"triageLevel\": \"low\", \"confidence\": 0.7, \"reason\": \"新規機能の要望\"}"} -{"input": "次は東京、東京です。お出口は左側です。ご利用ありがとうございます。", "output": "{\"title\": \"内容未分類(改善要望なし)\", \"summary\": \"\", \"isSpam\": true, \"labels\": [], \"category\": \"question\", \"triageLevel\": \"low\", \"confidence\": 0.9, \"reason\": \"車内放送の書き起こしでアクション不能\"}"} +{"input": "電車の音声案内が途中で止まってしまうことがあります。Androidの最新版です。", "output": "{\"title\": \"自動アナウンスが途中で停止する不具合\", \"summary\": \"オートモード中に自動音声が途中で再生されなくなる事象の報告。Android端末で発生。\", \"isSpam\": false, \"labels\": [\"bug\"], \"category\": \"bug\", \"triageLevel\": \"high\", \"component\": \"mobile_app\", \"componentConfidence\": 0.6, \"confidence\": 0.8, \"reason\": \"特定機能(TTS)が使えない不具合。再生側か合成側か切り分け不能\"}"} +{"input": "路線図にダークモードがほしいです。", "output": "{\"title\": \"路線図のダークモード対応要望\", \"summary\": \"路線図表示にダークモードを追加してほしいという新機能要望。\", \"isSpam\": false, \"labels\": [\"feature\", \"ui\"], \"category\": \"feature_request\", \"triageLevel\": \"low\", \"component\": \"mobile_app\", \"componentConfidence\": 0.9, \"confidence\": 0.7, \"reason\": \"アプリ画面の新規機能要望\"}"} +{"input": "〇〇線の△△駅が検索しても出てきません。駅名の表記も間違っています。", "output": "{\"title\": \"特定駅が検索に出ず駅名表記も誤り\", \"summary\": \"特定路線の駅が検索結果に表示されず、駅名の表記も誤っているという報告。\", \"isSpam\": false, \"labels\": [\"bug\", \"location\"], \"category\": \"bug\", \"triageLevel\": \"high\", \"component\": \"station_api\", \"componentConfidence\": 0.9, \"confidence\": 0.85, \"reason\": \"駅データそのものの欠落・誤りで原因はデータ提供側\"}"} +{"input": "アプリの中のAIに乗り換えを聞いても「エラーが発生しました」としか返ってきません。何度やり直しても同じです。", "output": "{\"title\": \"AIチャットが常にエラーを返し応答しない\", \"summary\": \"アプリ内のAIチャットに乗り換えを質問すると毎回エラーが返り、回答が得られない。再試行しても同様。\", \"isSpam\": false, \"labels\": [\"bug\", \"network\"], \"category\": \"bug\", \"triageLevel\": \"high\", \"component\": \"functions\", \"componentConfidence\": 0.8, \"confidence\": 0.8, \"reason\": \"AI応答APIが毎回エラーで原因はバックエンド側\"}", "weight": 2} +{"input": "公式サイトのプライバシーポリシーのリンクを踏むと404になります。ご確認ください。", "output": "{\"title\": \"公式サイトのプライバシーポリシーが404になる\", \"summary\": \"公式サイト上のプライバシーポリシーへのリンクが404を返し、内容を閲覧できない。\", \"isSpam\": false, \"labels\": [\"bug\"], \"category\": \"bug\", \"triageLevel\": \"medium\", \"component\": \"website\", \"componentConfidence\": 0.9, \"confidence\": 0.85, \"reason\": \"trainlcd.app 上のリンク切れでアプリ本体とは無関係\"}", "weight": 2} +{"input": "いつも使わせてもらってます。おかげで乗り過ごさなくなりました。本当にありがとうございます!", "output": "{\"title\": \"アプリへの感謝の声\", \"summary\": \"日常的に利用しており、乗り過ごしが減ったという感謝の声。対応すべき要望は含まれない。\", \"isSpam\": false, \"labels\": [], \"category\": \"praise\", \"triageLevel\": \"low\", \"component\": \"unknown\", \"componentConfidence\": 0, \"confidence\": 0.9, \"reason\": \"感謝のみで対応不要だが、利用者からの正当なフィードバック\"}", "weight": 2} +{"input": "次は東京、東京です。お出口は左側です。ご利用ありがとうございます。", "output": "{\"title\": \"内容未分類(改善要望なし)\", \"summary\": \"\", \"isSpam\": true, \"labels\": [], \"category\": \"question\", \"triageLevel\": \"low\", \"component\": \"unknown\", \"componentConfidence\": 0, \"confidence\": 0.9, \"reason\": \"車内放送の書き起こしでアクション不能\"}"} diff --git a/src/consumers/feedbackTriage.test.ts b/src/consumers/feedbackTriage.test.ts index 7520ae4..a2fd3cd 100644 --- a/src/consumers/feedbackTriage.test.ts +++ b/src/consumers/feedbackTriage.test.ts @@ -1,8 +1,20 @@ +import type { AIReport } from '../models/ai'; import { + applySpamHeuristic, buildFailedReport, + buildPublicIssueBody, + buildPublicIssueTitle, coerceReport, extractReportJson, + findBrokenTitleReason, + isUnusableTitle, looksLikeSpam, + MISSING_TITLE, + NON_ACTIONABLE_TITLE, + PUBLIC_ISSUE_MIN_CONFIDENCE, + pickModelResponse, + resolvePublicIssueRepo, + SPAM_OVERRIDE_MAX_CONFIDENCE, TRIAGE_FAILED_SUMMARY, } from './feedbackTriage'; @@ -102,8 +114,14 @@ describe('coerceReport', () => { it('falls back to the default title when both title and summary are empty', () => { const r = coerceReport({}); - expect(r.title).toBe('要約未取得'); - expect(r.summary).toBe('要約未取得'); + expect(r.title).toBe(MISSING_TITLE); + // タイトル未取得を要約に伝播させると両方が同時に無意味になるため、失敗を明示する + expect(r.summary).toBe(TRIAGE_FAILED_SUMMARY); + }); + + it('does not propagate a broken title into the summary', () => { + const r = coerceReport({ title: 'ををををを', summary: '' }); + expect(r.summary).toBe(TRIAGE_FAILED_SUMMARY); }); it('supports question and improvement synonyms', () => { @@ -112,6 +130,14 @@ describe('coerceReport', () => { 'improvement' ); }); + + it('感謝・称賛は praise として扱う(スパムに落とさない)', () => { + for (const raw of ['praise', 'Thanks', ' compliment ', 'gratitude']) { + const r = coerceReport({ title: 't', summary: 's', category: raw }); + expect(r.category).toBe('praise'); + expect(r.isSpam).toBe(false); + } + }); }); describe('looksLikeSpam', () => { @@ -198,3 +224,408 @@ describe('buildFailedReport', () => { expect(r.summary).toBe(TRIAGE_FAILED_SUMMARY); }); }); + +describe('coerceReport(原因コンポーネント)', () => { + it('canonical な component と信頼度を読み取る', () => { + const r = coerceReport({ + title: 't', + summary: 's', + category: 'bug', + component: 'station_api', + componentConfidence: 0.9, + }); + expect(r.component).toBe('station_api'); + expect(r.componentConfidence).toBe(0.9); + }); + + it('表記ゆれを正規化する', () => { + expect(coerceReport({ component: 'MobileApp' }).component).toBe( + 'mobile_app' + ); + expect(coerceReport({ component: ' iOS ' }).component).toBe('mobile_app'); + expect(coerceReport({ component: 'Web' }).component).toBe('website'); + expect(coerceReport({ component: 'workers' }).component).toBe('functions'); + }); + + it('unknown・未知の値・欠落は null にする', () => { + expect(coerceReport({ component: 'unknown' }).component).toBeNull(); + expect(coerceReport({ component: 'なにか' }).component).toBeNull(); + expect(coerceReport({}).component).toBeNull(); + }); + + it('component が特定できないときは信頼度を 0 に落とす', () => { + const r = coerceReport({ component: 'unknown', componentConfidence: 0.95 }); + expect(r.component).toBeNull(); + expect(r.componentConfidence).toBe(0); + }); + + it('component はあるが信頼度が欠落しているときは 0 とみなす', () => { + const r = coerceReport({ component: 'functions' }); + expect(r.componentConfidence).toBe(0); + }); + + it('0..1 の範囲外の信頼度は信用せず既定値に倒す', () => { + // パーセント表記(90)や負値は、そのまま通すと閾値判定をすり抜けて + // 公開リポジトリへ起票されてしまう + for (const bad of [90, 1.2, -0.5]) { + const r = coerceReport({ + component: 'station_api', + // category を省くと question 扱いになり、信頼度に到達する前に弾かれてしまう + category: 'bug', + componentConfidence: bad, + confidence: bad, + }); + expect(r.componentConfidence).toBe(0); + expect(r.confidence).toBe(0.5); + expect( + resolvePublicIssueRepo(r, { + reportType: 'feedback', + triageFailed: false, + }) + ).toBeNull(); + } + }); + + it('数値でない信頼度は既定値に倒す(Number() の型強制を通さない)', () => { + // Number(true) === 1、Number([1]) === 1 なので、型で絞らないと閾値を通過する + for (const bad of [true, [1], null, '', ' ', {}]) { + const r = coerceReport({ + component: 'station_api', + category: 'bug', + componentConfidence: bad, + confidence: bad, + }); + expect(r.componentConfidence).toBe(0); + expect(r.confidence).toBe(0.5); + expect( + resolvePublicIssueRepo(r, { + reportType: 'feedback', + triageFailed: false, + }) + ).toBeNull(); + } + }); + + it('数値だけの文字列は受け付ける', () => { + const r = coerceReport({ + component: 'station_api', + category: 'bug', + componentConfidence: '0.9', + }); + expect(r.componentConfidence).toBe(0.9); + }); + + it('境界値の 0 と 1 は受け付ける', () => { + expect( + coerceReport({ component: 'website', componentConfidence: 1 }) + .componentConfidence + ).toBe(1); + expect( + coerceReport({ component: 'website', componentConfidence: 0 }) + .componentConfidence + ).toBe(0); + }); +}); + +const baseReport = (overrides: Partial = {}): AIReport => ({ + title: 'タイトル', + summary: 'サマリ', + isSpam: false, + labels: [], + confidence: 0.9, + reason: 'reason', + category: 'bug', + triageLevel: 'high', + component: 'mobile_app', + componentConfidence: 0.9, + ...overrides, +}); + +describe('resolvePublicIssueRepo', () => { + const opts = { reportType: 'feedback' as const, triageFailed: false }; + + it('原因コンポーネントに対応する公開リポジトリを返す', () => { + expect(resolvePublicIssueRepo(baseReport(), opts)).toBe( + 'TrainLCD/MobileApp' + ); + expect( + resolvePublicIssueRepo(baseReport({ component: 'station_api' }), opts) + ).toBe('TrainLCD/StationAPI'); + expect( + resolvePublicIssueRepo(baseReport({ component: 'functions' }), opts) + ).toBe('TrainLCD/Functions'); + expect( + resolvePublicIssueRepo(baseReport({ component: 'website' }), opts) + ).toBe('TrainLCD/Website'); + }); + + it('改善・要望も対象にする', () => { + expect( + resolvePublicIssueRepo(baseReport({ category: 'improvement' }), opts) + ).toBe('TrainLCD/MobileApp'); + expect( + resolvePublicIssueRepo(baseReport({ category: 'feature_request' }), opts) + ).toBe('TrainLCD/MobileApp'); + }); + + it('原因が特定できていなければ起票しない', () => { + expect( + resolvePublicIssueRepo( + baseReport({ component: null, componentConfidence: 0 }), + opts + ) + ).toBeNull(); + }); + + it('信頼度が閾値未満なら起票しない', () => { + expect( + resolvePublicIssueRepo( + baseReport({ componentConfidence: PUBLIC_ISSUE_MIN_CONFIDENCE - 0.01 }), + opts + ) + ).toBeNull(); + expect( + resolvePublicIssueRepo( + baseReport({ componentConfidence: PUBLIC_ISSUE_MIN_CONFIDENCE }), + opts + ) + ).toBe('TrainLCD/MobileApp'); + }); + + it('スパム・質問・トリアージ失敗・クラッシュは起票しない', () => { + expect( + resolvePublicIssueRepo(baseReport({ isSpam: true }), opts) + ).toBeNull(); + expect( + resolvePublicIssueRepo(baseReport({ category: 'question' }), opts) + ).toBeNull(); + expect( + resolvePublicIssueRepo(baseReport(), { ...opts, triageFailed: true }) + ).toBeNull(); + expect( + resolvePublicIssueRepo(baseReport(), { ...opts, reportType: 'crash' }) + ).toBeNull(); + }); +}); + +describe('公開リポジトリ用の Issue 本文', () => { + const ticketId = 'a1b2c3d4-0000-4444-8888-abcdefabcdef'; + const body = buildPublicIssueBody({ internalIssueNumber: 123, ticketId }); + + it('管理 Issue 番号とチケットIDを紐づける', () => { + expect(buildPublicIssueTitle(123)).toContain('TrainLCD/Issues#123'); + expect(body).toContain('TrainLCD/Issues#123'); + expect(body).toContain(ticketId); + }); + + it('フィードバック由来の情報を一切含めない', () => { + const report = baseReport({ + title: '駅名が誤って表示される', + summary: '山手線で駅名が1つずれて表示されるという報告', + }); + const rendered = `${buildPublicIssueTitle(123)}\n${body}`; + expect(rendered).not.toContain(report.title); + expect(rendered).not.toContain(report.summary); + expect(rendered).not.toContain(report.reason); + }); +}); + +describe('looksLikeSpam(正当な報告の誤判定)', () => { + // 実フィードバックは非公開のため、同等の語彙構成を持つ合成文で確認する + it('停車駅・方面・路線名を含むデータ不備の報告をスパムにしない', () => { + expect( + looksLikeSpam('架空線の停車駅が違います。仮駅と例駅にも停車するはずです') + ).toBe(false); + expect(looksLikeSpam('行き先方面の案内が実際と異なります')).toBe(false); + expect(looksLikeSpam('架空線の停車駅、仮駅・例駅が抜けています')).toBe( + false + ); + expect(looksLikeSpam('種別が反映されていないようです')).toBe(false); + expect(looksLikeSpam('乗り換え路線を追加してほしいです')).toBe(false); + expect(looksLikeSpam('駅ナンバリングの表記がおかしいです')).toBe(false); + }); + + it('放送定型句を伴わない停車駅・方面の言及だけでは加点しない', () => { + // ACTIONABLE に一致しない書き方でも、放送の書き起こしでなければスパムにしない + expect(looksLikeSpam('架空線の停車駅と方面の情報について')).toBe(false); + }); + + it('車内放送の書き起こしは引き続きスパムとして扱う', () => { + expect( + looksLikeSpam( + '次は仮駅、仮駅です。お出口は左側です。ご利用ありがとうございます。' + ) + ).toBe(true); + expect( + looksLikeSpam( + '次は仮駅方面、停車駅は例駅、見本駅です。お乗り換えのご案内' + ) + ).toBe(true); + }); +}); + +describe('applySpamHeuristic', () => { + const notSpam = (confidence: number): AIReport => ({ + title: '停車駅の誤りについて', + summary: 'サマリ', + isSpam: false, + labels: ['bug'], + confidence, + reason: 'reason', + category: 'bug', + triageLevel: 'high', + component: 'station_api', + componentConfidence: 0.9, + }); + const transcript = + '次は仮駅、仮駅です。お出口は左側です。ご利用ありがとうございます。'; + + it('モデルが確信を持って非スパムと判定していれば分類を維持し、人手確認に回す', () => { + const { report, needsSpamReview } = applySpamHeuristic( + notSpam(SPAM_OVERRIDE_MAX_CONFIDENCE), + transcript, + { triageFailed: false } + ); + expect(needsSpamReview).toBe(true); + expect(report.isSpam).toBe(false); + expect(report.title).toBe('停車駅の誤りについて'); + expect(report.labels).toEqual(['bug']); + expect(report.category).toBe('bug'); + }); + + it('モデルの確信度が低い場合はヒューリスティックでスパムに倒す', () => { + const { report, needsSpamReview } = applySpamHeuristic( + notSpam(SPAM_OVERRIDE_MAX_CONFIDENCE - 0.01), + transcript, + { triageFailed: false } + ); + expect(needsSpamReview).toBe(false); + expect(report.isSpam).toBe(true); + expect(report.title).toBe(NON_ACTIONABLE_TITLE); + expect(report.labels).toEqual([]); + }); + + it('正当な報告には何もしない', () => { + const input = notSpam(0.9); + const { report, needsSpamReview } = applySpamHeuristic( + input, + '架空線の停車駅が違います', + { triageFailed: false } + ); + expect(needsSpamReview).toBe(false); + expect(report).toBe(input); + }); + + it('トリアージ失敗のレポートは上書きしない(失敗の事実を残す)', () => { + const failed = buildFailedReport(transcript, 72); + const { report, needsSpamReview } = applySpamHeuristic(failed, transcript, { + triageFailed: true, + }); + expect(needsSpamReview).toBe(false); + expect(report).toBe(failed); + expect(report.summary).toBe(TRIAGE_FAILED_SUMMARY); + }); + + it('モデル自身がスパムと判定したものはそのまま', () => { + const spam = { ...notSpam(0.9), isSpam: true }; + const { report, needsSpamReview } = applySpamHeuristic(spam, transcript, { + triageFailed: false, + }); + expect(needsSpamReview).toBe(false); + expect(report).toBe(spam); + }); +}); + +describe('findBrokenTitleReason', () => { + it('正常な日本語タイトルは通す', () => { + for (const title of [ + '自動アナウンスが途中で停止する不具合', + // 異なる助詞の連結は正常な日本語(誤検知の回帰テスト) + '駅名が反映されないのでは?という報告', + 'そのものには問題がない旨の報告', + '路線図のダークモード対応要望', + '特定駅が検索に出ず駅名表記も誤り', + 'オートモード時に駅ナンバリングがずれる', + 'Auto mode stops announcing station names', + ]) { + expect(findBrokenTitleReason(title)).toBeNull(); + } + }); + + it('未取得・空・記号のみを検知する', () => { + expect(findBrokenTitleReason(MISSING_TITLE)).toBe('missing'); + expect(findBrokenTitleReason('')).toBe('empty'); + expect(findBrokenTitleReason(' ')).toBe('empty'); + expect(findBrokenTitleReason('!!!…')).toBe('no_word_char'); + }); + + it('破損した生成結果を検知する', () => { + expect(findBrokenTitleReason('駅名が\uFFFD示される')).toBe( + 'replacement_char' + ); + expect(findBrokenTitleReason('繧医↓縺ゅk陦ィ遉コ')).toBe('mojibake_kanji'); + expect(findBrokenTitleReason('駅名ををををが変')).toBe('char_repeat'); + expect(findBrokenTitleReason('表示表示表示がおかしい')).toBe( + 'phrase_repeat' + ); + expect(findBrokenTitleReason('駅名ををを変わる')).toBe('particle_run'); + expect(findBrokenTitleReason('역명이 잘못 표시됨')).toBe('foreign_script'); + }); + + it('isUnusableTitle は真偽値を返す', () => { + expect(isUnusableTitle('正常なタイトル')).toBe(false); + expect(isUnusableTitle(MISSING_TITLE)).toBe(true); + }); +}); + +describe('pickModelResponse', () => { + it('従来の Workers AI 形式(response)を取り出す', () => { + expect(pickModelResponse({ response: { title: 't' } })).toEqual({ + title: 't', + }); + expect(pickModelResponse({ response: '{"title":"t"}' })).toBe( + '{"title":"t"}' + ); + }); + + it('OpenAI 互換形式(choices[0].message.content)を取り出す', () => { + // gemma-4 系は response を返さず choices のみ。ここを見落とすと全件失敗する + const raw = { + choices: [ + { + finish_reason: 'stop', + message: { content: '{"title":"駅名がずれる"}', reasoning: '...' }, + }, + ], + usage: { completion_tokens: 892 }, + }; + expect(pickModelResponse(raw)).toBe('{"title":"駅名がずれる"}'); + }); + + it('response があればそちらを優先する', () => { + const raw = { + response: { title: 'A' }, + choices: [{ message: { content: '{"title":"B"}' } }], + }; + expect(pickModelResponse(raw)).toEqual({ title: 'A' }); + }); + + it('取り出せない形は null', () => { + expect(pickModelResponse(null)).toBeNull(); + expect(pickModelResponse('text')).toBeNull(); + expect(pickModelResponse({})).toBeNull(); + expect(pickModelResponse({ choices: [] })).toBeNull(); + expect(pickModelResponse({ choices: [{ message: {} }] })).toBeNull(); + }); + + it('取り出した文字列は既存の JSON 抽出でパースできる', () => { + const content = '{\n "title": "駅名がずれる",\n "isSpam": false\n}'; + const picked = pickModelResponse({ + choices: [{ message: { content } }], + }) as string; + expect(extractReportJson(picked)).toEqual({ + title: '駅名がずれる', + isSpam: false, + }); + }); +}); diff --git a/src/consumers/feedbackTriage.ts b/src/consumers/feedbackTriage.ts index 555a755..7c5aee4 100644 --- a/src/consumers/feedbackTriage.ts +++ b/src/consumers/feedbackTriage.ts @@ -5,13 +5,40 @@ import dayjs from 'dayjs'; import type { AICategory, + AIComponent, AIReport, AITriageLevel, FewShotItem, } from '../models/ai'; import type { DiscordEmbed } from '../models/common'; +import type { Report } from '../models/feedback'; import type { Env, FeedbackQueueMessage } from '../types'; +/** フィードバック原本を保管する非公開リポジトリ */ +const INTERNAL_REPO = 'TrainLCD/Issues'; + +/** + * 原因コンポーネント → 起票先の公開リポジトリ。 + * 公開リポジトリなのでフィードバックの内容は一切載せず、非公開の管理チケットへの + * 参照(Issue 番号・チケットID)だけを持つスタブ Issue を立てる。 + */ +const COMPONENT_REPOS: Record = { + mobile_app: 'TrainLCD/MobileApp', + station_api: 'TrainLCD/StationAPI', + functions: 'TrainLCD/Functions', + website: 'TrainLCD/Website', +}; + +/** 原因コンポーネントを信用して公開リポジトリに起票する最低信頼度 */ +export const PUBLIC_ISSUE_MIN_CONFIDENCE = 0.7; + +/** 公開リポジトリへ転記する対象カテゴリ(質問は原因特定の対象外) */ +const PUBLIC_ISSUE_CATEGORIES: readonly AICategory[] = [ + 'bug', + 'improvement', + 'feature_request', +]; + const GITHUB_LABELS = { PLATFORM_IOS: '🍎 iOS', PLATFORM_IPADOS: '🍎 iPadOS', @@ -29,6 +56,7 @@ const GITHUB_LABELS = { CATEGORY_FEATURE_REQUEST: '✨ Feature Request', CATEGORY_IMPROVEMENT: '🛠️ Improvement', CATEGORY_QUESTION: '❓ Question', + CATEGORY_PRAISE: '💚 Praise', TRIAGE_URGENT: '🔴 P0 / Urgent', TRIAGE_HIGH: '🟠 P1 / High', TRIAGE_MEDIUM: '🟡 P2 / Medium', @@ -40,6 +68,7 @@ const CATEGORY_LABELS: Record = { feature_request: GITHUB_LABELS.CATEGORY_FEATURE_REQUEST, improvement: GITHUB_LABELS.CATEGORY_IMPROVEMENT, question: GITHUB_LABELS.CATEGORY_QUESTION, + praise: GITHUB_LABELS.CATEGORY_PRAISE, }; const TRIAGE_LABELS: Record = { @@ -63,6 +92,35 @@ const CATEGORY_SYNONYMS: Record = { question: 'question', support: 'question', help: 'question', + praise: 'praise', + thanks: 'praise', + thankyou: 'praise', + gratitude: 'praise', + compliment: 'praise', + kudos: 'praise', + positive: 'praise', +}; + +// モデルは enum 外の表記("MobileApp" / "ios" / "web" など)を返すことがあるため、 +// 区切り文字を除去したキーで正規化する。未知の値は null(=原因未特定)に落とす。 +const COMPONENT_SYNONYMS: Record = { + mobileapp: 'mobile_app', + mobile: 'mobile_app', + app: 'mobile_app', + client: 'mobile_app', + ios: 'mobile_app', + android: 'mobile_app', + stationapi: 'station_api', + station: 'station_api', + stationdata: 'station_api', + functions: 'functions', + function: 'functions', + worker: 'functions', + workers: 'functions', + website: 'website', + web: 'website', + site: 'website', + homepage: 'website', }; const TRIAGE_SYNONYMS: Record = { @@ -79,25 +137,44 @@ const TRIAGE_SYNONYMS: Record = { p3: 'low', }; +/** + * 不具合・要望の報告で使われる語彙。これが出たら車内放送の書き起こしではないと + * 断定してよいので、スコアリングに入る前に非スパムとして返す。 + * 「〜が違います」「反映されない」「〜してほしい」のように、報告者が「不具合」「要望」と + * いう語を使わずに書くケースを取りこぼさないことを重視している。 + */ +const ACTIONABLE = + /(修正|改善|追加|希望|要望|不具合|バグ|誤|間違|違い|違う|反映|表示|保存|再生|遅|遅延|できない|出来ない|できません|出来ません|されない|されません|しない|しません|エラー|落ちる|クラッシュ|重複|ズレ|ずれ|おかしい|ほしい|欲しい|直し|なおし|音がない|読み上げない)/; + +/** + * 車内放送でも報告文でも使われる言い回し。単独では判断できないため早期リターンには + * 使わず、スパムスコアの減点に留める(正当な報告を握りつぶす方が、スパムを 1 件 + * 通すより損失が大きいという方針)。 + */ +const WEAK_ACTIONABLE = /(になります|になっています|になってます|されています)/; + +/** 車内放送の定型句。書き起こし判定の主シグナル */ +const ANNOUNCEMENT_PHRASE = + /(次は|まもなく|この(列車|電車)は|行きです|ご利用ありがとうございます|お出口は(左|右)側です|各駅に(停ま|止ま)ります|お乗り換え)/; + export function looksLikeSpam(text: string): boolean { if (!text) return false; const t = String(text).replace(/\s+/g, ' ').trim(); - const ACTIONABLE = - /(修正|改善|追加|希望|要望|不具合|バグ|誤|間違い|表示|保存|再生|遅|遅延|できない|出来ない|エラー|落ちる|クラッシュ|重複|ズレ|音がない|読み上げない)/; if (ACTIONABLE.test(t)) return false; let score = 0; - if ( - /(次は|まもなく|この(列車|電車)は|行きです|ご利用ありがとうございます|お出口は(左|右)側です|各駅に(停ま|止ま)ります|お乗り換え)/.test( - t - ) - ) { + const hasAnnouncementPhrase = ANNOUNCEMENT_PHRASE.test(t); + if (hasAnnouncementPhrase) { score += 1; } - if (/(停車駅|方面)/.test(t)) score += 1; + // 「停車駅」「方面」「駅名・路線名の併記」はいずれも本アプリのドメイン語彙そのもので、 + // データ不備の報告に普通に現れる。放送の定型句と共起したときだけ書き起こしの + // シグナルとして扱う(単独加点だと、正確な報告ほどスパム判定されてしまう)。 + if (hasAnnouncementPhrase && /(停車駅|方面)/.test(t)) score += 1; if ( + hasAnnouncementPhrase && /([一-龥ァ-ヶー]{2,})(、|,|・|\s)([一-龥ァ-ヶー]{2,})/.test(t) && /(停車|次は|方面)/.test(t) ) { @@ -118,9 +195,76 @@ export function looksLikeSpam(text: string): boolean { if (/[🚃🚇🚈♪🎵]/u.test(t)) { score += 0.5; } + if (WEAK_ACTIONABLE.test(t)) score -= 1; return score >= 2; } +/** モデルがタイトルを返さなかったときの穴埋め文言 */ +export const MISSING_TITLE = '要約未取得'; + +/** + * 日本語として成立しない生成タイトルのパターン。 + * 小型モデルは日本語生成が破綻することがあり、破損タイトルのまま起票すると + * Issue 一覧から内容を判別できなくなる(= バックログの一次スクリーニングが機能しない)。 + * 誤検知するとまともなタイトルまで「要約失敗」に落としてしまうため、 + * 正常な日本語では起こり得ないものだけを列挙する。 + */ +const BROKEN_TITLE_PATTERNS: readonly { + name: string; + test: (title: string) => boolean; +}[] = [ + // 文字化け(U+FFFD)・制御文字 + { name: 'replacement_char', test: (t) => /\uFFFD/.test(t) }, + { + name: 'control_char', + // biome-ignore lint/suspicious/noControlCharactersInRegex: 制御文字の混入そのものを検知する + test: (t) => /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(t), + }, + // 日本語アプリのタイトルに現れない文字体系(ギリシャ・キリル・ヘブライ・アラビア・ + // デーヴァナーガリー・タイ・ハングル) + { + name: 'foreign_script', + test: (t) => + /[\u0370-\u03FF\u0400-\u04FF\u0590-\u05FF\u0600-\u06FF\u0900-\u097F\u0E00-\u0E7F\uAC00-\uD7AF]/.test( + t + ), + }, + // UTF-8 を Shift_JIS として解釈したときに出る典型的な文字化け漢字。現代日本語では + // ほぼ使われない字なので、連続していなくても 2 文字あれば破損とみなす + { + name: 'mojibake_kanji', + test: (t) => + (t.match(/[縺繧繝蜿蛻髢讌荳陦莠譌蟄蜀隱蠢遘蜷コサ]/g) ?? []).length >= 2, + }, + // 同一文字の 4 連続・同一語の 3 連続(生成ループ) + { name: 'char_repeat', test: (t) => /(.)\1{3,}/u.test(t) }, + { name: 'phrase_repeat', test: (t) => /(.{2,4})\1{2,}/u.test(t) }, + // 同一助詞の 3 連続。「のでは」「のにも」「ものには」のように異なる助詞が連なるのは + // 正常な日本語なので対象にしない(誤検知するとトリアージ結果ごと捨ててしまう) + { name: 'particle_run', test: (t) => /([はがのにをでとへも])\1{2,}/.test(t) }, +]; + +/** + * 生成タイトルが起票に使えない(未取得 or 日本語として破損している)かを判定する。 + * true のときは破損タイトルのまま起票せず、失敗を明示したレポートに倒す。 + */ +export function findBrokenTitleReason(title: string): string | null { + const t = String(title ?? '').trim(); + if (!t) return 'empty'; + if (t === MISSING_TITLE) return 'missing'; + // 記号・空白だけのタイトル + if (!/[\p{L}\p{N}]/u.test(t)) return 'no_word_char'; + for (const { name, test } of BROKEN_TITLE_PATTERNS) { + if (test(t)) return name; + } + return null; +} + +/** findBrokenTitleReason の真偽値版 */ +export function isUnusableTitle(title: string): boolean { + return findBrokenTitleReason(title) !== null; +} + export function coerceReport(raw: unknown, titleMax = 72): AIReport { const norm = (k: string) => String(k).toLowerCase().replace(/\s+/g, '').trim(); @@ -129,9 +273,22 @@ export function coerceReport(raw: unknown, titleMax = 72): AIReport { for (const [k, v] of entries) map.set(norm(k), v); const getStr = (k: string, d = '') => String(map.get(k) ?? d).trim(); - const getNum = (k: string, d = 0.5) => { - const n = Number(map.get(k)); - return Number.isFinite(n) ? n : d; + /** + * 0..1 の信頼度を読む。数値(または数値だけの文字列)以外と範囲外は、 + * スキーマに従っていない応答なので値を信用せず既定値に倒す。 + * Number() 任せにすると true や [1] が 1 に化けてしまうため型で絞る。 + * 特に componentConfidence は公開リポジトリへの起票判定に使うため、 + * 壊れた値をそのまま通すと内容を公開すべきでないものが流出しうる。 + */ + const getRatio = (k: string, d: number) => { + const raw = map.get(k); + const n = + typeof raw === 'number' + ? raw + : typeof raw === 'string' && raw.trim() !== '' + ? Number(raw) + : Number.NaN; + return Number.isFinite(n) && n >= 0 && n <= 1 ? n : d; }; const getBool = (...keys: string[]) => keys.some((k) => { @@ -146,7 +303,7 @@ export function coerceReport(raw: unknown, titleMax = 72): AIReport { const labels: string[] = Array.isArray(rawLabels) ? rawLabels.filter((l): l is string => typeof l === 'string') : []; - const confidence = getNum('confidence', 0.5); + const confidence = getRatio('confidence', 0.5); const reason = getStr('reason'); const categoryKey = getStr('category') .toLowerCase() @@ -156,12 +313,25 @@ export function coerceReport(raw: unknown, titleMax = 72): AIReport { .toLowerCase() .replaceAll(/[\s-]+/g, ''); const triageLevel: AITriageLevel = TRIAGE_SYNONYMS[triageKey] ?? 'medium'; + const componentKey = getStr('component') + .toLowerCase() + .replaceAll(/[\s_-]+/g, ''); + const component: AIComponent | null = + COMPONENT_SYNONYMS[componentKey] ?? null; + // 原因が特定できていないのに信頼度だけ高い、という応答を弾くため component とセットで扱う + const componentConfidence = component + ? getRatio('componentconfidence', 0) + : 0; - if (!title) title = '要約未取得'; + if (!title) title = MISSING_TITLE; if (title.length > titleMax) title = `${title.slice(0, titleMax - 1)}…`; // 要約が空だと Issue 本文の節が空になり、Discord embed も value 空でリジェクトされるため - // タイトルにフォールバックして常に何らかのテキストを入れる - if (!summary) summary = title; + // 常に何らかのテキストを入れる。ただしタイトルが未取得・破損しているときにそれを + // 要約へ伝播させると、タイトルと要約の両方が同時に壊れて内容が判別できなくなるため、 + // その場合は失敗を明示する文言に倒す。 + if (!summary) { + summary = isUnusableTitle(title) ? TRIAGE_FAILED_SUMMARY : title; + } return { title, @@ -172,6 +342,8 @@ export function coerceReport(raw: unknown, titleMax = 72): AIReport { reason, category, triageLevel, + component, + componentConfidence, }; } @@ -256,6 +428,53 @@ export function buildFailedReport( reason: 'triage_failed', category: 'question', triageLevel: 'medium', + component: null, + componentConfidence: 0, + }; +} + +/** + * ヒューリスティックがモデルの非スパム判定を覆せる、モデル側 confidence の上限。 + * これ以上の確信度でモデルが「スパムではない」と言っているときは、ヒューリスティックは + * 上書きせず人手確認のマーカーだけを付ける。 + */ +export const SPAM_OVERRIDE_MAX_CONFIDENCE = 0.5; + +/** スパム上書き時に使う、内容を判別できないことを示すタイトル */ +export const NON_ACTIONABLE_TITLE = '内容未分類(改善要望なし)'; + +/** + * looksLikeSpam の結果をレポートに反映する。 + * + * ヒューリスティックは補助でしかなく、正当な報告を握りつぶすと利用者の声が + * 完全に失われる(ラベルもカテゴリも消えて候補プールから脱落する)。そのため + * モデルが確信を持って「スパムではない」と判定しているときは分類をそのまま残し、 + * 人手確認用のマーカー(needsSpamReview)だけを立てる。 + */ +export function applySpamHeuristic( + aiReport: AIReport, + description: string, + opts: { triageFailed: boolean } +): { report: AIReport; needsSpamReview: boolean } { + // トリアージ自体が失敗しているレポートは、そもそもモデルの判定が無い。 + // ここでスパムに倒すと「要約失敗」の事実が消えるため触らない。 + if (opts.triageFailed) return { report: aiReport, needsSpamReview: false }; + if (aiReport.isSpam) return { report: aiReport, needsSpamReview: false }; + if (!looksLikeSpam(description)) { + return { report: aiReport, needsSpamReview: false }; + } + if (aiReport.confidence >= SPAM_OVERRIDE_MAX_CONFIDENCE) { + return { report: aiReport, needsSpamReview: true }; + } + return { + report: { + ...aiReport, + title: NON_ACTIONABLE_TITLE, + isSpam: true, + labels: [], + reason: 'non-actionable', + }, + needsSpamReview: false, }; } @@ -265,35 +484,47 @@ Task: 1. Summarize the user's message into a ONE-LINE issue title in Japanese (≤72 chars). 2. Also create a 1–3 sentence summary in Japanese that concisely describes the feedback content. 3. Classify spam. -4. If NOT spam, pick ONE primary "category" from ["bug","feature_request","improvement","question"]: +4. If NOT spam, pick ONE primary "category" from ["bug","feature_request","improvement","question","praise"]: - bug: 不具合・誤動作・クラッシュ・表示崩れ - feature_request: まだ存在しない機能の新規要望 - improvement: 既存機能の改善・調整 - question: 質問・使い方の確認・情報要求 + - praise: 感謝・称賛・応援のみで、対応すべき要望を含まないもの 5. If NOT spam, pick ONE "triageLevel" from ["urgent","high","medium","low"]: - urgent: クラッシュ・データ消失・広範な実用不能 - high: 特定機能が使えない/重要機能要望 - medium: 通常の改善・軽微なバグ - - low: 体裁の問題・質問・軽い要望 - If spam, omit "category" and "triageLevel" entirely. + - low: 体裁の問題・質問・軽い要望・感謝や称賛 + If spam, still output "category": "question" and "triageLevel": "low" (they are ignored for spam). +6. If NOT spam, decide WHICH component the root cause most likely lives in, as "component": + - "mobile_app": TrainLCD の iOS/Android アプリ本体(画面表示・UI・音声再生・クラッシュ・設定・位置情報の挙動) + - "station_api": 駅・路線・種別のデータや検索結果(駅名の誤り・駅の欠落・路線データの誤り) + - "functions": バックエンド Workers(AIチャット・音声合成・フィードバック送信・画像アップロード・API エラー) + - "website": 公式サイト(trainlcd.app) + Use "unknown" when the message does not clearly point at one component. + Also output "componentConfidence" (0..1) for how sure you are about "component". + Use a value below 0.7 unless the message clearly identifies the responsible component. + If spam, output "component": "unknown". Always output "componentConfidence" (0 when "unknown"). Rules: - Newspaper-style headline: [症状/論点]+[対象](助詞は最小限) - No device/OS/version/URL/stack unless essential - Prefer Japanese if input has Japanese -- If no actionable content (announcement transcript, chit-chat, praise-only), mark spam +- Mark spam ONLY for content unrelated to improving the app: 車内放送の書き起こし、無関係な雑談、宣伝・荒らし +- NEVER mark gratitude, praise or encouragement as spam. Even with nothing to fix, it is a real message from a real user: set isSpam=false and category="praise" - If not spam, pick labels from: ["bug","improvement","feature","localization","location","ui","performance","network","settings"] Output JSON only: -{"title": "...", "summary": "...", "isSpam": true|false, "labels": [], "category": "...", "triageLevel": "...", "confidence": 0..1, "reason": "..."} +{"title": "...", "summary": "...", "isSpam": true|false, "labels": [], "category": "...", "triageLevel": "...", "component": "...", "componentConfidence": 0..1, "confidence": 0..1, "reason": "..."} Return ONLY that JSON. No prose, no markdown. `.trim(); // Workers AI の JSON Mode(response_format)に渡すスキーマ。 // summary を required にして「フィールド欠落で要約が空」になるのを構造的に防ぐ。 -// category / triageLevel はスパム時に省ける運用なので optional のまま。 +// スパム時も含めて全フィールドを必須にし、フィールド欠落による既定値落ちを防ぐ +// (スパム判定時の category / triageLevel は起票側で無視する)。 const TRIAGE_JSON_SCHEMA = { type: 'object', properties: { @@ -303,13 +534,31 @@ const TRIAGE_JSON_SCHEMA = { labels: { type: 'array', items: { type: 'string' } }, category: { type: 'string', - enum: ['bug', 'feature_request', 'improvement', 'question'], + enum: ['bug', 'feature_request', 'improvement', 'question', 'praise'], }, triageLevel: { type: 'string', enum: ['urgent', 'high', 'medium', 'low'] }, - confidence: { type: 'number' }, + component: { + type: 'string', + enum: ['mobile_app', 'station_api', 'functions', 'website', 'unknown'], + }, + componentConfidence: { type: 'number', minimum: 0, maximum: 1 }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, reason: { type: 'string' }, }, - required: ['title', 'summary', 'isSpam', 'labels', 'confidence', 'reason'], + // category / triageLevel を optional にしていたため、モデルが省略した非スパムの + // レポートが軒並み既定値(question / medium)に落ちていた。常に出力させる。 + required: [ + 'title', + 'summary', + 'isSpam', + 'labels', + 'category', + 'triageLevel', + 'component', + 'componentConfidence', + 'confidence', + 'reason', + ], } as const; // ---- Few-shot loader(CONFIG_KV) ---- @@ -376,17 +625,37 @@ async function runTriage( ? '\n\nIMPORTANT: Output exactly ONE minified JSON object and nothing else. Do not repeat the examples. Do not add prose, comments, or code fences.' : ''; const prompt = `${fewshot}\n\nNow process this message:\n\n<>\n${userText}`; - const result = (await env.AI.run(env.AI_TRIAGE_MODEL, { + const result = await env.AI.run(env.AI_TRIAGE_MODEL, { messages: [ { role: 'system', content: SYSTEM_PROMPT + strictNudge }, { role: 'user', content: prompt }, ], - max_tokens: 768, + // 推論トレースを出すモデル(gemma-4 など)は本文の前に思考を吐くため、 + // 768 だと JSON が途中で切れる(finish_reason: "length")。実測で完了まで + // 900〜1100 トークン使うので余裕を持たせる。 + max_tokens: 2048, temperature: strict ? 0 : 0.2, // JSON Schema を強制し、summary などのフィールド欠落を防ぐ。 response_format: { type: 'json_schema', json_schema: TRIAGE_JSON_SCHEMA }, - })) as { response?: unknown }; - return result?.response ?? null; + }); + return pickModelResponse(result); +} + +/** + * Workers AI の応答から本文を取り出す。モデルによって形が 2 通りある。 + * - `response`: 従来の Workers AI 形式(パース済みオブジェクト or 文字列) + * - `choices[0].message.content`: OpenAI 互換形式(gemma-4 などはこちらのみ) + * 片方しか見ないとモデル差し替え時に全件トリアージ失敗になるため、両方を受ける。 + */ +export function pickModelResponse(result: unknown): unknown | null { + if (!result || typeof result !== 'object') return null; + const r = result as { + response?: unknown; + choices?: { message?: { content?: unknown } }[]; + }; + if (r.response !== undefined && r.response !== null) return r.response; + const content = r.choices?.[0]?.message?.content; + return content ?? null; } /** runTriage の戻り(オブジェクト or 文字列)からトリアージ JSON を取り出す。 */ @@ -405,6 +674,135 @@ function responseLength(resp: unknown): number { return s.length; } +// ---- GitHub Issue 作成 ---- + +/** GitHub REST API への POST(Issue 作成・コメント投稿の共通処理)。 */ +function githubPost(env: Env, path: string, body: unknown): Promise { + return fetch(`https://api.github.com/repos/${path}`, { + method: 'post', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${env.OCTOKIT_PAT ?? ''}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'trainlcd-worker', + }, + body: JSON.stringify(body), + }); +} + +/** + * 原因が特定できているフィードバックについて、起票先の公開リポジトリを返す。 + * 特定できていない・公開に適さない場合は null(=非公開リポジトリのみに起票)。 + * + * - クラッシュレポートは対象外(スタックトレースを含み、内容の公開範囲が読めないため) + * - スパム/スパム疑い/トリアージ失敗は原因を特定できていないので対象外 + * - 質問カテゴリは修正対象のコンポーネントが定まらないので対象外 + */ +export function resolvePublicIssueRepo( + aiReport: AIReport, + opts: { + reportType: Report['reportType']; + triageFailed: boolean; + needsSpamReview?: boolean; + } +): string | null { + if (opts.reportType !== 'feedback') return null; + if (opts.triageFailed || aiReport.isSpam) return null; + // スパム疑いで人手確認待ちのものを公開リポジトリに出さない + if (opts.needsSpamReview) return null; + if (!PUBLIC_ISSUE_CATEGORIES.includes(aiReport.category)) return null; + if (!aiReport.component) return null; + if (aiReport.componentConfidence < PUBLIC_ISSUE_MIN_CONFIDENCE) return null; + return COMPONENT_REPOS[aiReport.component]; +} + +/** + * 公開リポジトリに立てるスタブ Issue のタイトル。 + * 公開範囲にフィードバックの内容を出さないため、AI 要約もタイトルも使わず、 + * 非公開の管理 Issue 番号だけで表現する。 + */ +export function buildPublicIssueTitle(internalIssueNumber: number): string { + return `フィードバック対応: ${INTERNAL_REPO}#${internalIssueNumber}`; +} + +/** + * 公開リポジトリに立てるスタブ Issue の本文。 + * 意図的に、フィードバックの原文・AI 要約・タイトル・端末情報を一切含めない。 + * 内容を追うための手掛かりは非公開の管理チケットへの参照だけに限定する。 + */ +export function buildPublicIssueBody(params: { + internalIssueNumber: number; + ticketId: string; +}): string { + const { internalIssueNumber, ticketId } = params; + return ` +アプリから届いたフィードバックのトリアージで、原因が本リポジトリにあると推定されたため起票しています。 + +フィードバックの内容は公開リポジトリには掲載していません。原文・要約・端末情報などの詳細は、下記の非公開の管理チケットを参照してください。 + +## 管理チケット +- Issue: ${INTERNAL_REPO}#${internalIssueNumber} +- チケットID: \`${ticketId}\` +`.trim(); +} + +/** + * 公開リポジトリへスタブ Issue を作成し、その URL を返す(失敗時は null)。 + * ここで throw すると queue が再試行して非公開 Issue が重複作成されるため、 + * 失敗はログに留める。 + */ +async function createPublicIssue( + env: Env, + params: { repo: string; internalIssueNumber: number; ticketId: string } +): Promise { + const { repo, internalIssueNumber, ticketId } = params; + try { + // ラベルは公開リポジトリ側に存在しないと自動生成されてしまうため付けない。 + // 分類・優先度は非公開の管理 Issue 側のラベルで管理する。 + const res = await githubPost(env, `${repo}/issues`, { + title: buildPublicIssueTitle(internalIssueNumber), + body: buildPublicIssueBody({ internalIssueNumber, ticketId }), + assignees: ['TinyKitten'], + }); + if (res.status !== 201) { + console.error('公開リポジトリへの起票に失敗', { + repo, + status: res.status, + internalIssueNumber, + }); + return null; + } + const created = (await res.json()) as { html_url: string }; + return created.html_url; + } catch (err) { + console.error('公開リポジトリへの起票に失敗', { repo, err }); + return null; + } +} + +/** 非公開の管理 Issue 側に、公開 Issue へのリンクをコメントで残す(失敗しても無視)。 */ +async function linkPublicIssue( + env: Env, + internalIssueNumber: number, + publicIssueUrl: string +): Promise { + try { + const res = await githubPost( + env, + `${INTERNAL_REPO}/issues/${internalIssueNumber}/comments`, + { body: `公開リポジトリに対応 Issue を起票しました: ${publicIssueUrl}` } + ); + if (res.status !== 201) { + console.error('管理 Issue への相互リンクコメントに失敗', { + status: res.status, + internalIssueNumber, + }); + } + } catch (err) { + console.error('管理 Issue への相互リンクコメントに失敗', { err }); + } +} + export const processFeedbackMessage = async ( data: FeedbackQueueMessage, env: Env @@ -420,7 +818,22 @@ export const processFeedbackMessage = async ( let raw: unknown = null; let lastResponseLength = 0; for (let attempt = 1; attempt <= MAX_TRIAGE_ATTEMPTS; attempt++) { - const resp = await runTriage(env, fewshot, report.description, attempt > 1); + let resp: unknown = null; + try { + resp = await runTriage(env, fewshot, report.description, attempt > 1); + } catch (err) { + // JSON Mode を満たせない場合や AI 側の一時障害では env.AI.run が throw する。 + // ここで抜けると queue が再試行し、max_retries を使い切った時点でフィードバックが + // 消えるため、生成失敗として扱って最終的に「要約失敗」で起票する(原文は残る)。 + console.warn('feedbackTriage: トリアージの推論呼び出しが失敗(再試行)', { + reportId: report.id, + attempt, + maxAttempts: MAX_TRIAGE_ATTEMPTS, + model: env.AI_TRIAGE_MODEL, + error: err instanceof Error ? err.message : String(err), + }); + continue; + } lastResponseLength = responseLength(resp); raw = normalizeTriageResponse(resp); if (raw !== null) break; @@ -432,7 +845,7 @@ export const processFeedbackMessage = async ( }); } - const triageFailed = raw === null; + let triageFailed = raw === null; let aiReport: AIReport; if (triageFailed) { // 生成に失敗しても破棄しない。原文を保全したまま、要約欄に失敗を明示して起票する。 @@ -451,21 +864,41 @@ export const processFeedbackMessage = async ( )?.[1] : undefined; if (!aiReport.isSpam && String(rawSummary ?? '').trim() === '') { + console.warn('feedbackTriage: モデルが summary を空/欠落で返却', { + reportId: report.id, + responseLength: lastResponseLength, + }); + } + + // タイトルが未取得・日本語として破損している場合は、そのまま起票すると + // Issue 一覧から内容を判別できない。失敗を明示するレポートに倒したうえで + // ❓ Unknown Type を付け、破損率を追えるようにログを残す。 + const brokenTitleReason = findBrokenTitleReason(aiReport.title); + if (brokenTitleReason) { console.warn( - 'feedbackTriage: モデルが summary を空/欠落で返却(title にフォールバック)', - { reportId: report.id, responseLength: lastResponseLength } + 'feedbackTriage: 生成タイトルが使用不能(要約失敗として起票)', + { + reportId: report.id, + reason: brokenTitleReason, + model: env.AI_TRIAGE_MODEL, + responseLength: lastResponseLength, + } ); + aiReport = buildFailedReport(report.description, 72); + triageFailed = true; } } - if (!aiReport.isSpam && looksLikeSpam(report.description)) { - aiReport = { - ...aiReport, - title: '内容未分類(改善要望なし)', - isSpam: true, - labels: [], - reason: 'non-actionable', - }; + const spamDecision = applySpamHeuristic(aiReport, report.description, { + triageFailed, + }); + aiReport = spamDecision.report; + const { needsSpamReview } = spamDecision; + if (needsSpamReview) { + console.warn( + 'feedbackTriage: スパム判定がモデルとヒューリスティックで不一致(人手確認に回す)', + { reportId: report.id, confidence: aiReport.confidence } + ); } const { @@ -508,19 +941,9 @@ export const processFeedbackMessage = async ( : undefined; try { - const res = await fetch( - 'https://api.github.com/repos/TrainLCD/Issues/issues', - { - method: 'post', - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${env.OCTOKIT_PAT ?? ''}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'trainlcd-worker', - }, - body: JSON.stringify({ - title: aiReport.title ?? '要約未取得', - body: ` + const res = await githubPost(env, `${INTERNAL_REPO}/issues`, { + title: aiReport.title ?? '要約未取得', + body: ` ![Image](${imageUrl}) @@ -531,6 +954,9 @@ ${'```'} ## AIによる要約 ${aiReport.summary} +## チケットID +${id} + ## 発行日時 ${createdAtText} @@ -563,33 +989,54 @@ ${sentryEventId} ## レポーターUID ${reporterUid} `.trim(), - assignees: ['TinyKitten'], - milestone: null, - labels: [ - reportType === 'feedback' && - !aiReport.isSpam && - GITHUB_LABELS.FEEDBACK_TYPE, - reportType === 'crash' && GITHUB_LABELS.CRASH_TYPE, - appEdition === 'production' && GITHUB_LABELS.PRODUCTION_APP, - appEdition === 'canary' && GITHUB_LABELS.CANARY_APP, - appClip && GITHUB_LABELS.PLATFORM_APPCLIP, - aiReport.isSpam && GITHUB_LABELS.SPAM_TYPE, - triageFailed && GITHUB_LABELS.UNKNOWN_TYPE, - osNameLabel, - autoModeLabel, - categoryLabel, - triageLabel, - ].filter(Boolean), - }), - } - ); + assignees: ['TinyKitten'], + milestone: null, + labels: [ + reportType === 'feedback' && + !aiReport.isSpam && + GITHUB_LABELS.FEEDBACK_TYPE, + reportType === 'crash' && GITHUB_LABELS.CRASH_TYPE, + appEdition === 'production' && GITHUB_LABELS.PRODUCTION_APP, + appEdition === 'canary' && GITHUB_LABELS.CANARY_APP, + appClip && GITHUB_LABELS.PLATFORM_APPCLIP, + aiReport.isSpam && GITHUB_LABELS.SPAM_TYPE, + (triageFailed || needsSpamReview) && GITHUB_LABELS.UNKNOWN_TYPE, + osNameLabel, + autoModeLabel, + categoryLabel, + triageLabel, + ].filter(Boolean), + }); if (res.status !== 201) { console.error(await res.json()); throw new Error(`GitHub API failed with status ${res.status}`); } - const issuesRes = (await res.json()) as { html_url: string }; + const issuesRes = (await res.json()) as { + html_url: string; + number: number; + }; + + // 原因コンポーネントが特定できている場合のみ、該当の公開リポジトリにも起票する。 + // 公開側に載せるのは管理 Issue 番号とチケットIDだけで、フィードバックの内容は含めない。 + // 起票後は管理 Issue 側にもコメントでリンクを残し、双方向に追えるようにする。 + const publicRepo = resolvePublicIssueRepo(aiReport, { + reportType, + triageFailed, + needsSpamReview, + }); + let publicIssueUrl: string | null = null; + if (publicRepo) { + publicIssueUrl = await createPublicIssue(env, { + repo: publicRepo, + internalIssueNumber: issuesRes.number, + ticketId: id, + }); + if (publicIssueUrl) { + await linkPublicIssue(env, issuesRes.number, publicIssueUrl); + } + } const csWHUrl = env.DISCORD_CS_WEBHOOK_URL; const crashWHUrl = env.DISCORD_CRASH_WEBHOOK_URL; @@ -628,6 +1075,9 @@ ${reporterUid} (autoModeEnabled === false ? '無効' : '不明'), }, { name: 'GitHub Issue', value: issuesRes.html_url }, + ...(publicIssueUrl + ? [{ name: '公開リポジトリ Issue', value: publicIssueUrl }] + : []), { name: 'Sentry Event ID', value: sentryEventId ?? '不明' }, ], }, @@ -657,6 +1107,9 @@ ${reporterUid} (autoModeEnabled === false ? '無効' : '不明'), }, { name: 'GitHub Issue', value: issuesRes.html_url }, + ...(publicIssueUrl + ? [{ name: '公開リポジトリ Issue', value: publicIssueUrl }] + : []), ], }, ]; diff --git a/src/models/ai.ts b/src/models/ai.ts index 404ddbf..4e46f10 100644 --- a/src/models/ai.ts +++ b/src/models/ai.ts @@ -6,12 +6,26 @@ export const AI_CATEGORIES = [ 'feature_request', 'improvement', 'question', + /** 感謝・称賛・応援。対応は不要だがスパムではない */ + 'praise', ] as const; export type AICategory = (typeof AI_CATEGORIES)[number]; export const AI_TRIAGE_LEVELS = ['urgent', 'high', 'medium', 'low'] as const; export type AITriageLevel = (typeof AI_TRIAGE_LEVELS)[number]; +/** + * 原因があると推定されるコンポーネント。公開リポジトリへの起票先の決定に使う。 + * 特定できない場合は AIReport.component が null になる。 + */ +export const AI_COMPONENTS = [ + 'mobile_app', + 'station_api', + 'functions', + 'website', +] as const; +export type AIComponent = (typeof AI_COMPONENTS)[number]; + export type AIReport = { /** レポートのタイトル */ title: string; @@ -29,6 +43,10 @@ export type AIReport = { category: AICategory; /** トリアージ(優先度)レベル */ triageLevel: AITriageLevel; + /** 原因があると推定されるコンポーネント(特定できなければ null) */ + component: AIComponent | null; + /** component の推定信頼度 (0.0 - 1.0)。component が null のときは 0 */ + componentConfidence: number; }; export type FewShotItem = { diff --git a/wrangler.jsonc b/wrangler.jsonc index fdf6f13..d064ade 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -41,7 +41,10 @@ "vars": { "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", - "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", + // トリアージは日本語のタイトル・要約を生成するため、日本語生成品質が要件。 + // 旧 @cf/meta/llama-3.1-8b-instruct-fast は日本語が破綻するうえ、 + // Workers AI のカタログからも消えている(models list / schema に無い)。 + "AI_TRIAGE_MODEL": "@cf/google/gemma-4-26b-a4b-it", // --- TTS(/tts)--- // Google Cloud TTS。ボイス名はロケール込みで、日英それぞれに指定する。 // 既定は旧 Google TTS 実装(Firebase Functions 時代)と同じ組み合わせ。 @@ -56,7 +59,7 @@ "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads-dev.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", - "FEW_SHOT_LIMIT": "12", + "FEW_SHOT_LIMIT": "16", "FEW_SHOT_PER_EX_MAX": "800", // --- AI エージェント(/agent/chat)--- // モデルは "anthropic:" | "openai:" | "google:"。比較検証で差し替える @@ -110,7 +113,8 @@ "services": [{ "binding": "STATION_API", "service": "stationapi" }], "vars": { "GOOGLE_PLAY_PACKAGE_NAME": "me.tinykitten.trainlcd", - "AI_TRIAGE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", + // dev と同じ理由で大型モデルを使う(日本語タイトルの破損対策) + "AI_TRIAGE_MODEL": "@cf/google/gemma-4-26b-a4b-it", // --- TTS(/tts)--- // Google Cloud TTS。本番は dev より控えめの「やや速め」に合わせる "TTS_JA_VOICE_NAME": "ja-JP-Standard-B", @@ -119,7 +123,7 @@ "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", - "FEW_SHOT_LIMIT": "12", + "FEW_SHOT_LIMIT": "16", "FEW_SHOT_PER_EX_MAX": "800", // --- AI エージェント(/agent/chat)--- "AGENT_MODEL": "google:gemini-3.7-flash", From f850f3e5ba55fd01d5c48ca2132523c5bc30c015 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 27 Aug 2026 13:17:04 +0900 Subject: [PATCH 09/16] =?UTF-8?q?=E3=82=A2=E3=83=97=E3=83=AA=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E3=81=AE=E8=AA=AD=E3=81=BF=E4=B8=8A=E3=81=92=E9=80=9F?= =?UTF-8?q?=E5=BA=A6=E3=82=92=E5=8F=97=E3=81=91=E4=BB=98=E3=81=91=E6=97=A2?= =?UTF-8?q?=E5=AE=9A=E9=80=9F=E5=BA=A6=E3=82=92=E7=AD=89=E9=80=9F=E3=81=B8?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/google/tts.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/lib/google/tts.ts | 29 +++++++++++++++++++++++++++++ src/routes/tts.ts | 7 ++++++- wrangler.jsonc | 13 ++++++++----- 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/lib/google/tts.test.ts b/src/lib/google/tts.test.ts index c41cfcf..c56655d 100644 --- a/src/lib/google/tts.test.ts +++ b/src/lib/google/tts.test.ts @@ -1,11 +1,47 @@ import { + ALLOWED_CLIENT_SPEEDS, buildSynthesizeRequestBody, mimeTypeForFormat, normalizeResponseFormat, + parseClientSpeed, parsePitch, parseSpeed, } from './tts'; +describe('parseClientSpeed', () => { + it('accepts the announcement speed presets', () => { + for (const speed of ALLOWED_CLIENT_SPEEDS) { + expect(parseClientSpeed(speed)).toBe(speed); + } + }); + + it('accepts a preset sent as a string', () => { + expect(parseClientSpeed('1.15')).toBe(1.15); + }); + + it('ignores values outside the presets so the cache cannot be fanned out', () => { + // 許可リスト方式。任意の値を通すと同じ文が速度違いで際限なくキャッシュされる + for (const speed of [0.9, 1.05, 1.2, 2, 0.25, 4]) { + expect(parseClientSpeed(speed)).toBeUndefined(); + } + }); + + it('treats missing and malformed values as unspecified', () => { + for (const speed of [ + undefined, + null, + '', + 'fast', + {}, + [], + Number.NaN, + Number.POSITIVE_INFINITY, + ]) { + expect(parseClientSpeed(speed)).toBeUndefined(); + } + }); +}); + describe('buildSynthesizeRequestBody', () => { it('sends the plain text with the voice and its locale', () => { expect( diff --git a/src/lib/google/tts.ts b/src/lib/google/tts.ts index 8a8a7c7..c0dbcf2 100644 --- a/src/lib/google/tts.ts +++ b/src/lib/google/tts.ts @@ -79,6 +79,35 @@ export const mimeTypeForFormat = (format?: string): string => export const parseSpeed = (speed?: string | number): number | undefined => parseAudioNumber(speed, 0.25, 4.0); +/** + * アプリから届く読み上げ速度として受け付ける値。アナウンス設定のプリセットと + * 一対一で対応する。任意の値を通すと同じ文が速度違いで R2/KV に無制限へ積み上がり、 + * 合成回数(=文字数課金)もその分だけ増えるため、許可リスト方式にしている。 + * MobileApp の REMOTE_TTS_SPEED_RATES と必ず一致させること。 + */ +export const ALLOWED_CLIENT_SPEEDS = [0.85, 1.0, 1.15] as const; + +// JSON の数値は 1.15 のように二進で表現しきれない値があるため、厳密比較はしない。 +const SPEED_EPSILON = 1e-6; + +/** + * リクエストで指定された読み上げ速度を正規化する。許可リストに無い値・不正な値は + * 「未指定」として扱い、呼び出し側で環境変数の既定値へ倒す。古いアプリは speed を + * 送ってこないため、未指定は正常系であってエラーにはしない。 + */ +export const parseClientSpeed = (value: unknown): number | undefined => { + if (typeof value !== 'number' && typeof value !== 'string') { + return undefined; + } + const parsed = parseSpeed(value); + if (parsed === undefined) { + return undefined; + } + return ALLOWED_CLIENT_SPEEDS.find( + (allowed) => Math.abs(allowed - parsed) < SPEED_EPSILON + ); +}; + /** 声の高さ(セミトーン)を数値へ正規化する。範囲外・非数は未指定として扱う。 */ export const parsePitch = (pitch?: string | number): number | undefined => parseAudioNumber(pitch, -20.0, 20.0); diff --git a/src/routes/tts.ts b/src/routes/tts.ts index f678c14..78c4194 100644 --- a/src/routes/tts.ts +++ b/src/routes/tts.ts @@ -8,6 +8,7 @@ import { import { bytesToBase64, sha256Hex } from '../lib/crypto'; import { normalizeResponseFormat, + parseClientSpeed, parsePitch, parseSpeed, synthesizeSpeech, @@ -32,6 +33,8 @@ interface TtsRequest { textEn?: unknown; jaVoiceName?: unknown; enVoiceName?: unknown; + // アナウンス設定で選んだ読み上げ速度。プリセット外の値は無視される + speed?: unknown; } interface TtsConfig { @@ -170,7 +173,9 @@ export const handleTts = async ( // 環境変数は文字列なので、送信前に正規化した値を作る。この正規化後の値を // そのままキャッシュキーにも使い、設定変更が確実に別 ID になるようにする。 const responseFormat = normalizeResponseFormat(env.TTS_RESPONSE_FORMAT); - const speed = parseSpeed(env.TTS_SPEED); + // 速度はアプリの設定を優先し、未指定・プリセット外なら環境変数の既定値を使う。 + // 速度は computeId に含まれるため、選択が変わればキャッシュも自動的に分かれる。 + const speed = parseClientSpeed(data.speed) ?? parseSpeed(env.TTS_SPEED); const pitch = parsePitch(env.TTS_PITCH); const ttsOptions: TtsOptions = { responseFormat, speed, pitch }; diff --git a/wrangler.jsonc b/wrangler.jsonc index d064ade..668ee07 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -51,11 +51,13 @@ // Standard 系は端末内蔵 TTS と同水準の音質で単価も最安。より高品質にするなら // 末尾記号はそのまま Wavenet / Neural2 へ差し替える(例: ja-JP-Wavenet-B)。 // 読み方のプロンプト指示は Standard 系に無いため、速さは TTS_SPEED - // (speakingRate)、高さは TTS_PITCH で調整する。 + // (speakingRate)、高さは TTS_PITCH で調整する。TTS_SPEED はアプリが速度を + // 送ってこなかったときの既定値で、送ってきた場合はそちら(プリセット3値のみ)が優先。 "TTS_JA_VOICE_NAME": "ja-JP-Standard-B", "TTS_EN_VOICE_NAME": "en-US-Standard-G", - // 車内自動放送らしく早口寄りにする(1.0 が等速。旧実装は無指定=1.0 だった) - "TTS_SPEED": "1.15", + // アプリのアナウンス速度設定(既定「普通」)と同じ等速。速度を送ってこない + // 旧バージョンのアプリも、設定の既定値と同じ速さで聞こえるようにしている + "TTS_SPEED": "1.0", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads-dev.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", @@ -116,10 +118,11 @@ // dev と同じ理由で大型モデルを使う(日本語タイトルの破損対策) "AI_TRIAGE_MODEL": "@cf/google/gemma-4-26b-a4b-it", // --- TTS(/tts)--- - // Google Cloud TTS。本番は dev より控えめの「やや速め」に合わせる + // Google Cloud TTS。速度はアプリのアナウンス速度設定(既定「普通」)に + // 合わせた等速。dev と本番で差を付けると開発時の体感が本番と食い違う "TTS_JA_VOICE_NAME": "ja-JP-Standard-B", "TTS_EN_VOICE_NAME": "en-US-Standard-G", - "TTS_SPEED": "1.1", + "TTS_SPEED": "1.0", "SESSION_TOKEN_TTL_SECONDS": "3600", "UPLOAD_PUBLIC_BASE_URL": "https://uploads.trainlcd.app", "FEW_SHOT_KV_KEY": "config:fewshot", From 06ce6bcab6dc9e450af2a414197ca2e02e58bae2 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 27 Aug 2026 14:18:55 +0900 Subject: [PATCH 10/16] =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E3=81=AE=E3=82=AD=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=AB=20dead=20letter=20queue=20=E3=82=92=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * フィードバックのキューに dead letter queue を設定する consumer は max_retries のみで、使い切ったメッセージの退避先が無かった。 processFeedbackMessage() は queue に retry させるためエラーを再送出するが、 権限やリポジトリ設定のような恒久的な原因だと retry しても回復しないため、 3 回失敗した時点でメッセージごと破棄され、フィードバックが失われていた。 dev / production 両方の consumer に dead_letter_queue を追加する。DLQ 側に consumer は付けない。同じハンドラを回しても同じ理由で落ちるだけで、復旧は 原因を直してから手動で流し直すのが正しい。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018n3eJcHFb6E2BDzPCcwKZc * DLQ の保持期限と再実行手順をドキュメントに追加する CodeRabbit のレビュー指摘への対応。 - DLQ にも保持期限があり、期限切れでメッセージが消えることを明記した。 DLQ を置いただけでは「失わない」保証にならず、再実行には期限がある。 - 再実行用の一時 consumer には別の DLQ を付けるよう明記した。再実行は同じ ハンドラを回すため、DLQ が無いと max_retries 超過でそのまま削除される。 - Setup のリソース作成コマンドが dev 名のみだったので、production 側の 名前について補足した。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018n3eJcHFb6E2BDzPCcwKZc * DLQ の保持期限更新手順を dev 側にも適用する形にする CodeRabbit のレビュー指摘への対応。保持期限はキュー単位の設定で、 feedback-triage-dev-dlq が prod 側の設定を継承することはないため、 コマンド例を両方のキューを回す形に変えた。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018n3eJcHFb6E2BDzPCcwKZc * DLQ 再実行の CLI 手順を README に追加する CodeRabbit のレビュー指摘(outside diff)への対応。復旧手順が散文だけで コマンドが無かったため、dev / production 双方のキュー名・スクリプト名で 実行できる形にした。一時 quarantine DLQ の作成、consumer add での接続、 排出後の consumer remove までを含む。 保持期限の確認については、wrangler 4.103 の `queues info` が retention を 表示しないため、CLI では読み出せない旨を明記した(指摘では `queues info` で確認する手順が求められていたが、実際には出力されない)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018n3eJcHFb6E2BDzPCcwKZc * DLQ 手順の保持期限・バッチサイズ・quarantine の後始末を補う CodeRabbit のレビュー指摘への対応。 - 保持期限を RETENTION 変数にまとめ、free tier では 86400 が上限で 1209600 は弾かれることを明記した。 - quarantine DLQ の作成にも --message-retention-period-secs を渡すように した。指定しないとアカウント既定に落ちる。 - 再実行用 consumer に --batch-size 5 を付けた。既定の 10 は 1 メッセージ あたり 5〜17 秒の推論が入る本ワーカーには 1 起動あたりの負荷が大きい。 - quarantine DLQ は incident 後も残るため、再利用条件と queues delete に よる後始末を追記した。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018n3eJcHFb6E2BDzPCcwKZc * 再実行手順のブロック内で RETENTION を定義する CodeRabbit のレビュー指摘への対応。RETENTION を保持期限の設定ブロック側 だけで定義していたため、再実行手順のブロックだけをコピーして実行すると 空になり、quarantine DLQ に保持期限が渡らなかった。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018n3eJcHFb6E2BDzPCcwKZc --------- Co-authored-by: Claude Opus 5 --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++- wrangler.jsonc | 19 ++++++++++-- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c559661..e9c853f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ single Worker. - **Cloudflare Workers** — `fetch` / `queue` / `scheduled` handlers - **Workers KV** — TTS cache metadata, config, and review read-state - **R2** — audio binaries and feedback images -- **Cloudflare Queues** — `feedback-triage` +- **Cloudflare Queues** — `feedback-triage` (+ `feedback-triage-dlq` as its dead letter queue) - **Workers AI** — feedback triage - **Google Cloud Text-to-Speech** — TTS synthesis (`Standard` voices, service-account auth) - **OpenAI** — the conversational agent @@ -57,8 +57,14 @@ wrangler r2 bucket create trainlcd-tts-dev wrangler r2 bucket create trainlcd-uploads-dev # Queues wrangler queues create feedback-triage-dev +wrangler queues create feedback-triage-dev-dlq # dead letter queue (no consumer) ``` +The names above are the dev ones. Production uses the same set without the +`-dev` suffix (`trainlcd-tts`, `trainlcd-uploads`, `feedback-triage`, +`feedback-triage-dlq`); create those too if you are setting up prod from +scratch. Both environments' resources already exist on the TrainLCD account. + ### Setting secrets ```bash @@ -264,6 +270,77 @@ completes in 5–17 s at 26–62 neurons per feedback, versus ~1 s and ~4.7 neur for the old 8B model. Queue consumers use `max_batch_size: 5`, so a batch stays well inside the invocation limit. +### Dead letter queue + +`processFeedbackMessage()` rethrows on failure so the consumer can `retry()`, +but retrying does not help when the cause is permanent — a credential that no +longer grants access, a repo that was renamed. Without a dead letter queue the +message is simply dropped once `max_retries: 3` is exhausted, and the feedback +is lost for good. + +The consumers therefore declare `dead_letter_queue` (`feedback-triage-dlq`, and +`feedback-triage-dev-dlq` for dev). The DLQ intentionally has **no consumer** — +running the same handler against it would fail for the same reason. + +**A DLQ is not archival storage.** Messages sitting in it expire on the queue's +retention period, which both DLQs inherit from the account default (4 days on a +paid plan, and not extendable beyond 24 h on the free plan). That is the replay +deadline: once it passes the feedback is gone just as surely as it was before +this queue existed. Check and extend it if an incident may outlast it: + +```bash +# 1209600 = 14 days, the paid-plan maximum. The free tier is capped at 86400 +# (24 h) and rejects anything above it, so use that value instead on free. +# Run this for the dev DLQ too — retention is per queue, and +# feedback-triage-dev-dlq inherits nothing from the prod one. +RETENTION=1209600 +for q in feedback-triage-dlq feedback-triage-dev-dlq; do + wrangler queues update "$q" --message-retention-period-secs "$RETENTION" +done +``` + +`wrangler queues info` does not print the retention period (as of wrangler 4.103), +so there is no CLI read-back for it — set it explicitly, or check the dashboard. + +To recover, fix the root cause first, then replay by temporarily attaching a +consumer to the DLQ. Give that consumer its own dead letter queue — a replay +consumer runs the same handler, so anything still failing would hit `max_retries` +and be deleted outright, which is the exact loss this section exists to prevent. + +```bash +# production. For dev: DLQ=feedback-triage-dev-dlq, SCRIPT=trainlcd-worker-dev +DLQ=feedback-triage-dlq +SCRIPT=trainlcd-worker +RETENTION=1209600 # 86400 on the free tier, as above + +wrangler queues info "$DLQ" # backlog size, current consumers + +# Catches whatever still fails on replay. Give it the same retention as the DLQ, +# otherwise it silently falls back to the account default. +wrangler queues create "$DLQ-quarantine" --message-retention-period-secs "$RETENTION" + +# --batch-size 5 matches the max_batch_size the regular consumer runs with; the +# default of 10 is a lot for one invocation at 5–17 s of inference per message. +wrangler queues consumer add "$DLQ" "$SCRIPT" \ + --dead-letter-queue "$DLQ-quarantine" --batch-size 5 + +# ...wait for the backlog to drain, then detach: +wrangler queues consumer remove "$DLQ" "$SCRIPT" +``` + +The replay consumer is deliberately not declared in `wrangler.jsonc` — it exists +only for the duration of an incident, so nothing will remove it for you. Leaving +it attached means every later failure gets reprocessed by it instead of landing +in the DLQ where you can see it. + +`$DLQ-quarantine` outlives the incident as well. `queues create` fails if it +already exists, so on the next incident either reuse it (confirm it is empty +first — anything left in it is unprocessed feedback) or `wrangler queues delete` +it once you have dealt with whatever landed there. + +Note that DLQ messages carry the full feedback payload, so the DLQ is subject to +the same handling rules as the private `TrainLCD/Issues` repo. + ## Public repo routing Feedback Issues are always created in the private `TrainLCD/Issues` repo with diff --git a/wrangler.jsonc b/wrangler.jsonc index 668ee07..f2389ae 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -26,12 +26,22 @@ { "binding": "UPLOAD_BUCKET", "bucket_name": "trainlcd-uploads-dev" } ], + // dead_letter_queue: max_retries を使い切ったメッセージの退避先。未設定だと + // GitHub 起票が恒久的に失敗する状況(PAT 失効など)でフィードバックが消えるため必須。 + // DLQ 側に consumer は付けない(同じ処理を回すと同じ理由で落ちるだけ)。 + // DLQ にも保持期限があり、期限を過ぎたメッセージは消える。復旧手順と + // 保持期限の延ばし方は README の「Dead letter queue」節を参照。 "queues": { "producers": [ { "binding": "FEEDBACK_QUEUE", "queue": "feedback-triage-dev" } ], "consumers": [ - { "queue": "feedback-triage-dev", "max_batch_size": 5, "max_retries": 3 } + { + "queue": "feedback-triage-dev", + "max_batch_size": 5, + "max_retries": 3, + "dead_letter_queue": "feedback-triage-dev-dlq" + } ] }, @@ -109,7 +119,12 @@ { "binding": "FEEDBACK_QUEUE", "queue": "feedback-triage" } ], "consumers": [ - { "queue": "feedback-triage", "max_batch_size": 5, "max_retries": 3 } + { + "queue": "feedback-triage", + "max_batch_size": 5, + "max_retries": 3, + "dead_letter_queue": "feedback-triage-dlq" + } ] }, "services": [{ "binding": "STATION_API", "service": "stationapi" }], From 631398bec2f8489fb4fc94da730eb7fe294a3bd7 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 27 Aug 2026 14:26:34 +0900 Subject: [PATCH 11/16] =?UTF-8?q?DLQ=20=E3=81=AE=E7=B4=90=E4=BB=98?= =?UTF-8?q?=E3=81=91=E3=82=92=E7=A2=BA=E8=AA=8D=E3=81=99=E3=82=8B=E6=89=8B?= =?UTF-8?q?=E9=A0=86=E3=82=92=20README=20=E3=81=AB=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DLQ への移送は Cloudflare 側が内部で行うため、正しく設定されていても `wrangler queues list` 上の DLQ は producer / consumer とも 0 のままになる。 紐付けは移送元キューの consumer 設定に載るので、確認は `wrangler queues consumer list <移送元キュー>` で行う必要がある。 設定は deploy して初めて反映される点も併記した。dead_letter_queue が "-" のままなら、その環境は今も retry 枯渇でメッセージを捨てている。 Claude-Session: https://claude.ai/code/session_018n3eJcHFb6E2BDzPCcwKZc Co-authored-by: Claude Opus 5 --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index e9c853f..4934833 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,18 @@ The consumers therefore declare `dead_letter_queue` (`feedback-triage-dlq`, and `feedback-triage-dev-dlq` for dev). The DLQ intentionally has **no consumer** — running the same handler against it would fail for the same reason. +Do not look for the wiring on the DLQ itself. Moving a message into a DLQ is +something Cloudflare does internally, not something the Worker sends, so a +correctly configured DLQ still reports zero producers and zero consumers in +`wrangler queues list`. The link lives on the **source** queue's consumer, and it +only exists once the config has been deployed: + +```bash +wrangler queues consumer list feedback-triage-dev # or feedback-triage for prod +# dead_letter_queue must name the DLQ; "-" means this environment is still +# dropping messages once max_retries is exhausted. +``` + **A DLQ is not archival storage.** Messages sitting in it expire on the queue's retention period, which both DLQs inherit from the account default (4 days on a paid plan, and not extendable beyond 24 h on the free plan). That is the replay From 8c47ab7c30eb5fa4672a67f248c9c58afb6d8c92 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Fri, 28 Aug 2026 22:17:33 +0900 Subject: [PATCH 12/16] =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E5=86=8D=E8=A9=A6=E8=A1=8C=E3=81=A7?= =?UTF-8?q?=E3=81=AE=20Issue=20=E9=87=8D=E8=A4=87=E8=B5=B7=E7=A5=A8?= =?UTF-8?q?=E3=82=92=E9=98=B2=E3=81=90=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 69 +++ src/consumers/feedbackTriage.test.ts | 288 ++++++++++++ src/consumers/feedbackTriage.ts | 639 +++++++++++++++++++++------ src/index.ts | 10 +- 4 files changed, 864 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 4934833..85329f6 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,75 @@ it once you have dealt with whatever landed there. Note that DLQ messages carry the full feedback payload, so the DLQ is subject to the same handling rules as the private `TrainLCD/Issues` repo. +### Retry idempotency + +A retry re-runs `processFeedbackMessage()` from the top, so anything that throws +*after* the Issue has been created files the same feedback again — up to four +Issues with `max_retries: 3`, plus one more for every DLQ replay. + +To prevent that, the consumer keeps a per-report marker in `STATE_KV` under +`feedbackTriage:processed:` (30-day TTL, long enough to cover a DLQ +replay). It records the created Issue number and URL, the public stub URL, the +triage result, and whether the Discord notification went out. The marker decides +what each delivery still has to do: + +- **notified** — nothing. The message is acked and dropped. +- **Issue created, not notified** — skip triage and Issue creation, re-send the + Discord notification only. The stored triage result is reused instead of being + re-inferred, so the notification matches the Issue that was already filed, and + the retry costs no Workers AI neurons. +- **no marker** — the full path, writing the marker as soon as the Issue exists. + +Nothing between the Issue being created and the marker being written may throw, +because a throw there is a retry with no marker to stop it. So a malformed +Issue-creation response and a failed marker write are logged and swallowed, and +`notifyDiscord()` turns every failure into a return value instead of an +exception — including `fetch()` itself rejecting on a network or DNS error, +which is what made this reachable in practice. + +Past that point a throw is safe, and one is deliberate. The marker records +whether Discord actually accepted the request, so a failed notification is saved +as `notified: false` and *then* rethrown as `FeedbackNotifyError`, which retries +the message: the retry reads the marker, skips straight to the notification, and +leaves the Issue alone. Retrying the handler for a Discord outage is exactly +what used to duplicate Issues — the marker is what makes it safe now. A +notification that never succeeds ends up in the DLQ after `max_retries`, which +is how a broken webhook becomes visible. + +The one case that is *not* retried is a notification failure where the marker +write also failed. Without the marker a retry would file the Issue again, so the +notification is given up and the message acked — the feedback is on GitHub +either way. + +**KV is not a lock, and the marker read is what makes this work — so the retry +has to be slow enough for the read to see it.** KV caches the *absence* of a key +at the edge for the read's `cacheTtl` (60 s by default), so a retry that runs +immediately after the failure can miss a marker that was written seconds ago and +file the Issue again. The consumer therefore retries with +`message.retry({ delaySeconds: FEEDBACK_RETRY_DELAY_SECONDS })` (90 s) so the +negative cache has expired by the time the marker is read. Changing that +constant without understanding this is how the duplicate comes back. + +The same limit applies to the writes: KV accepts at most one write per second to +a given key, and one report writes that key twice — once when the Issue exists, +once when the notification result is known. A notification that completes in +under a second would make the second write a 429, so the consumer spaces writes +to the same key ~1.1 s apart (and waits that long before its one write retry) +rather than losing the notification state and re-notifying on a replay. + +That covers the sequential retries of one message. It does **not** serialize two +deliveries of the same report racing each other — Cloudflare Queues is +at-least-once, so that race is possible in principle, and with an eventually +consistent read there is nothing to make it safe. Strict de-duplication would +take a per-report claim in a Durable Object (the only strongly consistent option +here), which is a bigger change than the failure it covers. + +One gap stays open by design: if the Issue-creation `fetch()` fails *after* +GitHub has already created the Issue, no marker was written and the retry files +a second one. Closing that would mean searching `TrainLCD/Issues` by ticket ID +before every creation, which costs a request per feedback for a case that needs +GitHub to drop the response of a request it accepted. + ## Public repo routing Feedback Issues are always created in the private `TrainLCD/Issues` repo with diff --git a/src/consumers/feedbackTriage.test.ts b/src/consumers/feedbackTriage.test.ts index a2fd3cd..4d33dde 100644 --- a/src/consumers/feedbackTriage.test.ts +++ b/src/consumers/feedbackTriage.test.ts @@ -1,4 +1,6 @@ import type { AIReport } from '../models/ai'; +import type { Report } from '../models/feedback'; +import type { FeedbackQueueMessage } from '../types'; import { applySpamHeuristic, buildFailedReport, @@ -13,9 +15,11 @@ import { NON_ACTIONABLE_TITLE, PUBLIC_ISSUE_MIN_CONFIDENCE, pickModelResponse, + processFeedbackMessage, resolvePublicIssueRepo, SPAM_OVERRIDE_MAX_CONFIDENCE, TRIAGE_FAILED_SUMMARY, + triageMarkerKey, } from './feedbackTriage'; describe('coerceReport', () => { @@ -629,3 +633,287 @@ describe('pickModelResponse', () => { }); }); }); + +describe('processFeedbackMessage(再試行時の冪等化)', () => { + const ISSUES_API = 'https://api.github.com/repos/TrainLCD/Issues/issues'; + const CS_WEBHOOK = 'https://discord.example.com/webhooks/cs'; + + const AI_JSON = JSON.stringify({ + title: 'タイトル', + summary: '要約', + isSpam: false, + labels: [], + confidence: 0.9, + reason: '理由', + category: 'question', + triageLevel: 'medium', + component: null, + componentConfidence: 0, + }); + + const report: Report = { + id: 'report-1', + reportType: 'feedback', + description: '駅の表示がおかしいので直してほしいです', + stacktrace: undefined, + resolved: false, + resolvedReason: '', + language: 'ja-JP', + appVersion: '1.0.0', + deviceInfo: null, + resolverUid: '', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + reporterUid: 'uid-1', + imageUrl: null, + appEdition: 'production', + appClip: false, + autoModeEnabled: false, + }; + + // KV の同一キー書き込み制限を守るための待ちがテスト間に持ち越されないよう、 + // レポートIDはテストごとに変える。 + let seq = 0; + const makeMessage = (): FeedbackQueueMessage => { + seq += 1; + return { + id: `msg-${seq}`, + receivedAt: '2024-01-01T00:00:00.000Z', + report: { ...report, id: `report-${seq}` }, + version: 1, + }; + }; + + // biome-ignore lint/suspicious/noExplicitAny: テスト用の最小 Env スタブ + type TestEnv = any; + + const createEnv = (): { env: TestEnv; store: Map } => { + const store = new Map(); + return { + env: { + AI: { run: jest.fn().mockResolvedValue({ response: AI_JSON }) }, + CONFIG_KV: { + get: jest + .fn() + .mockResolvedValue('{"input":"入力例","output":"出力例"}'), + }, + STATE_KV: { + get: jest.fn(async (key: string) => store.get(key) ?? null), + put: jest.fn(async (key: string, value: string) => { + store.set(key, value); + }), + }, + AI_TRIAGE_MODEL: 'test-model', + FEW_SHOT_KV_KEY: 'fewshot', + FEW_SHOT_LIMIT: '1', + FEW_SHOT_PER_EX_MAX: '800', + OCTOKIT_PAT: 'pat', + DISCORD_CS_WEBHOOK_URL: CS_WEBHOOK, + DISCORD_CRASH_WEBHOOK_URL: '', + }, + store, + }; + }; + + const marker = (overrides: Record = {}) => + JSON.stringify({ + version: 1, + issueNumber: 42, + issueUrl: 'https://github.com/TrainLCD/Issues/issues/42', + publicIssueUrl: null, + aiReport: JSON.parse(AI_JSON), + triageFailed: false, + needsSpamReview: false, + notified: false, + updatedAt: '2024-01-01T00:00:00.000Z', + ...overrides, + }); + + const originalFetch = global.fetch; + let errorSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + global.fetch = originalFetch; + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + const githubCalls = (fetchMock: jest.Mock) => + fetchMock.mock.calls.filter((c) => String(c[0]) === ISSUES_API); + const discordCalls = (fetchMock: jest.Mock) => + fetchMock.mock.calls.filter((c) => String(c[0]) === CS_WEBHOOK); + + it('起票直後と通知後のマーカー保存を 1 秒以上空ける(KV の同一キー制限)', async () => { + const msg = makeMessage(); + const { env } = createEnv(); + const writeAt: number[] = []; + const originalPut = env.STATE_KV.put; + env.STATE_KV.put = jest.fn(async (key: string, value: string) => { + writeAt.push(Date.now()); + return originalPut(key, value); + }); + const fetchMock = jest.fn(async (input: unknown) => { + if (String(input) === ISSUES_API) { + return new Response( + JSON.stringify({ + html_url: 'https://github.com/TrainLCD/Issues/issues/42', + number: 42, + }), + { status: 201 } + ); + } + return new Response(null, { status: 204 }); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + await processFeedbackMessage(msg, env); + + expect(writeAt).toHaveLength(2); + expect(writeAt[1] - writeAt[0]).toBeGreaterThanOrEqual(1000); + }); + + it('Discord への fetch が throw したら未通知として再試行に回す(起票は 1 回だけ)', async () => { + const msg = makeMessage(); + const { env, store } = createEnv(); + const fetchMock = jest.fn(async (input: unknown) => { + if (String(input) === ISSUES_API) { + return new Response( + JSON.stringify({ + html_url: 'https://github.com/TrainLCD/Issues/issues/42', + number: 42, + }), + { status: 201 } + ); + } + throw new TypeError('network error'); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(processFeedbackMessage(msg, env)).rejects.toThrow( + 'Discord notification failed' + ); + + expect(githubCalls(fetchMock)).toHaveLength(1); + const saved = JSON.parse(store.get(triageMarkerKey(msg.report.id)) ?? '{}'); + expect(saved.issueNumber).toBe(42); + // 未通知のまま残し、再試行では起票を飛ばして通知だけやり直す + expect(saved.notified).toBe(false); + }); + + it('通知にもマーカー保存にも失敗したら再試行しない(重複起票に戻るため)', async () => { + const msg = makeMessage(); + const { env } = createEnv(); + env.STATE_KV.put = jest.fn(async () => { + throw new Error('KV unavailable'); + }); + const fetchMock = jest.fn(async (input: unknown) => { + if (String(input) === ISSUES_API) { + return new Response( + JSON.stringify({ + html_url: 'https://github.com/TrainLCD/Issues/issues/42', + number: 42, + }), + { status: 201 } + ); + } + throw new TypeError('network error'); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(processFeedbackMessage(msg, env)).resolves.toBeUndefined(); + + expect(githubCalls(fetchMock)).toHaveLength(1); + }); + + it('Discord が HTTP エラーを返したときも未通知のまま記録する', async () => { + const msg = makeMessage(); + const { env, store } = createEnv(); + store.set(triageMarkerKey(msg.report.id), marker()); + const fetchMock = jest.fn( + async () => new Response('rate limited', { status: 429 }) + ); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(processFeedbackMessage(msg, env)).rejects.toThrow( + 'Discord notification failed' + ); + + expect(discordCalls(fetchMock)).toHaveLength(1); + expect( + JSON.parse(store.get(triageMarkerKey(msg.report.id)) ?? '{}').notified + ).toBe(false); + }); + + it('起票済みマーカーがあれば Issue を作り直さず、通知だけやり直す', async () => { + const msg = makeMessage(); + const { env, store } = createEnv(); + store.set(triageMarkerKey(msg.report.id), marker()); + const fetchMock = jest.fn(async () => new Response(null, { status: 204 })); + global.fetch = fetchMock as unknown as typeof fetch; + + await processFeedbackMessage(msg, env); + + expect(githubCalls(fetchMock)).toHaveLength(0); + // 再試行でトリアージをやり直すと Issue と通知の内容がずれるため、AI も呼ばない + expect(env.AI.run).not.toHaveBeenCalled(); + expect(discordCalls(fetchMock)).toHaveLength(1); + expect( + JSON.parse(store.get(triageMarkerKey(msg.report.id)) ?? '{}').notified + ).toBe(true); + }); + + it('マーカー保存が一度失敗しても書き直し、throw しない', async () => { + const msg = makeMessage(); + const { env, store } = createEnv(); + store.set(triageMarkerKey(msg.report.id), marker()); + let puts = 0; + env.STATE_KV.put = jest.fn(async (key: string, value: string) => { + puts += 1; + if (puts === 1) throw new Error('KV unavailable'); + store.set(key, value); + }); + const fetchMock = jest.fn(async () => new Response(null, { status: 204 })); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(processFeedbackMessage(msg, env)).resolves.toBeUndefined(); + + expect(puts).toBe(2); + expect( + JSON.parse(store.get(triageMarkerKey(msg.report.id)) ?? '{}').notified + ).toBe(true); + }); + + it('通知まで完了したマーカーがあれば何もしない', async () => { + const msg = makeMessage(); + const { env, store } = createEnv(); + store.set(triageMarkerKey(msg.report.id), marker({ notified: true })); + const fetchMock = jest.fn(async () => new Response(null, { status: 204 })); + global.fetch = fetchMock as unknown as typeof fetch; + + await processFeedbackMessage(msg, env); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(env.AI.run).not.toHaveBeenCalled(); + expect(env.STATE_KV.put).not.toHaveBeenCalled(); + }); + + it('起票前の失敗は再送出し、マーカーを残さない(メッセージを失わないため)', async () => { + const msg = makeMessage(); + const { env, store } = createEnv(); + const fetchMock = jest.fn( + async () => new Response('{"message":"boom"}', { status: 500 }) + ); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(processFeedbackMessage(msg, env)).rejects.toThrow( + 'GitHub API failed with status 500' + ); + expect(store.has(triageMarkerKey(msg.report.id))).toBe(false); + }); +}); diff --git a/src/consumers/feedbackTriage.ts b/src/consumers/feedbackTriage.ts index 7c5aee4..b2e5282 100644 --- a/src/consumers/feedbackTriage.ts +++ b/src/consumers/feedbackTriage.ts @@ -803,13 +803,216 @@ async function linkPublicIssue( } } -export const processFeedbackMessage = async ( - data: FeedbackQueueMessage, - env: Env -): Promise => { - if (!data?.report) return; - const { report } = data; +// ---- 冪等化マーカー(STATE_KV) ---- + +/** + * レポート 1 件の処理状態を STATE_KV に残すマーカー。 + * + * GitHub Issue の作成後に例外が出ると queue が再試行し、同じフィードバックで + * Issue がもう 1 件作られてしまう。report.id をキーに「どこまで終わったか」を + * 永続化しておき、再試行では済んだ工程を飛ばす。 + * + * 再試行のたびに AI を呼び直すとトリアージ結果がぶれ、起票済み Issue と通知の + * 内容がずれるため、トリアージ結果もマーカーに含めて再利用する。 + */ +export type TriageMarker = { + version: 1; + /** 非公開リポジトリに作成した Issue 番号(レスポンスの解析に失敗したときは null) */ + issueNumber: number | null; + /** 作成した Issue の URL(同上) */ + issueUrl: string | null; + /** 公開リポジトリに作成したスタブ Issue の URL(作っていなければ null) */ + publicIssueUrl: string | null; + aiReport: AIReport; + triageFailed: boolean; + needsSpamReview: boolean; + /** Discord 通知まで完了しているか */ + notified: boolean; + updatedAt: string; +}; + +/** + * マーカーの保持期間。queue の再試行自体は数分で終わるが、DLQ に落ちたメッセージを + * 後日手動で流し直すことがあるため長めに取る。 + */ +export const TRIAGE_MARKER_TTL_SECONDS = 60 * 60 * 24 * 30; + +/** + * Discord 通知だけが失敗したことを示す。queue ハンドラはこれを受けて再試行し、 + * 再試行はマーカーを見て通知から再開する(Issue は作り直さない)。 + */ +export class FeedbackNotifyError extends Error { + constructor(reportId: string) { + super(`Discord notification failed for report ${reportId}`); + this.name = 'FeedbackNotifyError'; + } +} + +/** 処理済みマーカーの KV キー。 */ +export const triageMarkerKey = (reportId: string): string => + `feedbackTriage:processed:${reportId}`; + +/** + * 処理済みマーカーを読む。KV 障害は握り潰さず上位へ伝播させる(=再試行させる)。 + * ここで null に倒すと重複起票を防ぐという目的そのものを損なうため。 + * まだ副作用を出していない地点なので、throw しても Issue は重複しない。 + */ +async function loadTriageMarker( + env: Env, + reportId: string +): Promise { + const raw = await env.STATE_KV.get(triageMarkerKey(reportId), 'text'); + if (!raw) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.error('feedbackTriage: 処理済みマーカーが壊れているため無視する', { + reportId, + }); + return null; + } + if (!parsed || typeof parsed !== 'object') return null; + + const marker = parsed as Partial; + // aiReport を失っているマーカーは通知を組み立て直せないので無効扱いにする。 + if (!marker.aiReport || typeof marker.aiReport !== 'object') { + console.error( + 'feedbackTriage: 処理済みマーカーの内容が不正なため無視する', + { + reportId, + } + ); + return null; + } + + return { + version: 1, + issueNumber: + typeof marker.issueNumber === 'number' ? marker.issueNumber : null, + issueUrl: typeof marker.issueUrl === 'string' ? marker.issueUrl : null, + publicIssueUrl: + typeof marker.publicIssueUrl === 'string' ? marker.publicIssueUrl : null, + aiReport: marker.aiReport, + triageFailed: marker.triageFailed === true, + needsSpamReview: marker.needsSpamReview === true, + notified: marker.notified === true, + updatedAt: + typeof marker.updatedAt === 'string' + ? marker.updatedAt + : new Date().toISOString(), + }; +} +/** マーカー保存の試行回数。KV の一過性エラーで冪等化の記録を落とさないため。 */ +const SAVE_MARKER_ATTEMPTS = 2; + +/** + * 同一マーカーキーへの書き込みを空ける間隔。KV は同一キーへの書き込みを 1 秒に + * 1 回までしか受け付けず、超えると 429 になる。 + */ +const SAVE_MARKER_MIN_INTERVAL_MS = 1100; + +/** 同一キーに最後に書き込めた時刻。次の書き込みを 1 秒以上空けるために持つ。 */ +const lastMarkerWriteAt = new Map(); + +/** + * 失敗したメッセージを再試行に回すまでの待ち時間。 + * + * KV はキーが無かったという結果も cacheTtl(既定 60 秒)の間エッジにキャッシュ + * するため、遅延なしで再試行すると、起票直後に書いたマーカーを読めずに + * Issue を作り直してしまう。ネガティブキャッシュが切れてから再試行させる。 + */ +export const FEEDBACK_RETRY_DELAY_SECONDS = 90; + +/** 同一キーへの書き込み間隔が 1 秒未満にならないよう、必要なぶんだけ待つ。 */ +async function waitForMarkerWriteWindow( + key: string, + extraWaitMs = 0 +): Promise { + const lastAt = lastMarkerWriteAt.get(key); + const sinceLastWrite = + lastAt === undefined ? Number.POSITIVE_INFINITY : Date.now() - lastAt; + const waitMs = Math.max( + SAVE_MARKER_MIN_INTERVAL_MS - sinceLastWrite, + extraWaitMs + ); + if (waitMs <= 0) return; + await new Promise((resolve) => setTimeout(resolve, waitMs)); +} + +/** + * 処理済みマーカーを書く。ここで throw すると「Issue は作成済みなのに再試行される」 + * という、まさに防ぎたい状態を作ってしまうため、失敗はログに留めて false を返す。 + * 呼び出し側は、マーカーを残せたかどうかで再試行してよいかを判断する。 + * + * 書けなかったマーカーはそのまま重複起票の窓になるので、諦める前に一度だけ + * 書き直す(KV の書き込み失敗は一過性のことが多い)。 + * + * 1 件のレポートでは、起票直後(notified: false)と通知後(notified の実結果)の + * 2 回、同じキーに書く。通知が 1 秒以内に終わると KV の同一キー書き込み制限に + * かかるため、間隔が足りなければ待ってから書く。 + */ +async function saveTriageMarker( + env: Env, + reportId: string, + marker: Omit +): Promise { + const key = triageMarkerKey(reportId); + for (let attempt = 1; attempt <= SAVE_MARKER_ATTEMPTS; attempt++) { + await waitForMarkerWriteWindow( + key, + attempt > 1 ? SAVE_MARKER_MIN_INTERVAL_MS : 0 + ); + const value: TriageMarker = { + version: 1, + ...marker, + updatedAt: new Date().toISOString(), + }; + try { + await env.STATE_KV.put(key, JSON.stringify(value), { + expirationTtl: TRIAGE_MARKER_TTL_SECONDS, + }); + lastMarkerWriteAt.set(key, Date.now()); + pruneMarkerWriteTimes(); + return true; + } catch (err) { + console.error('feedbackTriage: 処理済みマーカーの保存に失敗', { + reportId, + attempt, + maxAttempts: SAVE_MARKER_ATTEMPTS, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return false; +} + +/** 書き込み時刻の記録が isolate に溜まり続けないよう、間隔を過ぎたものを捨てる。 */ +function pruneMarkerWriteTimes(): void { + const now = Date.now(); + for (const [key, at] of lastMarkerWriteAt) { + if (now - at >= SAVE_MARKER_MIN_INTERVAL_MS) lastMarkerWriteAt.delete(key); + } +} + +// ---- トリアージ ---- + +type TriageOutcome = { + aiReport: AIReport; + triageFailed: boolean; + needsSpamReview: boolean; +}; + +/** + * フィードバック本文を AI でトリアージする。生成に失敗しても throw せず、 + * 「要約失敗」レポートに倒して原文を保全する(フィードバックを捨てないため)。 + */ +async function triageFeedback( + env: Env, + report: Report +): Promise { const fewshot = await getFewShotText(env); // 生成 → 最初のバランスした JSON を抽出。失敗したら厳格モードで数回まで再生成する。 @@ -901,6 +1104,43 @@ export const processFeedbackMessage = async ( ); } + return { aiReport, triageFailed, needsSpamReview }; +} + +// ---- Discord 通知 ---- + +/** + * Discord へ通知する。GitHub Issue の作成後に呼ばれるため、ここで throw すると + * queue ハンドラが再試行し、同一レポートで Issue が重複作成される。 + * webhook URL 未設定・HTTP エラーに加え、fetch 自体の失敗(ネットワーク断・DNS + * 失敗・不正な URL)も含めて、あらゆる失敗をログに留めて握り潰す。 + * + * 戻り値は「通知を送り終えたか」。false のときは処理済みマーカーを未通知のまま + * 残し、メッセージを再投入したときに通知だけやり直せるようにする。 + */ +async function notifyDiscord( + env: Env, + params: { + report: Report; + aiReport: AIReport; + shouldTagTriage: boolean; + categoryLabel?: string; + triageLabel?: string; + autoModeLabel?: string; + issueUrl: string | null; + publicIssueUrl: string | null; + } +): Promise { + const { + report, + aiReport, + shouldTagTriage, + categoryLabel, + triageLabel, + autoModeLabel, + issueUrl, + publicIssueUrl, + } = params; const { id, createdAt, @@ -912,134 +1152,14 @@ export const processFeedbackMessage = async ( stacktrace, reportType, imageUrl, - appEdition, - appClip, autoModeEnabled, sentryEventId, } = report; - const createdAtText = dayjs(createdAt).format('YYYY/MM/DD HH:mm:ss'); - const osNameLabel = (() => { - if (deviceInfo?.osName === 'iOS') return GITHUB_LABELS.PLATFORM_IOS; - if (deviceInfo?.osName === 'iPadOS') return GITHUB_LABELS.PLATFORM_IPADOS; - if (deviceInfo?.osName === 'Android') return GITHUB_LABELS.PLATFORM_ANDROID; - return GITHUB_LABELS.PLATFORM_OTHER_OS; - })(); - - const autoModeLabel = autoModeEnabled - ? GITHUB_LABELS.AUTOMODE_ENABLED - : undefined; - - // トリアージ生成に失敗したときは誤ったカテゴリ/優先度を付けない。 - const shouldTagTriage = - reportType === 'feedback' && !aiReport.isSpam && !triageFailed; - const categoryLabel = shouldTagTriage - ? CATEGORY_LABELS[aiReport.category] - : undefined; - const triageLabel = shouldTagTriage - ? TRIAGE_LABELS[aiReport.triageLevel] - : undefined; - try { - const res = await githubPost(env, `${INTERNAL_REPO}/issues`, { - title: aiReport.title ?? '要約未取得', - body: ` -![Image](${imageUrl}) - - -${'```'} -${description} -${'```'} - -## AIによる要約 -${aiReport.summary} - -## チケットID -${id} - -## 発行日時 -${createdAtText} - -## 端末モデル名 -${deviceInfo?.brand} ${deviceInfo?.modelName}(${deviceInfo?.modelId}) - -## 端末のOS -${deviceInfo?.osName} ${deviceInfo?.osVersion} - -## 端末設定言語 -${deviceInfo?.locale} - -## アプリの設定言語 -${language} - -## アプリのバージョン -${appVersion} - -## オートモード -${autoModeEnabled ? '有効' : '無効'} - -## スタックトレース -${'```'} -${stacktrace} -${'```'} - -## Sentry Event ID -${sentryEventId} - -## レポーターUID -${reporterUid} - `.trim(), - assignees: ['TinyKitten'], - milestone: null, - labels: [ - reportType === 'feedback' && - !aiReport.isSpam && - GITHUB_LABELS.FEEDBACK_TYPE, - reportType === 'crash' && GITHUB_LABELS.CRASH_TYPE, - appEdition === 'production' && GITHUB_LABELS.PRODUCTION_APP, - appEdition === 'canary' && GITHUB_LABELS.CANARY_APP, - appClip && GITHUB_LABELS.PLATFORM_APPCLIP, - aiReport.isSpam && GITHUB_LABELS.SPAM_TYPE, - (triageFailed || needsSpamReview) && GITHUB_LABELS.UNKNOWN_TYPE, - osNameLabel, - autoModeLabel, - categoryLabel, - triageLabel, - ].filter(Boolean), - }); - - if (res.status !== 201) { - console.error(await res.json()); - throw new Error(`GitHub API failed with status ${res.status}`); - } - - const issuesRes = (await res.json()) as { - html_url: string; - number: number; - }; - - // 原因コンポーネントが特定できている場合のみ、該当の公開リポジトリにも起票する。 - // 公開側に載せるのは管理 Issue 番号とチケットIDだけで、フィードバックの内容は含めない。 - // 起票後は管理 Issue 側にもコメントでリンクを残し、双方向に追えるようにする。 - const publicRepo = resolvePublicIssueRepo(aiReport, { - reportType, - triageFailed, - needsSpamReview, - }); - let publicIssueUrl: string | null = null; - if (publicRepo) { - publicIssueUrl = await createPublicIssue(env, { - repo: publicRepo, - internalIssueNumber: issuesRes.number, - ticketId: id, - }); - if (publicIssueUrl) { - await linkPublicIssue(env, issuesRes.number, publicIssueUrl); - } - } - const csWHUrl = env.DISCORD_CS_WEBHOOK_URL; const crashWHUrl = env.DISCORD_CRASH_WEBHOOK_URL; + const issueUrlText = issueUrl ?? '不明'; const embeds: DiscordEmbed[] = deviceInfo ? [ { @@ -1074,7 +1194,7 @@ ${reporterUid} autoModeLabel ?? (autoModeEnabled === false ? '無効' : '不明'), }, - { name: 'GitHub Issue', value: issuesRes.html_url }, + { name: 'GitHub Issue', value: issueUrlText }, ...(publicIssueUrl ? [{ name: '公開リポジトリ Issue', value: publicIssueUrl }] : []), @@ -1106,7 +1226,7 @@ ${reporterUid} autoModeLabel ?? (autoModeEnabled === false ? '無効' : '不明'), }, - { name: 'GitHub Issue', value: issuesRes.html_url }, + { name: 'GitHub Issue', value: issueUrlText }, ...(publicIssueUrl ? [{ name: '公開リポジトリ Issue', value: publicIssueUrl }] : []), @@ -1123,14 +1243,11 @@ ${reporterUid} .slice(0, 10) .join('\n')}\n${stacktraceTooLong ? '...' : ''}\`\`\``; - // 注意: ここから先(GitHub Issue 作成後)の Discord 通知は失敗しても throw しない。 - // throw すると queue ハンドラが retry し、同一レポートで Issue が重複作成されるため、 - // 通知の失敗・URL 未設定はログに留める。 switch (reportType) { case 'feedback': { if (!csWHUrl) { console.error('DISCORD_CS_WEBHOOK_URL is not set; skipping notify'); - break; + return false; } const whRes = await fetch(csWHUrl, { method: 'POST', @@ -1146,15 +1263,16 @@ ${reporterUid} if (!whRes.ok) { const msg = await whRes.text().catch(() => ''); console.error('Discord CS webhook failed', whRes.status, msg); + return false; } - break; + return true; } case 'crash': { if (!crashWHUrl) { console.error( 'DISCORD_CRASH_WEBHOOK_URL is not set; skipping notify' ); - break; + return false; } const whRes = await fetch(crashWHUrl, { method: 'POST', @@ -1164,15 +1282,256 @@ ${reporterUid} if (!whRes.ok) { const msg = await whRes.text().catch(() => ''); console.error('Discord Crash webhook failed', whRes.status, msg); + return false; } - break; + return true; } default: - break; + // 通知先のない種別。送るものがないので「通知済み」として扱う。 + return true; } } catch (err) { - // 握りつぶすと queue ハンドラが ack してメッセージを失うため、再送出して再試行させる - console.error(err); - throw err; + // fetch 自体の失敗(ネットワークエラー等)。再送出すると Issue が重複するため握り潰す。 + console.error('feedbackTriage: Discord 通知に失敗', { + reportId: id, + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} + +export const processFeedbackMessage = async ( + data: FeedbackQueueMessage, + env: Env +): Promise => { + if (!data?.report) return; + const { report } = data; + + const { + id, + createdAt, + description, + deviceInfo, + language, + appVersion, + reporterUid, + stacktrace, + reportType, + imageUrl, + appEdition, + appClip, + autoModeEnabled, + sentryEventId, + } = report; + + // 再試行や DLQ からの再投入で同じレポートが流れてきたとき、Issue を重複起票しない + // ように、処理済みマーカーを見て済んだ工程を飛ばす。 + const marker = await loadTriageMarker(env, id); + if (marker?.notified) { + console.warn( + 'feedbackTriage: 処理済みのレポートを再受信したためスキップする', + { reportId: id, issueNumber: marker.issueNumber } + ); + return; } + + // 起票済みなら AI を呼び直さない。呼び直すと結果がぶれ、起票済み Issue と + // Discord 通知の内容がずれるため、マーカーに残したトリアージ結果を使う。 + const { aiReport, triageFailed, needsSpamReview } = marker + ? { + aiReport: marker.aiReport, + triageFailed: marker.triageFailed, + needsSpamReview: marker.needsSpamReview, + } + : await triageFeedback(env, report); + + const createdAtText = dayjs(createdAt).format('YYYY/MM/DD HH:mm:ss'); + const osNameLabel = (() => { + if (deviceInfo?.osName === 'iOS') return GITHUB_LABELS.PLATFORM_IOS; + if (deviceInfo?.osName === 'iPadOS') return GITHUB_LABELS.PLATFORM_IPADOS; + if (deviceInfo?.osName === 'Android') return GITHUB_LABELS.PLATFORM_ANDROID; + return GITHUB_LABELS.PLATFORM_OTHER_OS; + })(); + + const autoModeLabel = autoModeEnabled + ? GITHUB_LABELS.AUTOMODE_ENABLED + : undefined; + + // トリアージ生成に失敗したときは誤ったカテゴリ/優先度を付けない。 + const shouldTagTriage = + reportType === 'feedback' && !aiReport.isSpam && !triageFailed; + const categoryLabel = shouldTagTriage + ? CATEGORY_LABELS[aiReport.category] + : undefined; + const triageLabel = shouldTagTriage + ? TRIAGE_LABELS[aiReport.triageLevel] + : undefined; + + let issueNumber = marker?.issueNumber ?? null; + let issueUrl = marker?.issueUrl ?? null; + let publicIssueUrl = marker?.publicIssueUrl ?? null; + + if (!marker) { + try { + const res = await githubPost(env, `${INTERNAL_REPO}/issues`, { + title: aiReport.title ?? '要約未取得', + body: ` +![Image](${imageUrl}) + + +${'```'} +${description} +${'```'} + +## AIによる要約 +${aiReport.summary} + +## チケットID +${id} + +## 発行日時 +${createdAtText} + +## 端末モデル名 +${deviceInfo?.brand} ${deviceInfo?.modelName}(${deviceInfo?.modelId}) + +## 端末のOS +${deviceInfo?.osName} ${deviceInfo?.osVersion} + +## 端末設定言語 +${deviceInfo?.locale} + +## アプリの設定言語 +${language} + +## アプリのバージョン +${appVersion} + +## オートモード +${autoModeEnabled ? '有効' : '無効'} + +## スタックトレース +${'```'} +${stacktrace} +${'```'} + +## Sentry Event ID +${sentryEventId} + +## レポーターUID +${reporterUid} + `.trim(), + assignees: ['TinyKitten'], + milestone: null, + labels: [ + reportType === 'feedback' && + !aiReport.isSpam && + GITHUB_LABELS.FEEDBACK_TYPE, + reportType === 'crash' && GITHUB_LABELS.CRASH_TYPE, + appEdition === 'production' && GITHUB_LABELS.PRODUCTION_APP, + appEdition === 'canary' && GITHUB_LABELS.CANARY_APP, + appClip && GITHUB_LABELS.PLATFORM_APPCLIP, + aiReport.isSpam && GITHUB_LABELS.SPAM_TYPE, + (triageFailed || needsSpamReview) && GITHUB_LABELS.UNKNOWN_TYPE, + osNameLabel, + autoModeLabel, + categoryLabel, + triageLabel, + ].filter(Boolean), + }); + + if (res.status !== 201) { + console.error(await res.text().catch(() => '')); + throw new Error(`GitHub API failed with status ${res.status}`); + } + + // ここから先は Issue 作成済み。throw して再試行させると重複起票になるため、 + // レスポンスの解析に失敗しても続行し、分かった範囲をマーカーに残す。 + const created = (await res.json().catch((err: unknown) => { + console.error('feedbackTriage: 起票レスポンスの解析に失敗', { + reportId: id, + error: err instanceof Error ? err.message : String(err), + }); + return null; + })) as { html_url?: string; number?: number } | null; + issueNumber = typeof created?.number === 'number' ? created.number : null; + issueUrl = + typeof created?.html_url === 'string' ? created.html_url : null; + } catch (err) { + // Issue 作成前の失敗。握りつぶすと queue ハンドラが ack してメッセージを失うため、 + // 再送出して再試行させる(この時点では Issue は作られていないので重複しない)。 + console.error(err); + throw err; + } + + // 原因コンポーネントが特定できている場合のみ、該当の公開リポジトリにも起票する。 + // 公開側に載せるのは管理 Issue 番号とチケットIDだけで、フィードバックの内容は含めない。 + // 起票後は管理 Issue 側にもコメントでリンクを残し、双方向に追えるようにする。 + const publicRepo = resolvePublicIssueRepo(aiReport, { + reportType, + triageFailed, + needsSpamReview, + }); + if (publicRepo && issueNumber !== null) { + publicIssueUrl = await createPublicIssue(env, { + repo: publicRepo, + internalIssueNumber: issueNumber, + ticketId: id, + }); + if (publicIssueUrl) { + await linkPublicIssue(env, issueNumber, publicIssueUrl); + } + } + + // 起票済みであることを先に永続化する。この後で落ちても、再試行は通知から再開する。 + await saveTriageMarker(env, id, { + issueNumber, + issueUrl, + publicIssueUrl, + aiReport, + triageFailed, + needsSpamReview, + notified: false, + }); + } + + const notified = await notifyDiscord(env, { + report, + aiReport, + shouldTagTriage, + categoryLabel, + triageLabel, + autoModeLabel, + issueUrl, + publicIssueUrl, + }); + + const markerSaved = await saveTriageMarker(env, id, { + issueNumber, + issueUrl, + publicIssueUrl, + aiReport, + triageFailed, + needsSpamReview, + // 通知に失敗したときは未通知のまま残す。再試行では起票を飛ばして通知だけ + // やり直す(成功したことにすると通知が永久に届かない)。 + notified, + }); + + if (notified) return; + + if (!markerSaved) { + // マーカーを残せなかったので、再試行すると Issue を作り直してしまう。 + // 通知を諦めて ack する(フィードバック自体は起票済みで失われない)。 + console.error( + 'feedbackTriage: 通知に失敗したがマーカーも残せなかったため再試行しない', + { reportId: id, issueNumber } + ); + return; + } + + // 起票済みなので、再試行してもマーカーを見て通知から再開する(重複起票しない)。 + // max_retries を使い切ったメッセージは DLQ に残り、Discord 側の障害・設定ミスに + // 気づける。 + throw new FeedbackNotifyError(id); }; diff --git a/src/index.ts b/src/index.ts index f43ac5a..e049e00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,10 @@ * 1 つの Worker に HTTP(fetch) / キュー(queue) / Cron(scheduled) の 3 ハンドラを集約する。 */ import { handleAgentChat, handleAgentChatStream } from './agent/handler'; -import { processFeedbackMessage } from './consumers/feedbackTriage'; +import { + FEEDBACK_RETRY_DELAY_SECONDS, + processFeedbackMessage, +} from './consumers/feedbackTriage'; import { withCallable } from './lib/callable'; import { handleAuthToken } from './routes/auth'; import { handleMaintenanceConfig, handleRemoteConfig } from './routes/config'; @@ -78,7 +81,10 @@ const worker: ExportedHandler = { message.ack(); } catch (e) { console.error(`Queue message failed (${batch.queue}):`, e); - message.retry(); + // 遅延なしで再試行すると、processFeedbackMessage が起票直後に書いた + // 冪等化マーカーを KV のネガティブキャッシュ越しに読めず、Issue を + // 作り直してしまう。キャッシュが切れてから再試行させる。 + message.retry({ delaySeconds: FEEDBACK_RETRY_DELAY_SECONDS }); } } }, From 94a409cd931d92ecf9e3f416501b4df58d5d3087 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Thu, 3 Sep 2026 09:17:07 +0900 Subject: [PATCH 13/16] =?UTF-8?q?AI=20=E3=82=A8=E3=83=BC=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=AE=E3=83=A2=E3=83=87=E3=83=AB=E3=82=92?= =?UTF-8?q?=20Gemini=203.8=20Flash=20=E3=81=B8=E6=9B=B4=E6=96=B0=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/agent/handler.test.ts | 2 +- src/agent/llm.test.ts | 22 +++++++++++----------- wrangler.jsonc | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 85329f6..18042ab 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The conversational agent picks its provider from `AGENT_MODEL`, written as | ------------------------- | ----------------------- | ---------------------- | | `openai:gpt-5.6-luna` | OpenAI | `OPENAI_API_KEY` | | `anthropic:` | Anthropic | `ANTHROPIC_API_KEY` | -| `google:gemini-3.7-flash` | Google Vertex AI | `GOOGLE_VERTEX_SA_KEY` | +| `google:gemini-3.8-flash` | Google Vertex AI | `GOOGLE_VERTEX_SA_KEY` | Switching providers is a vars-only change (`wrangler deploy`) **as long as that provider's secret is already set** — no code change is needed. If it is missing, diff --git a/src/agent/handler.test.ts b/src/agent/handler.test.ts index 1a80d6e..92ba772 100644 --- a/src/agent/handler.test.ts +++ b/src/agent/handler.test.ts @@ -365,7 +365,7 @@ describe('runAgentTurn', () => { ); await runAgentTurn({ ...baseParams, - model: 'gemini-3.7-flash' as AnyFn, + model: 'gemini-3.8-flash' as AnyFn, streamText, searchStations: jest.fn(), }); diff --git a/src/agent/llm.test.ts b/src/agent/llm.test.ts index f44e275..d13d41e 100644 --- a/src/agent/llm.test.ts +++ b/src/agent/llm.test.ts @@ -48,12 +48,12 @@ describe('resolveAgentModel', () => { const model = asStub( resolveAgentModel( makeEnv({ - AGENT_MODEL: 'google:gemini-3.7-flash', + AGENT_MODEL: 'google:gemini-3.8-flash', GOOGLE_VERTEX_SA_KEY: SA_KEY, }) ) ); - expect(model.modelId).toBe('gemini-3.7-flash'); + expect(model.modelId).toBe('gemini-3.8-flash'); // プロジェクトは鍵の project_id を既定にし、ロケーションは global expect(model.options?.project).toBe('sa-project'); expect(model.options?.location).toBe('global'); @@ -73,7 +73,7 @@ describe('resolveAgentModel', () => { const model = asStub( resolveAgentModel( makeEnv({ - AGENT_MODEL: 'google:gemini-3.7-flash', + AGENT_MODEL: 'google:gemini-3.8-flash', GOOGLE_VERTEX_SA_KEY: SA_KEY, GOOGLE_VERTEX_PROJECT: 'other-project', GOOGLE_VERTEX_LOCATION: 'asia-northeast1', @@ -88,7 +88,7 @@ describe('resolveAgentModel', () => { const model = asStub( resolveAgentModel( makeEnv({ - AGENT_MODEL: 'google:gemini-3.7-flash', + AGENT_MODEL: 'google:gemini-3.8-flash', GOOGLE_VERTEX_SA_KEY: SA_KEY, GOOGLE_VERTEX_LOCATION: 'asia-northeast1', // 末尾スラッシュの揺れも吸収する @@ -107,7 +107,7 @@ describe('resolveAgentModel', () => { it('google: で鍵が無ければエラーにする', () => { expect(() => - resolveAgentModel(makeEnv({ AGENT_MODEL: 'google:gemini-3.7-flash' })) + resolveAgentModel(makeEnv({ AGENT_MODEL: 'google:gemini-3.8-flash' })) ).toThrow('GOOGLE_VERTEX_SA_KEY is not configured'); }); @@ -115,7 +115,7 @@ describe('resolveAgentModel', () => { expect(() => resolveAgentModel( makeEnv({ - AGENT_MODEL: 'google:gemini-3.7-flash', + AGENT_MODEL: 'google:gemini-3.8-flash', GOOGLE_VERTEX_SA_KEY: 'not-json', }) ) @@ -124,7 +124,7 @@ describe('resolveAgentModel', () => { expect(() => resolveAgentModel( makeEnv({ - AGENT_MODEL: 'google:gemini-3.7-flash', + AGENT_MODEL: 'google:gemini-3.8-flash', GOOGLE_VERTEX_SA_KEY: JSON.stringify({ project_id: 'p' }), }) ) @@ -135,7 +135,7 @@ describe('resolveAgentModel', () => { expect(() => resolveAgentModel( makeEnv({ - AGENT_MODEL: 'google:gemini-3.7-flash', + AGENT_MODEL: 'google:gemini-3.8-flash', GOOGLE_VERTEX_SA_KEY: JSON.stringify({ client_email: 'a@b.iam.gserviceaccount.com', private_key: 'pk', @@ -147,7 +147,7 @@ describe('resolveAgentModel', () => { it('未対応のプロバイダ指定はエラーにする', () => { expect(() => - resolveAgentModel(makeEnv({ AGENT_MODEL: 'gemini-3.7-flash' })) + resolveAgentModel(makeEnv({ AGENT_MODEL: 'gemini-3.8-flash' })) ).toThrow(/unsupported AGENT_MODEL/); }); }); @@ -159,7 +159,7 @@ describe('resolveGoogleReasoningSetting', () => { it('3 系は受理される最小値の low まで下げる', () => { // 'none' は thinkingLevel: minimal に変換され、Vertex に 400 で拒否される - expect(resolveGoogleReasoningSetting('gemini-3.7-flash')).toBe('low'); + expect(resolveGoogleReasoningSetting('gemini-3.8-flash')).toBe('low'); expect(resolveGoogleReasoningSetting('gemini-3-flash-preview')).toBe('low'); }); @@ -172,6 +172,6 @@ describe('resolveGoogleReasoningSetting', () => { }); it('OpenAI 向けの抑制指定は Gemini に反応しない', () => { - expect(resolveOpenAIReasoningOptions('gemini-3.7-flash')).toBeUndefined(); + expect(resolveOpenAIReasoningOptions('gemini-3.8-flash')).toBeUndefined(); }); }); diff --git a/wrangler.jsonc b/wrangler.jsonc index f2389ae..95719f9 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -76,7 +76,7 @@ // --- AI エージェント(/agent/chat)--- // モデルは "anthropic:" | "openai:" | "google:"。比較検証で差し替える // ("google:" は Vertex AI 経由。API キーではなく GOOGLE_VERTEX_SA_KEY が必要) - "AGENT_MODEL": "google:gemini-3.7-flash", + "AGENT_MODEL": "google:gemini-3.8-flash", // Vertex AI 用(AGENT_MODEL が google: のときのみ使う)。 // GOOGLE_VERTEX_PROJECT 未設定なら GOOGLE_VERTEX_SA_KEY の project_id を使う。 // ロケーションは "global" がモデルの提供範囲が最も広い。特定リージョンに寄せるなら @@ -144,7 +144,7 @@ "FEW_SHOT_LIMIT": "16", "FEW_SHOT_PER_EX_MAX": "800", // --- AI エージェント(/agent/chat)--- - "AGENT_MODEL": "google:gemini-3.7-flash", + "AGENT_MODEL": "google:gemini-3.8-flash", "GOOGLE_VERTEX_LOCATION": "global", "AGENT_GATE_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", "AGENT_FAQ_KV_KEY": "config:agent-faq", From f3fa86232b4c8e39f0541dd4cdeed8b111a1c232 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 7 Sep 2026 00:01:14 +0900 Subject: [PATCH 14/16] =?UTF-8?q?=E8=8B=B1=E8=AA=9ETTS=E3=81=A7=E3=80=8CKe?= =?UTF-8?q?isei=E3=80=8D=E3=81=8C=E3=80=8C=E3=81=8B=E3=81=84=E3=81=9B?= =?UTF-8?q?=E3=81=84=E3=80=8D=E3=81=A8=E8=AA=AD=E3=81=BE=E3=82=8C=E3=82=8B?= =?UTF-8?q?=E8=AA=A4=E8=AA=AD=E3=82=92=E5=90=88=E6=88=90=E5=89=8D=E3=81=AE?= =?UTF-8?q?=E8=A1=A8=E8=A8=98=E7=BD=AE=E6=8F=9B=E3=81=A7=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/normalize.test.ts | 20 ++++++++++++++++++++ src/utils/normalize.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/src/utils/normalize.test.ts b/src/utils/normalize.test.ts index e8f3605..63948d1 100644 --- a/src/utils/normalize.test.ts +++ b/src/utils/normalize.test.ts @@ -30,6 +30,26 @@ describe('utils/normalize.ts', () => { ); }); + it('replaces Keisei with a spelling English TTS reads as けいせい', () => { + // 英語 TTS は "Keisei" を「かいせい」と読むため、辞書語の綴りへ倒す + expect(normalizeRomanText('Change here for the Keisei Main Line.')).toBe( + 'Change here for the Kay-say Main Line.' + ); + expect(normalizeRomanText('The next station is Keisei-Ueno.')).toBe( + 'The next station is Kay-say-ueno.' + ); + expect(normalizeRomanText('KEISEI SKYLINER')).toBe('Kay-say Skyliner'); + // 別語の一部は置換しない + expect(normalizeRomanText('Keiseibus')).toBe('Keiseibus'); + }); + + it('keeps Kay-say stable when normalized twice', () => { + // 二重に適用しても結果が変わらないこと(キャッシュキーの安定性) + expect(normalizeRomanText(normalizeRomanText('Keisei Main Line'))).toBe( + 'Kay-say Main Line' + ); + }); + it.each(['Tokyo', 'tOkyo'])('text: %s', (text) => { expect(normalizeRomanText(text)).toBe('Tokyo'); }); diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts index 90f68fb..c777663 100644 --- a/src/utils/normalize.ts +++ b/src/utils/normalize.ts @@ -35,6 +35,12 @@ const normalizeTextNode = (text: string): string => // 明治神宮前駅等の駅名にバッククォートが含まれる場合があるため除去 .replace(/`/g, '') .replace(/JR/gi, 'J-R') + // 「Keisei(京成)」は英語 TTS が "ei" を /aɪ/ と推定して「かいせい」と読むため、 + // 英単語 "Kay" + "say" で /keɪ.seɪ/(けいせい)を確定させる。読み替え先を + // 未知語の綴りにすると G2P の推定に戻ってエンジンごとに結果がぶれるので、 + // 辞書語のハイフン連結にする。単語境界で一致させ、Keisei-Ueno のような + // ハイフン連結の駅名も語単位で置換する + .replace(/\bKeisei\b/gi, 'Kay-say') // 都営バスを想定 .replace(/\bSta\./gi, ' Station') .replace(/\bUniv\./gi, ' University') From e73f616a94d653a835aa98476b974715530546ec Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 7 Sep 2026 00:24:35 +0900 Subject: [PATCH 15/16] =?UTF-8?q?GitHub=20Actions=20=E3=81=A7=20dev?= =?UTF-8?q?=E3=83=BBmaster=20=E3=81=8B=E3=82=89=E3=81=AE=E3=83=87=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=82=A4=E3=82=92=E8=87=AA=E5=8B=95=E5=8C=96=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * GitHub Actions で dev・master からのデプロイを自動化する 検証 (lint / typecheck / test / wrangler の dry-run) を composite action へ 切り出し、CI とデプロイ 2 環境で同じ手順を踏ませる。デプロイ先はブランチを 式で判定せず、ファイルとトリガで固定する。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EWZaSGKrw2MDZy5SYhuGiY * デプロイ workflow の変更でも ci.yml が起動するようにする deploy_dev.yml / deploy_production.yml はどちらも pull_request では起動せず、 ci.yml の paths にも載っていなかったため、この 2 ファイルだけを変更した PR が どの workflow も通らないままマージできてしまう状態だった。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EWZaSGKrw2MDZy5SYhuGiY --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/actions/verify/action.yml | 67 +++++++++++++++++++ .github/workflows/ci.yml | 85 +++++++++++++++++++++++++ .github/workflows/deploy_dev.yml | 69 ++++++++++++++++++++ .github/workflows/deploy_production.yml | 68 ++++++++++++++++++++ README.md | 39 ++++++++++++ 5 files changed, 328 insertions(+) create mode 100644 .github/actions/verify/action.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy_dev.yml create mode 100644 .github/workflows/deploy_production.yml diff --git a/.github/actions/verify/action.yml b/.github/actions/verify/action.yml new file mode 100644 index 0000000..d6d160c --- /dev/null +++ b/.github/actions/verify/action.yml @@ -0,0 +1,67 @@ +name: Verify worker +description: >- + lockfile どおりに依存を入れて lint / typecheck / test を回し、Worker が実際に + バンドルできることを dev・production 双方の設定で確認する。検証用ワークフローと + デプロイ用ワークフローで同じ手順を踏むため composite action に切り出してある。 + +inputs: + node-version: + description: Node.js のバージョン (package.json の engines と揃えること) + required: false + default: "22" + +# ローカル action は checkout 済みでないと解決できないため、checkout は +# 呼び出し側のワークフローに置いてある。 +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: npm + + # npm install と違い package-lock.json を書き換えないので、ローカルで + # 動かしたのと同じ wrangler / biome / TypeScript の版で検証できる。 + # wrangler もこの lockfile から入るため、デプロイに使う版の固定先は + # ワークフロー側ではなく package-lock.json 一箇所で済む。 + - name: Install dependencies + shell: bash + run: npm ci + + - name: Lint + shell: bash + run: npm run lint + + - name: Typecheck + shell: bash + run: npm run typecheck + + - name: Test + shell: bash + run: npm test + + # tsc は型しか見ないので、import の解決ミスや nodejs_compat で賄えない + # Node API はバンドルして初めて落ちる。--dry-run は Cloudflare API を + # 叩かないため認証情報なしで回せる。 + # + # dev と production を両方バンドルするのは、wrangler.jsonc の env.production + # 側だけが壊れている状態を master へ入れる前に捕まえるため。dev への push + # では production 設定に一切触れないまま緑になってしまう。 + - name: Build (dry-run) + shell: bash + env: + WRANGLER_SEND_METRICS: "false" + # wrangler の色付けが Total Upload 行に混ざると要約が読めなくなる + NO_COLOR: "1" + run: | + # dev は wrangler.jsonc の top-level 設定。wrangler 4 は環境が複数ある + # 状態で --env を省くと警告を出すため、空文字でも明示する。 + for target in "" production; do + label="${target:-dev}" + npx wrangler deploy --env="$target" --dry-run \ + --outdir "$RUNNER_TEMP/bundle-$label" 2>&1 | tee "$RUNNER_TEMP/$label.log" + # Workers の上限は gzip 後で 10 MiB。今は 1/10 にも届かないので + # 失敗にはせず、増え方が見えるよう要約に残すだけにする。 + size=$(grep -m1 'Total Upload' "$RUNNER_TEMP/$label.log" || true) + echo "- \`$label\`: ${size:-size unknown}" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e156bf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +# lint / typecheck / test が通ることと、Worker がバンドルできることを検証する +# だけのワークフロー。デプロイはしない。 +# +# デプロイは環境ごとに別ファイルへ分けてある: +# dev -> deploy_dev.yml (trainlcd-worker-dev) +# master -> deploy_production.yml (trainlcd-worker) +# デプロイ先をトリガとファイルで固定することで、他のブランチが誤って +# どこかの環境へ向くことがないようにしている。 +on: + pull_request: + # GitHub Actions は YAML のアンカー / エイリアスを解釈しないため、 + # このリストは push 側とも、デプロイ用の 2 ファイルとも二重に書く必要が + # ある。片方だけ直さないこと。 + paths: + - "src/**" + - "test/**" + - "scripts/**" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "biome.json" + - "jest.config.js" + - "wrangler.jsonc" + - ".github/actions/verify/action.yml" + - ".github/workflows/ci.yml" + # deploy 用の 2 ファイルはどちらも pull_request で起動しないため、 + # ここに載せておかないと変更した PR がどの workflow も通らないまま + # マージされ、デプロイ時に初めて動くことになる。 + - ".github/workflows/deploy_dev.yml" + - ".github/workflows/deploy_production.yml" + push: + # dev / master は deploy_dev.yml / deploy_production.yml が同じ composite + # action で検証してからデプロイするため、ここでは走らせない。 + branches-ignore: + - dev + - master + paths: + - "src/**" + - "test/**" + - "scripts/**" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "biome.json" + - "jest.config.js" + - "wrangler.jsonc" + - ".github/actions/verify/action.yml" + - ".github/workflows/ci.yml" + # deploy 用の 2 ファイルはどちらも pull_request で起動しないため、 + # ここに載せておかないと変更した PR がどの workflow も通らないまま + # マージされ、デプロイ時に初めて動くことになる。 + - ".github/workflows/deploy_dev.yml" + - ".github/workflows/deploy_production.yml" + workflow_dispatch: + +name: Continuous integration + +# 同じ PR / ブランチに続けて push したとき、古い方は結果が要らない。 +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + name: Lint, typecheck, test and build + runs-on: ubuntu-latest + + # ここでは environment を宣言しない。environment は if: と違って + # ジョブが走れば必ず適用されるため、宣言すると全ブランチ・全 PR が + # その環境へのデプロイとして履歴に載り、環境 Secret (デプロイ用の + # CLOUDFLARE_API_TOKEN を含む) が任意のブランチのビルドから触れる。 + # + # 検証は --dry-run で Cloudflare API を叩かないため、そもそも認証情報が要らない。 + steps: + # checkout は既定で GITHUB_TOKEN を .git/config に残す。後続の npm ci は + # 依存パッケージの install スクリプトを実行するため読み取られうる。 + # ここから先で git 認証は使わない。 + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: ./.github/actions/verify diff --git a/.github/workflows/deploy_dev.yml b/.github/workflows/deploy_dev.yml new file mode 100644 index 0000000..69b531f --- /dev/null +++ b/.github/workflows/deploy_dev.yml @@ -0,0 +1,69 @@ +# dev を dev 環境 (trainlcd-worker-dev) へデプロイする。 +# +# デプロイ先はこのファイルとトリガで固定してある。ブランチを式で判定して +# 環境を選ぶ作りにすると、environment は if: と違ってジョブが走れば必ず +# 適用されるため、意図しないブランチがこの環境の履歴と Secret に触れる。 +on: + push: + branches: + - dev + # GitHub Actions は YAML のアンカー / エイリアスを解釈しないため、 + # このリストは ci.yml / deploy_production.yml とも二重に書く必要がある。 + # 片方だけ直さないこと。 + paths: + - "src/**" + - "test/**" + - "scripts/**" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "biome.json" + - "jest.config.js" + - "wrangler.jsonc" + - ".github/actions/verify/action.yml" + - ".github/workflows/deploy_dev.yml" + workflow_dispatch: + +name: Deploy to dev + +# 同時に流れると、先に始まった古い版が後から上書きしうる。 +concurrency: + group: deploy-dev + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + name: Verify and deploy to dev + runs-on: ubuntu-latest + + # workflow_dispatch にはブランチ絞り込みが無いので、ここで塞ぐ。 + # push は on: branches で dev に限定済み。 + if: github.ref == 'refs/heads/dev' + + environment: dev + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: ./.github/actions/verify + + # 直前の検証で --dry-run 済みのものと同じ入力から同じバンドルが組み上がる。 + # wrangler は node_modules から解決されるので、検証と同じ版が走る。 + # + # dev は wrangler.jsonc の top-level 設定なので環境名は空にする。 + # wrangler 4 は環境が複数あると --env の省略を警告するため、空でも明示する。 + # + # Worker の secrets (SESSION_JWT_SECRET など) はここでは触らない。 + # deploy は既存の secrets を保持するため、投入は scripts/put-secrets.sh で + # 手元から行う運用のままでよい。 + - name: Deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_SEND_METRICS: "false" + run: npx wrangler deploy --env="" diff --git a/.github/workflows/deploy_production.yml b/.github/workflows/deploy_production.yml new file mode 100644 index 0000000..0b108e8 --- /dev/null +++ b/.github/workflows/deploy_production.yml @@ -0,0 +1,68 @@ +# master を production (trainlcd-worker) へデプロイする。 +# +# デプロイ先はこのファイルとトリガで固定してある。ブランチを式で判定して +# 環境を選ぶ作りにすると、environment は if: と違ってジョブが走れば必ず +# 適用されるため、意図しないブランチがこの環境の履歴と Secret に触れる。 +on: + push: + branches: + - master + # GitHub Actions は YAML のアンカー / エイリアスを解釈しないため、 + # このリストは ci.yml / deploy_dev.yml とも二重に書く必要がある。 + # 片方だけ直さないこと。 + paths: + - "src/**" + - "test/**" + - "scripts/**" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "biome.json" + - "jest.config.js" + - "wrangler.jsonc" + - ".github/actions/verify/action.yml" + - ".github/workflows/deploy_production.yml" + workflow_dispatch: + +name: Deploy to production + +# 同時に流れると、先に始まった古い版が後から上書きしうる。 +concurrency: + group: deploy-production + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deploy: + name: Verify and deploy to production + runs-on: ubuntu-latest + + # workflow_dispatch にはブランチ絞り込みが無いので、ここで塞ぐ。 + # push は on: branches で master に限定済み。 + if: github.ref == 'refs/heads/master' + + environment: production + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: ./.github/actions/verify + + # 直前の検証で --dry-run 済みのものと同じ入力から同じバンドルが組み上がる。 + # wrangler は node_modules から解決されるので、検証と同じ版が走る。 + # + # production は wrangler.jsonc の env.production を指す。 + # + # Worker の secrets (SESSION_JWT_SECRET など) はここでは触らない。 + # deploy は既存の secrets を保持するため、投入は scripts/put-secrets.sh で + # 手元から行う運用のままでよい。 + - name: Deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_SEND_METRICS: "false" + run: npx wrangler deploy --env production diff --git a/README.md b/README.md index 18042ab..e8a00e6 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,45 @@ npm run deploy:prod # wrangler deploy --env production npm run tail # follow logs ``` +### CI / CD (GitHub Actions) + +Deploys run from GitHub Actions. The target is fixed by the workflow file and +its trigger rather than chosen by an expression, so no branch can point at an +environment it was not meant to reach: + +| Workflow | Trigger | Result | +| ------------------------------------ | -------------------------------- | ----------------------------------- | +| `.github/workflows/ci.yml` | PRs, and pushes to other branches | Verify only, no deploy | +| `.github/workflows/deploy_dev.yml` | push to `dev` | Deploy `trainlcd-worker-dev` | +| `.github/workflows/deploy_production.yml` | push to `master` | Deploy `trainlcd-worker` | + +All three run the same `.github/actions/verify` composite action first — `npm +ci`, lint, typecheck, tests, and a `wrangler deploy --dry-run` of **both** the +dev and the production config. The dry run bundles the Worker for real, so +import mistakes and missing `nodejs_compat` APIs fail there rather than at +deploy time, and building the production config on every run catches an +`env.production` that only breaks after the merge to `master`. `npm ci` installs +wrangler from `package-lock.json`, so the version that deploys is the version +the lockfile pins — there is no second place to bump. + +Each deploy workflow needs two secrets on its GitHub environment (`dev` and +`production` respectively): + +- `CLOUDFLARE_API_TOKEN` — the *Edit Cloudflare Workers* template plus + **Queues: Edit**, since `wrangler deploy` also applies the queue consumer + settings from `wrangler.jsonc`. +- `CLOUDFLARE_ACCOUNT_ID` — `wrangler.jsonc` carries no `account_id`. + +Keeping them on the environment rather than on the repository is what stops an +arbitrary branch from reading the production token: `ci.yml` deliberately +declares no `environment`, and it needs no credentials because `--dry-run` never +calls the Cloudflare API. + +Worker secrets (`SESSION_JWT_SECRET`, `OCTOKIT_PAT`, …) are **not** touched by +the workflows. `wrangler deploy` preserves the secrets already on a Worker, so +they stay a manual `scripts/put-secrets.sh` step — see [Setting +secrets](#setting-secrets). + ## Client wire protocol `POST /tts` and `POST /postFeedback` keep the Firebase callable-compatible wire From a63ffe36e6f803a15dfbaa31475e25fee2f2d1f5 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 7 Sep 2026 01:21:18 +0900 Subject: [PATCH 16/16] =?UTF-8?q?=E8=8B=B1=E8=AA=9ETTS=E3=81=A7=E3=80=8CSe?= =?UTF-8?q?ibu=E3=80=8D=E3=81=8C=E3=80=8C=E3=81=95=E3=81=84=E3=81=B6?= =?UTF-8?q?=E3=80=8D=E3=81=A8=E8=AA=AD=E3=81=BE=E3=82=8C=E3=82=8B=E8=AA=A4?= =?UTF-8?q?=E8=AA=AD=E3=82=92=E5=90=88=E6=88=90=E5=89=8D=E3=81=AE=E8=A1=A8?= =?UTF-8?q?=E8=A8=98=E7=BD=AE=E6=8F=9B=E3=81=A7=E4=BF=AE=E6=AD=A3=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WS1LSBnYTMfm7CyrzqKxuW Co-authored-by: Claude --- src/utils/normalize.test.ts | 14 ++++++++++++++ src/utils/normalize.ts | 6 ++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/utils/normalize.test.ts b/src/utils/normalize.test.ts index 63948d1..40e7ac7 100644 --- a/src/utils/normalize.test.ts +++ b/src/utils/normalize.test.ts @@ -43,11 +43,25 @@ describe('utils/normalize.ts', () => { expect(normalizeRomanText('Keiseibus')).toBe('Keiseibus'); }); + it('replaces Seibu with a spelling English TTS reads as せいぶ', () => { + expect( + normalizeRomanText('Change here for the Seibu Ikebukuro Line.') + ).toBe('Change here for the Say-boo Ikebukuro Line.'); + expect(normalizeRomanText('The next station is Seibu-Shinjuku.')).toBe( + 'The next station is Say-boo-shinjuku.' + ); + // 西武園 (Seibuen) は 1 語なので語単位の一致では対象外 + expect(normalizeRomanText('Seibuen')).toBe('Seibuen'); + }); + it('keeps Kay-say stable when normalized twice', () => { // 二重に適用しても結果が変わらないこと(キャッシュキーの安定性) expect(normalizeRomanText(normalizeRomanText('Keisei Main Line'))).toBe( 'Kay-say Main Line' ); + expect(normalizeRomanText(normalizeRomanText('Seibu Shinjuku Line'))).toBe( + 'Say-boo Shinjuku Line' + ); }); it.each(['Tokyo', 'tOkyo'])('text: %s', (text) => { diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts index c777663..cfd4deb 100644 --- a/src/utils/normalize.ts +++ b/src/utils/normalize.ts @@ -35,12 +35,14 @@ const normalizeTextNode = (text: string): string => // 明治神宮前駅等の駅名にバッククォートが含まれる場合があるため除去 .replace(/`/g, '') .replace(/JR/gi, 'J-R') - // 「Keisei(京成)」は英語 TTS が "ei" を /aɪ/ と推定して「かいせい」と読むため、 - // 英単語 "Kay" + "say" で /keɪ.seɪ/(けいせい)を確定させる。読み替え先を + // 「Keisei(京成)」「Seibu(西武)」は英語 TTS が "ei" を /aɪ/ と推定して + // 「かいせい」「さいぶ」と読むため、英単語 "Kay" + "say" / "Say" + "boo" で + // /keɪ.seɪ/(けいせい)/ /seɪ.buː/(せいぶ)を確定させる。読み替え先を // 未知語の綴りにすると G2P の推定に戻ってエンジンごとに結果がぶれるので、 // 辞書語のハイフン連結にする。単語境界で一致させ、Keisei-Ueno のような // ハイフン連結の駅名も語単位で置換する .replace(/\bKeisei\b/gi, 'Kay-say') + .replace(/\bSeibu\b/gi, 'Say-boo') // 都営バスを想定 .replace(/\bSta\./gi, ' Station') .replace(/\bUniv\./gi, ' University')