Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .secrets.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
57 changes: 49 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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**

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -103,10 +103,51 @@ format.

A session JWT is obtained from `POST /auth/token` (body `{ "installId": "<uuid>" }`).

### `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": "<sha256 of the request, used as the cache key>",
"jaAudioContent": "<base64>",
"jaAudioMimeType": "audio/mpeg",
"enAudioContent": "<base64>",
"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
Expand Down Expand Up @@ -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
```
Expand Down
1 change: 0 additions & 1 deletion scripts/build-secrets-json.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 1 addition & 3 deletions src/agent/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@ describe('buildSystemPrompt', () => {
expect(prompt).toContain(
'search_stations_by_name の結果は現在駅からの直通到達性しか保証しない'
);
expect(prompt).toContain(
'それだけを根拠に駅を乗換地点として扱わない'
);
expect(prompt).toContain('それだけを根拠に駅を乗換地点として扱わない');
expect(prompt).toContain(
'最終目的地への接続を確認できない場合は suggestions を空配列'
);
Expand Down
30 changes: 18 additions & 12 deletions src/cli/find-tts-cache.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/**
* KV(TTS_KV) の voice:* メタを SSML 本文で検索し、必要なら KV ドキュメントと
* KV(TTS_KV) の voice:* メタを読み上げ本文で検索し、必要なら KV ドキュメントと
* R2 上の音声ファイルを削除する。旧 Firestore+GCS 版の Cloudflare 移植。
*
* KV の一覧・値取得・削除、R2 の削除、バケット名解決はすべて wrangler
* (wrangler.jsonc + ログイン済みアカウント)経由で行うため、API トークンや
* ネームスペース 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
*/
Expand All @@ -26,20 +26,20 @@ const R2_BINDING = 'TTS_BUCKET';

interface CliArgs {
searchTerm: string;
field?: 'ssmlJa' | 'ssmlEn';
field?: 'textJa' | 'textEn';
exact: boolean;
delete: boolean;
env?: string;
}

function printUsage(): void {
console.error(
'Usage: npm run find-tts-cache -- <search-term> [--field ssmlJa|ssmlEn] [--exact] [--delete] [--env <name>]'
'Usage: npm run find-tts-cache -- <search-term> [--field textJa|textEn] [--exact] [--delete] [--env <name>]'
);
console.error('');
console.error('Options:');
console.error(
' --field <ssmlJa|ssmlEn> 検索対象フィールド(省略時は両方)'
' --field <textJa|textEn> 検索対象フィールド(省略時は両方)'
);
console.error(' --exact 部分一致ではなく完全一致で検索');
console.error(' --delete KV ドキュメントと R2 音声を削除');
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -141,9 +141,14 @@ async function main(): Promise<void> {
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);
}

Expand All @@ -155,8 +160,9 @@ async function main(): Promise<void> {
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 ?? ''}`);
Expand Down
4 changes: 4 additions & 0 deletions src/cli/lib/wrangler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ export function confirm(prompt: string): Promise<boolean> {
// --- 共有: voice メタの型 ---
export interface VoiceCacheRecord {
id: string;
model?: string;
textJa?: string;
textEn?: string;
/** Azure/SSML 時代のレコード。旧エントリを検索・削除できるよう残している */
ssmlJa?: string;
ssmlEn?: string;
pathJa?: string;
Expand Down
56 changes: 0 additions & 56 deletions src/lib/azure/tts.test.ts

This file was deleted.

108 changes: 0 additions & 108 deletions src/lib/azure/tts.ts

This file was deleted.

Loading