Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d99b66e
TTSをAzure SpeechからOpenAI gpt-4o-mini-ttsの女性声へ全面移行 (#2)
TinyKitten Aug 13, 2026
b2ea58b
TTSの既定ボイスを日英で分けて早口寄りに調整する (#3)
TinyKitten Aug 13, 2026
7782d5c
AIチャットのモデルにGemini(Vertex AI)を追加する (#4)
TinyKitten Aug 17, 2026
bf4776d
TTSをOpenAIからGoogle Cloud Text-to-Speechへ全面移行する (#5)
TinyKitten Aug 17, 2026
16a6518
本番の AGENT_MODEL を Gemini(Vertex AI)へ切り替える (#6)
TinyKitten Aug 17, 2026
335bbf4
staging の駅検索を sapi-bff-stg から stationapi-stg へ切り替える (#7)
TinyKitten Aug 22, 2026
d7dcbb3
本番の駅検索を sapi-bff から stationapi へ切り替える (#9)
TinyKitten Aug 23, 2026
7370018
フィードバックを原因リポジトリへ振り分け、トリアージの起票品質を改善する (#14)
TinyKitten Aug 27, 2026
f850f3e
アプリ指定の読み上げ速度を受け付け既定速度を等速へ変更 (#16)
TinyKitten Aug 27, 2026
06ce6bc
フィードバックのキューに dead letter queue を設定する (#19)
TinyKitten Aug 27, 2026
631398b
DLQ の紐付けを確認する手順を README に追加する (#22)
TinyKitten Aug 27, 2026
8c47ab7
フィードバック再試行での Issue 重複起票を防ぐ (#23)
TinyKitten Aug 28, 2026
94a409c
AI エージェントのモデルを Gemini 3.8 Flash へ更新する (#24)
TinyKitten Sep 3, 2026
f3fa862
英語TTSで「Keisei」が「かいせい」と読まれる誤読を合成前の表記置換で修正する (#25)
TinyKitten Sep 6, 2026
e73f616
GitHub Actions で dev・master からのデプロイを自動化する (#26)
TinyKitten Sep 6, 2026
a63ffe3
英語TTSで「Seibu」が「さいぶ」と読まれる誤読を合成前の表記置換で修正する (#27)
TinyKitten Sep 6, 2026
439ec90
master を dev へ取り込み AGENTS.md を同期する (#30)
TinyKitten Sep 12, 2026
3dda858
フィードバックトリアージの判定をTypeSafeに切り替える (#32)
TinyKitten Sep 17, 2026
6ee0bef
提案駅のリランクをTypeSafeで判定して対話ターンへ組み込む (#33)
TinyKitten Sep 17, 2026
cce388d
リランクの注記を user ロールで注入し Gemini でのターン失敗を直す (#34)
TinyKitten Sep 17, 2026
a81fe64
フィードバックから自動で修正PRを出すワークフローを追加 (#36)
TinyKitten Sep 21, 2026
fe49160
feedback-autofixを更新し準備が失敗した場合も結果を報告するようにした (#38)
TinyKitten Sep 21, 2026
0c1e8f3
本文が空白のみのフィードバックをpostFeedbackで弾くようにする (#39)
TinyKitten Sep 21, 2026
69917f7
英語TTSでMineを「マイン」と読む誤読を修正する (#42)
TinyKitten Sep 21, 2026
11e1c66
行き先相談エージェントが乗換の必要な駅も行き先として提案するようにプロンプトとツール説明を更新 (#44)
TinyKitten Sep 22, 2026
b4a5484
行き先相談エージェントのモデルをGemini 3.8 Flashからgpt-5.6-lunaへ戻して応答の期限切れを防ぐ (#46)
TinyKitten Sep 22, 2026
513c8c0
行き先相談エージェントのモデルをgpt-6-lunaへ更新する (#47)
TinyKitten Sep 23, 2026
3bcca63
変更依頼のフィードバックがスパム扱いされる問題を修正 (#48)
TinyKitten Sep 24, 2026
c284b52
Merge remote-tracking branch 'origin/master' into release/2026-09-24-…
TinyKitten Sep 24, 2026
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