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
77 changes: 70 additions & 7 deletions src/consumers/feedbackTriage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -497,24 +528,54 @@ 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);
expect(report.title).toBe(NON_ACTIONABLE_TITLE);
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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
47 changes: 37 additions & 10 deletions src/consumers/feedbackTriage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -158,9 +162,12 @@ const TRIAGE_SYNONYMS: Record<string, AITriageLevel> = {
* 断定してよいので、スコアリングに入る前に非スパムとして返す。
* 「〜が違います」「反映されない」「〜してほしい」のように、報告者が「不具合」「要望」と
* いう語を使わずに書くケースを取りこぼさないことを重視している。
* 「〜に変更お願いします」のような依頼も報告者自身の訴えなので、お願いで拾う。
* 「ご協力をお願いします」は車内放送の定型句でもあるため除外する。変更は単独では
* 入れない。「行き先を変更し」のように運転変更の放送にも現れるため。
*/
const ACTIONABLE =
/(修正|改善|追加|希望|要望|不具合|バグ|誤|間違|違い|違う|反映|表示|保存|再生|遅|遅延|できない|出来ない|できません|出来ません|されない|されません|しない|しません|エラー|落ちる|クラッシュ|重複|ズレ|ずれ|おかしい|ほしい|欲しい|直し|なおし|音がない|読み上げない)/;
/(修正|改善|追加|(?<!ご(協力|理解)を?)お願い|希望|要望|不具合|バグ|誤|間違|違い|違う|反映|表示|保存|再生|遅|遅延|できない|出来ない|できません|出来ません|されない|されません|しない|しません|エラー|落ちる|クラッシュ|重複|ズレ|ずれ|おかしい|ほしい|欲しい|直し|なおし|音がない|読み上げない)/;

/**
* 車内放送でも報告文でも使われる言い回し。単独では判断できないため早期リターンには
Expand Down Expand Up @@ -450,9 +457,9 @@ export function buildFailedReport(
}

/**
* ヒューリスティックがモデルの非スパム判定を覆せる、モデル側 confidence の上限。
* これ以上の確信度でモデルが「スパムではない」と言っているときは、ヒューリスティックは
* 上書きせず人手確認のマーカーだけを付ける。
* Jev の判定が無いときに、ヒューリスティックがスパムに倒してよいかを決める
* confidence の上限。判定が無い経路では confidence が 0 になるため、実質的には
* 常にヒューリスティックの判断が通る。
*/
export const SPAM_OVERRIDE_MAX_CONFIDENCE = 0.5;

Expand All @@ -464,13 +471,22 @@ export const NON_ACTIONABLE_TITLE = '内容未分類(改善要望なし)';
*
* ヒューリスティックは補助でしかなく、正当な報告を握りつぶすと利用者の声が
* 完全に失われる(ラベルもカテゴリも消えて候補プールから脱落する)。そのため
* モデルが確信を持って「スパムではない」と判定しているときは分類をそのまま残し、
* 人手確認用のマーカー(needsSpamReview)だけを立てる。
* Jev のスパム信号(spamSignal)が低く「スパムではない」と言えているときは分類を
* そのまま残し、人手確認用のマーカー(needsSpamReview)だけを立てる。
*
* 以前はカテゴリの confidence で上書きの可否を決めていたが、これは「改善要望か
* 新機能要望か」の迷いを表す値で、スパムかどうかの確信とは無関係だった。
* 実際に、Jev が is_spam 0.05 と判定した変更依頼が、カテゴリの confidence 0.4 を
* 理由にスパムへ上書きされた(TrainLCD/Issues#1281)。
*/
export function applySpamHeuristic(
aiReport: AIReport,
description: string,
opts: { triageFailed: boolean }
opts: {
triageFailed: boolean;
/** Jev のスパム信号。判定を取得できなかったときは null */
spamSignal: number | null;
}
): { report: AIReport; needsSpamReview: boolean } {
// トリアージ自体が失敗しているレポートは、そもそもモデルの判定が無い。
// ここでスパムに倒すと「要約失敗」の事実が消えるため触らない。
Expand All @@ -479,7 +495,11 @@ export function applySpamHeuristic(
if (!looksLikeSpam(description)) {
return { report: aiReport, needsSpamReview: false };
}
if (aiReport.confidence >= 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 {
Expand Down Expand Up @@ -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 }
);
}

Expand Down
11 changes: 11 additions & 0 deletions src/consumers/typesafeTriage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
});
9 changes: 9 additions & 0 deletions src/consumers/typesafeTriage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 判定など)も投機的に同梱してよい。
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -300,6 +308,7 @@ export function compose(answers: Record<string, Answer>): Verdict {
return {
isSpam,
needsSpamReview,
spamSignal: Math.max(spamSignal, announcement),
category,
categoryConfidence: cat.confidence,
component: isSpam ? 'unknown' : comp.choice,
Expand Down
Loading