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 4934833..e8a00e6 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, @@ -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 @@ -353,6 +392,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/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/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 }); } } }, diff --git a/src/utils/normalize.test.ts b/src/utils/normalize.test.ts index e8f3605..40e7ac7 100644 --- a/src/utils/normalize.test.ts +++ b/src/utils/normalize.test.ts @@ -30,6 +30,40 @@ 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('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) => { expect(normalizeRomanText(text)).toBe('Tokyo'); }); diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts index 90f68fb..cfd4deb 100644 --- a/src/utils/normalize.ts +++ b/src/utils/normalize.ts @@ -35,6 +35,14 @@ const normalizeTextNode = (text: string): string => // 明治神宮前駅等の駅名にバッククォートが含まれる場合があるため除去 .replace(/`/g, '') .replace(/JR/gi, 'J-R') + // 「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') 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",