diff --git a/src/consumers/feedbackTriage.test.ts b/src/consumers/feedbackTriage.test.ts index a83fa50..bc54402 100644 --- a/src/consumers/feedbackTriage.test.ts +++ b/src/consumers/feedbackTriage.test.ts @@ -21,6 +21,7 @@ import { TRIAGE_FAILED_SUMMARY, triageMarkerKey, } from './feedbackTriage'; +import { SPAM_REVIEW_THRESHOLD } from './typesafeTriage'; describe('coerceReport', () => { it('returns defaults when category and triageLevel are missing', () => { @@ -449,6 +450,36 @@ describe('looksLikeSpam(正当な報告の誤判定)', () => { expect(looksLikeSpam('駅ナンバリングの表記がおかしいです')).toBe(false); }); + it('放送定型句を引用した変更依頼をスパムにしない', () => { + // 期待する放送文言を並べ、報告者の訴えは「変更お願いします」だけで示すケース + expect( + looksLikeSpam( + [ + '架空線普通は仮駅までしか行かないので仮駅行きに変更お願いします', + '例駅から各駅に止まります放送お願いします', + '架空線.見本線乗換え変更お願いします', + '見本線例駅方面', + ].join('\n') + ) + ).toBe(false); + }); + + it('「変更」を含む運転変更の放送はスパムとして扱う', () => { + expect( + looksLikeSpam( + '次は仮駅です。この電車は行き先を変更し、例駅方面へ向かいます。' + ) + ).toBe(true); + }); + + it('「ご協力をお願いします」は依頼として扱わない', () => { + expect( + looksLikeSpam( + '次は仮駅、仮駅です。この電車は各駅に停まります。例駅方面へお越しの方はお乗り換えです。ご協力をお願いします' + ) + ).toBe(true); + }); + it('放送定型句を伴わない停車駅・方面の言及だけでは加点しない', () => { // ACTIONABLE に一致しない書き方でも、放送の書き起こしでなければスパムにしない expect(looksLikeSpam('架空線の停車駅と方面の情報について')).toBe(false); @@ -484,11 +515,11 @@ describe('applySpamHeuristic', () => { const transcript = '次は仮駅、仮駅です。お出口は左側です。ご利用ありがとうございます。'; - it('モデルが確信を持って非スパムと判定していれば分類を維持し、人手確認に回す', () => { + it('Jev のスパム信号が低ければ分類を維持し、人手確認に回す', () => { const { report, needsSpamReview } = applySpamHeuristic( - notSpam(SPAM_OVERRIDE_MAX_CONFIDENCE), + notSpam(0.9), transcript, - { triageFailed: false } + { triageFailed: false, spamSignal: SPAM_REVIEW_THRESHOLD - 0.01 } ); expect(needsSpamReview).toBe(true); expect(report.isSpam).toBe(false); @@ -497,11 +528,23 @@ describe('applySpamHeuristic', () => { expect(report.category).toBe('bug'); }); - it('モデルの確信度が低い場合はヒューリスティックでスパムに倒す', () => { + it('カテゴリの確信度が低くても、Jev のスパム信号が低ければ上書きしない', () => { + // TrainLCD/Issues#1281: 改善要望か新機能要望かの迷いで confidence が 0.4 に + // なっただけの変更依頼が、スパムに上書きされていた const { report, needsSpamReview } = applySpamHeuristic( - notSpam(SPAM_OVERRIDE_MAX_CONFIDENCE - 0.01), + notSpam(0.4), + transcript, + { triageFailed: false, spamSignal: 0.18 } + ); + expect(needsSpamReview).toBe(true); + expect(report.isSpam).toBe(false); + }); + + it('Jev のスパム信号が確認下限以上ならヒューリスティックでスパムに倒す', () => { + const { report, needsSpamReview } = applySpamHeuristic( + notSpam(0.9), transcript, - { triageFailed: false } + { triageFailed: false, spamSignal: SPAM_REVIEW_THRESHOLD } ); expect(needsSpamReview).toBe(false); expect(report.isSpam).toBe(true); @@ -509,12 +552,30 @@ describe('applySpamHeuristic', () => { expect(report.labels).toEqual([]); }); + it('Jev の判定が無いときは confidence で上書きの可否を決める', () => { + const kept = applySpamHeuristic( + notSpam(SPAM_OVERRIDE_MAX_CONFIDENCE), + transcript, + { triageFailed: false, spamSignal: null } + ); + expect(kept.needsSpamReview).toBe(true); + expect(kept.report.isSpam).toBe(false); + + const overridden = applySpamHeuristic( + notSpam(SPAM_OVERRIDE_MAX_CONFIDENCE - 0.01), + transcript, + { triageFailed: false, spamSignal: null } + ); + expect(overridden.needsSpamReview).toBe(false); + expect(overridden.report.isSpam).toBe(true); + }); + it('正当な報告には何もしない', () => { const input = notSpam(0.9); const { report, needsSpamReview } = applySpamHeuristic( input, '架空線の停車駅が違います', - { triageFailed: false } + { triageFailed: false, spamSignal: 0.05 } ); expect(needsSpamReview).toBe(false); expect(report).toBe(input); @@ -524,6 +585,7 @@ describe('applySpamHeuristic', () => { const failed = buildFailedReport(transcript, 72); const { report, needsSpamReview } = applySpamHeuristic(failed, transcript, { triageFailed: true, + spamSignal: null, }); expect(needsSpamReview).toBe(false); expect(report).toBe(failed); @@ -534,6 +596,7 @@ describe('applySpamHeuristic', () => { const spam = { ...notSpam(0.9), isSpam: true }; const { report, needsSpamReview } = applySpamHeuristic(spam, transcript, { triageFailed: false, + spamSignal: 0.9, }); expect(needsSpamReview).toBe(false); expect(report).toBe(spam); diff --git a/src/consumers/feedbackTriage.ts b/src/consumers/feedbackTriage.ts index e497266..de5858b 100644 --- a/src/consumers/feedbackTriage.ts +++ b/src/consumers/feedbackTriage.ts @@ -13,7 +13,11 @@ import type { import type { DiscordEmbed } from '../models/common'; import type { Report } from '../models/feedback'; import type { Env, FeedbackQueueMessage } from '../types'; -import { judgeFeedback, type Verdict } from './typesafeTriage'; +import { + judgeFeedback, + SPAM_REVIEW_THRESHOLD, + type Verdict, +} from './typesafeTriage'; /** フィードバック原本を保管する非公開リポジトリ */ const INTERNAL_REPO = 'TrainLCD/Issues'; @@ -158,9 +162,12 @@ const TRIAGE_SYNONYMS: Record = { * 断定してよいので、スコアリングに入る前に非スパムとして返す。 * 「〜が違います」「反映されない」「〜してほしい」のように、報告者が「不具合」「要望」と * いう語を使わずに書くケースを取りこぼさないことを重視している。 + * 「〜に変更お願いします」のような依頼も報告者自身の訴えなので、お願いで拾う。 + * 「ご協力をお願いします」は車内放送の定型句でもあるため除外する。変更は単独では + * 入れない。「行き先を変更し」のように運転変更の放送にも現れるため。 */ const ACTIONABLE = - /(修正|改善|追加|希望|要望|不具合|バグ|誤|間違|違い|違う|反映|表示|保存|再生|遅|遅延|できない|出来ない|できません|出来ません|されない|されません|しない|しません|エラー|落ちる|クラッシュ|重複|ズレ|ずれ|おかしい|ほしい|欲しい|直し|なおし|音がない|読み上げない)/; + /(修正|改善|追加|(?= SPAM_OVERRIDE_MAX_CONFIDENCE) { + const canOverride = + opts.spamSignal === null + ? aiReport.confidence < SPAM_OVERRIDE_MAX_CONFIDENCE + : opts.spamSignal >= SPAM_REVIEW_THRESHOLD; + if (!canOverride) { return { report: aiReport, needsSpamReview: true }; } return { @@ -1133,13 +1153,20 @@ async function triageFeedback( const spamDecision = applySpamHeuristic(aiReport, report.description, { triageFailed, + spamSignal: judgment?.spamSignal ?? null, }); + if (!aiReport.isSpam && spamDecision.report.isSpam) { + console.warn('feedbackTriage: ヒューリスティックでスパムに上書きした', { + reportId: report.id, + spamSignal: judgment?.spamSignal ?? null, + }); + } aiReport = spamDecision.report; const { needsSpamReview } = spamDecision; if (needsSpamReview) { console.warn( 'feedbackTriage: スパム判定がモデルとヒューリスティックで不一致(人手確認に回す)', - { reportId: report.id, confidence: aiReport.confidence } + { reportId: report.id, spamSignal: judgment?.spamSignal ?? null } ); } diff --git a/src/consumers/typesafeTriage.test.ts b/src/consumers/typesafeTriage.test.ts index ecec037..f33252c 100644 --- a/src/consumers/typesafeTriage.test.ts +++ b/src/consumers/typesafeTriage.test.ts @@ -151,3 +151,14 @@ describe('needsSpamReview', () => { expect(v.needsSpamReview).toBe(false); }); }); + +describe('spamSignal', () => { + it('is_spam と is_announcement_transcript の大きい方を返す', () => { + expect(compose(answers({ spam: 0.2, announcement: 0.4 })).spamSignal).toBe( + 0.4 + ); + expect(compose(answers({ spam: 0.6, announcement: 0.1 })).spamSignal).toBe( + 0.6 + ); + }); +}); diff --git a/src/consumers/typesafeTriage.ts b/src/consumers/typesafeTriage.ts index 6edd8b9..d022bdc 100644 --- a/src/consumers/typesafeTriage.ts +++ b/src/consumers/typesafeTriage.ts @@ -92,6 +92,12 @@ const T = { REQUEST_MEDIUM: 1.0, } as const; +/** + * スパム確定には満たないが人手確認に回す下限。キーワード判定(looksLikeSpam)が + * Jev の非スパム判定を覆してよいかの境界にも使う。 + */ +export const SPAM_REVIEW_THRESHOLD = T.SPAM_REVIEW; + /** * 1 リクエストにまとめて投げる。TypeSafe の質問は互いに独立で並列評価されるため、 * 一部の入力でしか使わない質問(praise 判定など)も投機的に同梱してよい。 @@ -249,6 +255,8 @@ export const QUESTIONS = { export type Verdict = { isSpam: boolean; needsSpamReview: boolean; + /** is_spam と is_announcement_transcript の大きい方。スパムらしさの生の信号 */ + spamSignal: number; category: string; categoryConfidence: number; component: string; @@ -300,6 +308,7 @@ export function compose(answers: Record): Verdict { return { isSpam, needsSpamReview, + spamSignal: Math.max(spamSignal, announcement), category, categoryConfidence: cat.confidence, component: isSpam ? 'unknown' : comp.choice,