From a38c87e4c61dc88ba9af91f724d7737c6b0b3848 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Wed, 14 Oct 2026 20:58:11 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=BC=E3=83=89=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=81=AE=E3=83=8F=E3=83=B3=E3=83=89=E3=82=AA?= =?UTF-8?q?=E3=83=95=E8=BF=BD=E8=B7=A1=E3=81=A8=E7=B5=90=E6=9E=9C=E3=82=B3?= =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=88=E3=81=AE=E5=86=8D=E9=80=81=E3=82=92?= =?UTF-8?q?=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/consumers/feedbackTriage.test.ts | 225 ++++++++++++++++++++++++++- src/consumers/feedbackTriage.ts | 216 +++++++++++++++++++------ 2 files changed, 393 insertions(+), 48 deletions(-) diff --git a/src/consumers/feedbackTriage.test.ts b/src/consumers/feedbackTriage.test.ts index bc54402..c9f074f 100644 --- a/src/consumers/feedbackTriage.test.ts +++ b/src/consumers/feedbackTriage.test.ts @@ -16,6 +16,7 @@ import { PUBLIC_ISSUE_MIN_CONFIDENCE, pickModelResponse, processFeedbackMessage, + resolveHandoffDecision, resolvePublicIssueRepo, SPAM_OVERRIDE_MAX_CONFIDENCE, TRIAGE_FAILED_SUMMARY, @@ -413,6 +414,35 @@ describe('resolvePublicIssueRepo', () => { }); }); +describe('ハンドオフ見送り理由', () => { + const opts = { reportType: 'feedback' as const, triageFailed: false }; + it.each([ + [{ component: null }, {}, '原因コンポーネント未特定'], + [{ componentConfidence: 0.69 }, {}, '原因コンポーネントの信頼度不足'], + [ + { reason: 'judgment-unavailable' }, + { needsSpamReview: true }, + 'TypeSafe 判定取得失敗', + ], + [{}, { needsSpamReview: true }, '人手確認待ち'], + [{}, { triageFailed: true }, 'トリアージ生成失敗'], + [{ isSpam: true }, {}, 'スパム判定'], + [{ category: 'question' }, {}, '対象外のカテゴリ'], + [{}, { reportType: 'crash' }, '対象外のレポート種別'], + ])('見送り理由を記録する: %s / %s', (report, overrides, reason) => { + expect( + resolveHandoffDecision(baseReport(report as Partial), { + ...opts, + ...overrides, + } as Parameters[1]) + ).toEqual({ + status: 'skipped', + repo: null, + reason, + }); + }); +}); + describe('公開リポジトリ用の Issue 本文', () => { const ticketId = 'a1b2c3d4-0000-4444-8888-abcdefabcdef'; const body = buildPublicIssueBody({ internalIssueNumber: 123, ticketId }); @@ -931,6 +961,93 @@ describe('processFeedbackMessage(再試行時の冪等化)', () => { ).toBe(true); }); + it('通知再試行でも記録済みのハンドオフ失敗を保持する', async () => { + const msg = makeMessage(); + const { env, store } = createEnv(); + const handoff = { + status: 'failed', + reason: 'GitHub 起票失敗', + repo: 'TrainLCD/StationAPI', + httpStatus: 403, + }; + store.set(triageMarkerKey(msg.report.id), marker({ handoff })); + const fetchMock = jest.fn(async () => new Response(null, { status: 204 })); + global.fetch = fetchMock as unknown as typeof fetch; + await processFeedbackMessage(msg, env); + expect( + JSON.parse(store.get(triageMarkerKey(msg.report.id)) ?? '{}').handoff + ).toEqual(handoff); + expect(fetchMock.mock.calls).toHaveLength(1); + }); + + it.each(['http', 'exception'])( + '結果コメント失敗を保存し、通知済みでもコメントだけ再送する: %s', + async (failure) => { + const msg = makeMessage(); + const { env, store } = createEnv(); + const handoff = { + status: 'created', + reason: '起票完了', + repo: 'TrainLCD/StationAPI', + issueUrl: 'https://github.com/TrainLCD/StationAPI/issues/99', + }; + store.set( + triageMarkerKey(msg.report.id), + marker({ handoff, handoffCommentRecorded: false }) + ); + let fail = true; + const fetchMock = jest.fn(async (input: unknown) => { + if (String(input).endsWith('/comments')) { + if (fail && failure === 'exception') + throw new TypeError('network error'); + return new Response('{}', { status: fail ? 503 : 201 }); + } + return new Response(null, { status: 204 }); + }); + global.fetch = fetchMock as unknown as typeof fetch; + await expect(processFeedbackMessage(msg, env)).rejects.toThrow( + 'Handoff comment failed' + ); + const saved = JSON.parse( + store.get(triageMarkerKey(msg.report.id)) ?? '{}' + ); + expect(saved.notified).toBe(true); + expect(saved.handoffCommentRecorded).toBe(false); + expect(saved.handoff).toEqual(handoff); + fail = false; + await processFeedbackMessage(msg, env); + expect( + JSON.parse(store.get(triageMarkerKey(msg.report.id)) ?? '{}') + .handoffCommentRecorded + ).toBe(true); + expect(githubCalls(fetchMock)).toHaveLength(0); + expect(discordCalls(fetchMock)).toHaveLength(1); + expect(env.AI.run).not.toHaveBeenCalled(); + expect( + fetchMock.mock.calls.filter(([url]) => + String(url).endsWith('/comments') + ) + ).toHaveLength(2); + fetchMock.mockClear(); + await processFeedbackMessage(msg, env); + expect(fetchMock).not.toHaveBeenCalled(); + } + ); + + it('結果コメントが成功済みなら、通知再試行で重複投稿しない', async () => { + const msg = makeMessage(); + const { env, store } = createEnv(); + store.set( + triageMarkerKey(msg.report.id), + marker({ handoffCommentRecorded: 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.mock.calls).toHaveLength(1); + expect(discordCalls(fetchMock)).toHaveLength(1); + }); + it('マーカー保存が一度失敗しても書き直し、throw しない', async () => { const msg = makeMessage(); const { env, store } = createEnv(); @@ -1078,11 +1195,23 @@ describe('TypeSafe の判定を起票に反映する', () => { jest.clearAllMocks(); }); - const run = async (env: TestEnv, typesafe: () => Response) => { + const run = async ( + env: TestEnv, + typesafe: () => Response, + publicResponse?: () => Response, + commentResponse?: () => Response + ) => { const created: Record[] = []; global.fetch = jest.fn(async (input: unknown, init?: RequestInit) => { const url = String(input); if (url === TYPESAFE_API) return typesafe(); + if (url.endsWith('/issues/1/comments')) + return commentResponse?.() ?? new Response('{}', { status: 201 }); + if ( + url === 'https://api.github.com/repos/TrainLCD/StationAPI/issues' && + publicResponse + ) + return publicResponse(); if (url === ISSUES_API) { created.push(JSON.parse(String(init?.body))); return new Response( @@ -1104,6 +1233,100 @@ describe('TypeSafe の判定を起票に反映する', () => { return created; }; + it.each([201, 403])( + '公開起票の結果をコメントと保存済み判定に残す: HTTP %s', + async (status) => { + const env = createEnv(); + const publicUrl = 'https://github.com/TrainLCD/StationAPI/issues/99'; + const created = await run( + env, + () => new Response(typesafeBody(), { status: 200 }), + () => + new Response( + JSON.stringify({ + html_url: publicUrl, + message: 'sensitive response', + }), + { status } + ) + ); + expect(String(created[0]?.body)).toContain( + '原因コンポーネント信頼度: 0.86(閾値: 0.7)' + ); + const fetchMock = global.fetch as jest.Mock; + const comment = fetchMock.mock.calls.find(([url]) => + String(url).endsWith('/issues/1/comments') + ); + const body = JSON.parse(String(comment?.[1]?.body)).body; + expect(body).toContain(`GitHub HTTP ステータス: ${status}`); + expect(body).toContain(`状態: ${status === 201 ? 'created' : 'failed'}`); + expect(body).not.toContain('sensitive response'); + const saved = JSON.parse(env.STATE_KV.put.mock.calls.at(-1)[1]); + expect(saved.handoff).toMatchObject({ + status: status === 201 ? 'created' : 'failed', + httpStatus: status, + }); + if (status === 201) expect(saved.handoff.issueUrl).toBe(publicUrl); + } + ); + + it('初回の結果コメント失敗を起票結果とともに保存する', async () => { + const env = createEnv(); + await expect( + run( + env, + () => new Response(typesafeBody(), { status: 200 }), + () => + new Response( + JSON.stringify({ + html_url: 'https://github.com/TrainLCD/StationAPI/issues/99', + }), + { status: 201 } + ), + () => new Response('unavailable', { status: 503 }) + ) + ).rejects.toThrow('Handoff comment failed'); + const writes = env.STATE_KV.put.mock.calls.map((args: string[]) => + JSON.parse(args[1]) + ); + expect(writes[0]).toMatchObject({ + handoffCommentRecorded: false, + handoff: { status: 'created' }, + }); + expect(writes.at(-1)).toMatchObject({ + handoffCommentRecorded: false, + notified: true, + }); + }); + + it('信頼度不足は管理 Issue に理由を残し、公開起票しない', async () => { + const env = createEnv(); + const created = await run( + env, + () => + new Response( + typesafeBody({ + component: { + type: 'choice', + choice: 'station_api', + confidence: 0.6, + probabilities: {}, + }, + }), + { status: 200 } + ) + ); + expect(String(created[0]?.body)).toContain( + '理由: 原因コンポーネントの信頼度不足' + ); + expect(String(created[0]?.body)).toContain('0.6(閾値: 0.7)'); + expect( + (global.fetch as jest.Mock).mock.calls.some(([url]) => + String(url).includes('/StationAPI/issues') + ) + ).toBe(false); + }); + it('判定したカテゴリと優先度をラベルに反映する', async () => { const created = await run( createEnv(), diff --git a/src/consumers/feedbackTriage.ts b/src/consumers/feedbackTriage.ts index d8d64f2..995b617 100644 --- a/src/consumers/feedbackTriage.ts +++ b/src/consumers/feedbackTriage.ts @@ -717,14 +717,66 @@ export function resolvePublicIssueRepo( 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]; + return resolveHandoffDecision(aiReport, opts).repo; +} + +export type HandoffTrace = { + status: 'pending' | 'skipped' | 'created' | 'failed'; + reason: string; + repo: string | null; + httpStatus?: number; + issueUrl?: string; +}; + +/** 起票可否と見送り理由を同じ判定から導く。 */ +export function resolveHandoffDecision( + aiReport: AIReport, + opts: { + reportType: Report['reportType']; + triageFailed: boolean; + needsSpamReview?: boolean; + } +): HandoffTrace { + const skip = (reason: string): HandoffTrace => ({ + status: 'skipped', + reason, + repo: null, + }); + if (opts.reportType !== 'feedback') return skip('対象外のレポート種別'); + if (opts.triageFailed) return skip('トリアージ生成失敗'); + if (aiReport.isSpam) return skip('スパム判定'); + if (aiReport.reason === 'judgment-unavailable') + return skip('TypeSafe 判定取得失敗'); + if (opts.needsSpamReview) return skip('人手確認待ち'); + if (!PUBLIC_ISSUE_CATEGORIES.includes(aiReport.category)) + return skip('対象外のカテゴリ'); + if (!aiReport.component) return skip('原因コンポーネント未特定'); + if (aiReport.componentConfidence < PUBLIC_ISSUE_MIN_CONFIDENCE) + return skip('原因コンポーネントの信頼度不足'); + return { + status: 'pending', + reason: '起票対象', + repo: COMPONENT_REPOS[aiReport.component], + }; +} + +export function buildHandoffTraceBody( + aiReport: AIReport, + trace: HandoffTrace +): string { + return [ + '## ハンドオフ追跡', + `- 状態: ${trace.status}`, + `- 理由: ${trace.reason}`, + `- カテゴリ: ${aiReport.category}`, + `- 原因コンポーネント: ${aiReport.component ?? 'unknown'}`, + `- 原因コンポーネント信頼度: ${aiReport.componentConfidence}(閾値: ${PUBLIC_ISSUE_MIN_CONFIDENCE})`, + `- 引き継ぎ先: ${trace.repo ?? 'なし'}`, + ...(trace.httpStatus !== undefined + ? [`- GitHub HTTP ステータス: ${trace.httpStatus}`] + : []), + ...(trace.issueUrl ? [`- 対応 Issue: ${trace.issueUrl}`] : []), + ].join('\n'); } /** @@ -758,14 +810,14 @@ export function buildPublicIssueBody(params: { } /** - * 公開リポジトリへスタブ Issue を作成し、その URL を返す(失敗時は null)。 + * 公開リポジトリへスタブ Issue を作成し、成功・失敗の追跡結果を返す。 * ここで throw すると queue が再試行して非公開 Issue が重複作成されるため、 - * 失敗はログに留める。 + * 失敗は管理 Issue・保存済みマーカー・ログに記録する。 */ async function createPublicIssue( env: Env, params: { repo: string; internalIssueNumber: number; ticketId: string } -): Promise { +): Promise { const { repo, internalIssueNumber, ticketId } = params; try { // ラベルは公開リポジトリ側に存在しないと自動生成されてしまうため付けない。 @@ -781,36 +833,62 @@ async function createPublicIssue( status: res.status, internalIssueNumber, }); - return null; + return { + status: 'failed', + reason: 'GitHub 起票失敗', + repo, + httpStatus: res.status, + }; } const created = (await res.json()) as { html_url: string }; - return created.html_url; + if (typeof created.html_url !== 'string') { + return { + status: 'failed', + reason: 'GitHub 起票レスポンス不正(作成済みの可能性あり)', + repo, + httpStatus: res.status, + }; + } + return { + status: 'created', + reason: '起票完了', + repo, + httpStatus: res.status, + issueUrl: created.html_url, + }; } catch (err) { console.error('公開リポジトリへの起票に失敗', { repo, err }); - return null; + return { + status: 'failed', + reason: '通信またはレスポンス解析失敗(作成済みの可能性あり)', + repo, + }; } } -/** 非公開の管理 Issue 側に、公開 Issue へのリンクをコメントで残す(失敗しても無視)。 */ -async function linkPublicIssue( +/** 非公開の管理 Issue 側に起票結果を残す。投稿失敗時も結果は KV とログに残る。 */ +async function recordHandoffResult( env: Env, internalIssueNumber: number, - publicIssueUrl: string -): Promise { + body: string +): Promise { try { const res = await githubPost( env, `${INTERNAL_REPO}/issues/${internalIssueNumber}/comments`, - { body: `公開リポジトリに対応 Issue を起票しました: ${publicIssueUrl}` } + { body } ); if (res.status !== 201) { - console.error('管理 Issue への相互リンクコメントに失敗', { + console.error('管理 Issue へのハンドオフ結果コメントに失敗', { status: res.status, internalIssueNumber, }); + return false; } + return true; } catch (err) { - console.error('管理 Issue への相互リンクコメントに失敗', { err }); + console.error('管理 Issue へのハンドオフ結果コメントに失敗', { err }); + return false; } } @@ -835,6 +913,10 @@ export type TriageMarker = { /** 公開リポジトリに作成したスタブ Issue の URL(作っていなければ null) */ publicIssueUrl: string | null; aiReport: AIReport; + /** 旧マーカーには存在しない。起票結果を通知再試行でも保持する。 */ + handoff?: HandoffTrace; + /** false のときだけ、保存済みの結果コメントを再送する。旧マーカーは対象外。 */ + handoffCommentRecorded?: boolean; triageFailed: boolean; needsSpamReview: boolean; /** Discord 通知まで完了しているか */ @@ -906,6 +988,8 @@ async function loadTriageMarker( publicIssueUrl: typeof marker.publicIssueUrl === 'string' ? marker.publicIssueUrl : null, aiReport: marker.aiReport, + handoff: marker.handoff, + handoffCommentRecorded: marker.handoffCommentRecorded, triageFailed: marker.triageFailed === true, needsSpamReview: marker.needsSpamReview === true, notified: marker.notified === true, @@ -1403,7 +1487,7 @@ export const processFeedbackMessage = async ( // 再試行や DLQ からの再投入で同じレポートが流れてきたとき、Issue を重複起票しない // ように、処理済みマーカーを見て済んだ工程を飛ばす。 const marker = await loadTriageMarker(env, id); - if (marker?.notified) { + if (marker?.notified && marker.handoffCommentRecorded !== false) { console.warn( 'feedbackTriage: 処理済みのレポートを再受信したためスキップする', { reportId: id, issueNumber: marker.issueNumber } @@ -1446,6 +1530,15 @@ export const processFeedbackMessage = async ( let issueNumber = marker?.issueNumber ?? null; let issueUrl = marker?.issueUrl ?? null; let publicIssueUrl = marker?.publicIssueUrl ?? null; + let handoffCommentRecorded = marker?.handoffCommentRecorded ?? true; + + let handoff = + marker?.handoff ?? + resolveHandoffDecision(aiReport, { + reportType, + triageFailed, + needsSpamReview, + }); if (!marker) { const res = await githubPost(env, `${INTERNAL_REPO}/issues`, { @@ -1461,6 +1554,8 @@ ${'```'} ## AIによる要約 ${aiReport.summary} +${buildHandoffTraceBody(aiReport, handoff).replace('## ハンドオフ追跡', '## ハンドオフ判定(起票時)')} + ## チケットID ${id} @@ -1534,50 +1629,73 @@ ${reporterUid} // 原因コンポーネントが特定できている場合のみ、該当の公開リポジトリにも起票する。 // 公開側に載せるのは管理 Issue 番号とチケットIDだけで、フィードバックの内容は含めない。 // 起票後は管理 Issue 側にもコメントでリンクを残し、双方向に追えるようにする。 - const publicRepo = resolvePublicIssueRepo(aiReport, { - reportType, - triageFailed, - needsSpamReview, - }); - if (publicRepo && issueNumber !== null) { - publicIssueUrl = await createPublicIssue(env, { - repo: publicRepo, + if (handoff.repo && issueNumber !== null) { + handoff = await createPublicIssue(env, { + repo: handoff.repo, internalIssueNumber: issueNumber, ticketId: id, }); - if (publicIssueUrl) { - await linkPublicIssue(env, issueNumber, publicIssueUrl); - } + publicIssueUrl = handoff.issueUrl ?? null; + handoffCommentRecorded = false; + } else if (handoff.repo) { + handoff = { + ...handoff, + status: 'failed', + reason: '管理 Issue 番号を取得できなかった', + }; } + console.info('feedbackTriage: handoff', { + reportId: id, + internalIssueNumber: issueNumber, + component: aiReport.component, + componentConfidence: aiReport.componentConfidence, + threshold: PUBLIC_ISSUE_MIN_CONFIDENCE, + ...handoff, + }); - // 起票済みであることを先に永続化する。この後で落ちても、再試行は通知から再開する。 + // 起票済みであることを先に永続化する。この後で落ちても、再試行は未完了のコメント・通知から再開する。 await saveTriageMarker(env, id, { issueNumber, issueUrl, publicIssueUrl, aiReport, + handoff, + handoffCommentRecorded, triageFailed, needsSpamReview, notified: false, }); } - const notified = await notifyDiscord(env, { - report, - aiReport, - shouldTagTriage, - categoryLabel, - triageLabel, - autoModeLabel, - issueUrl, - publicIssueUrl, - }); + // 起票結果を先に保存し、コメント投稿だけを独立して再試行する。 + if (!handoffCommentRecorded && issueNumber !== null) { + handoffCommentRecorded = await recordHandoffResult( + env, + issueNumber, + buildHandoffTraceBody(aiReport, handoff) + ); + } + + const notified = + marker?.notified || + (await notifyDiscord(env, { + report, + aiReport, + shouldTagTriage, + categoryLabel, + triageLabel, + autoModeLabel, + issueUrl, + publicIssueUrl, + })); const markerSaved = await saveTriageMarker(env, id, { issueNumber, issueUrl, publicIssueUrl, aiReport, + handoff, + handoffCommentRecorded, triageFailed, needsSpamReview, // 通知に失敗したときは未通知のまま残す。再試行では起票を飛ばして通知だけ @@ -1585,18 +1703,22 @@ ${reporterUid} notified, }); - if (notified) return; + if (notified && handoffCommentRecorded) return; if (!markerSaved) { // マーカーを残せなかったので、再試行すると Issue を作り直してしまう。 - // 通知を諦めて ack する(フィードバック自体は起票済みで失われない)。 + // コメント・通知の再送を諦めて ack する(フィードバック自体は起票済み)。 console.error( - 'feedbackTriage: 通知に失敗したがマーカーも残せなかったため再試行しない', + 'feedbackTriage: コメントまたは通知に失敗したがマーカーも残せなかったため再試行しない', { reportId: id, issueNumber } ); return; } + if (!handoffCommentRecorded) { + throw new Error(`Handoff comment failed: ${id}`); + } + // 起票済みなので、再試行してもマーカーを見て通知から再開する(重複起票しない)。 // max_retries を使い切ったメッセージは DLQ に残り、Discord 側の障害・設定ミスに // 気づける。