diff --git a/apps/server/src/modules/ai/ai.service.spec.ts b/apps/server/src/modules/ai/ai.service.spec.ts index 957ff29..670479b 100644 --- a/apps/server/src/modules/ai/ai.service.spec.ts +++ b/apps/server/src/modules/ai/ai.service.spec.ts @@ -1,7 +1,7 @@ import { AiService } from './ai.service'; import type { AiGatewayService } from './ai-gateway.service'; import { DECOMPOSE_SYSTEM_PROMPT, DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; -import { RISK_INTERPRET_TOOL_INPUT_SCHEMA } from './prompts/risk-interpret'; +import { RISK_INTERPRET_SYSTEM_PROMPT, RISK_INTERPRET_TOOL_INPUT_SCHEMA } from './prompts/risk-interpret'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { RiskInterpretDto } from './dto/risk-interpret.dto'; @@ -104,6 +104,38 @@ describe('AiService', () => { expect(callTool.mock.calls[0][0].userPrompt).toContain('风险上升'); }); + it('removes invalid page refresh suggestions from risk interpretation results', async () => { + const callTool = jest.fn().mockResolvedValue({ + toolName: 'submit_risk_interpretation', + toolInput: { + summary: '预计延期', + why: ['P1 Bug 增加'], + forecast: '预计 2026-07-04 可发', + suggestedActions: [ + '手动刷新小宝预警页面,等待风险自动更新', + '优先修复 P1 Bug 并安排测试复测', + ], + ownerHints: ['测试负责人同步失败用例'], + generatedAt: '2026-06-29T01:00:00.000Z', + }, + rawModel: 'test-model', + inputTokens: 10, + outputTokens: 20, + }); + const gateway = { + getActiveProvider: jest.fn().mockResolvedValue({ callTool }), + getActiveModel: jest.fn().mockResolvedValue('test-model'), + } as unknown as AiGatewayService; + const service = new AiService(gateway); + + const result = await service.interpretRisk(createRiskRequest()); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.suggestedActions).toEqual(['优先修复 P1 Bug 并安排测试复测']); + } + }); + it('returns PARSE_ERROR when risk provider returns malformed payload', async () => { const gateway = { getActiveProvider: jest.fn().mockResolvedValue({ @@ -211,6 +243,11 @@ describe('AiService', () => { expect((RISK_INTERPRET_TOOL_INPUT_SCHEMA as any).additionalProperties).toBe(false); }); + it('tells the risk model not to suggest page refresh or manual update triggers', () => { + expect(RISK_INTERPRET_SYSTEM_PROMPT).toContain('不要建议刷新页面'); + expect(RISK_INTERPRET_SYSTEM_PROMPT).toContain('系统会自动更新'); + }); + it('returns PARSE_ERROR when provider reports invalid tool arguments', async () => { const gateway = { getActiveProvider: jest.fn().mockResolvedValue({ diff --git a/apps/server/src/modules/ai/ai.service.ts b/apps/server/src/modules/ai/ai.service.ts index c3684ba..535ca87 100644 --- a/apps/server/src/modules/ai/ai.service.ts +++ b/apps/server/src/modules/ai/ai.service.ts @@ -29,6 +29,13 @@ const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const; const PROTOTYPE_FETCH_TIMEOUT_MS = 15000; const PROTOTYPE_FETCH_ATTEMPTS = 3; const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500; +const PAGE_REFRESH_ADVICE_PATTERNS = [ + /(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i, + /(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i, + /(手动|主动).*(触发|刷新).*(更新|预警|分析)/i, + /(manual|manually).*(refresh|reload|trigger)/i, + /(refresh|reload).*(page|browser|xiaobao|warning)/i, +]; @Injectable() export class AiService { @@ -222,8 +229,8 @@ export class AiService { summary, why, forecast, - suggestedActions, - ownerHints, + suggestedActions: this.filterPageRefreshAdvice(suggestedActions), + ownerHints: this.filterPageRefreshAdvice(ownerHints), generatedAt, }; if (input.recommendedReleaseWindow !== undefined) { @@ -234,6 +241,14 @@ export class AiService { return insight; } + private filterPageRefreshAdvice(items: string[]): string[] { + return items.filter((item) => !this.isPageRefreshAdvice(item)); + } + + private isPageRefreshAdvice(text: string): boolean { + return PAGE_REFRESH_ADVICE_PATTERNS.some((pattern) => pattern.test(text)); + } + private readString(value: unknown): string | null { return typeof value === 'string' ? value : null; } diff --git a/apps/server/src/modules/ai/prompts/risk-interpret.ts b/apps/server/src/modules/ai/prompts/risk-interpret.ts index 7f6216e..b5a8c67 100644 --- a/apps/server/src/modules/ai/prompts/risk-interpret.ts +++ b/apps/server/src/modules/ai/prompts/risk-interpret.ts @@ -13,6 +13,8 @@ export const RISK_INTERPRET_SYSTEM_PROMPT = `你是 FTB 项目管理系统中的 输入中的 signals 是规则引擎压缩后的结构化风险信号,优先用它判断 Bug、测试失败、阻塞、静默风险和临近发版的变化。 如果 confidence 低,必须提醒“预测可信度较低,需补充数据”。 +系统会自动更新小宝预警结果,用户不需要手动触发页面刷新或重新打开页面。 +suggestedActions 只能给业务动作,例如修复 Bug、补充日报、复测失败用例、解除阻塞、调整排期或确认负责人;不要建议刷新页面、手动触发更新、重新加载浏览器或等待页面自动更新。 输出必须通过 submit_risk_interpretation 工具返回,不要输出自然语言正文或 Markdown。`; diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 31c2feb..f779eb9 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -21,7 +21,7 @@ import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-r import { buildRiskInsightSignature, findLatestRiskInsightForVersion, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCooldown } from '@/lib/xiaobao-risk-ai'; import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; -import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; +import { filterXiaobaoWarningVersions, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; type RiskFilter = 'all' | 'attention' | 'high'; @@ -155,7 +155,7 @@ function XiaobaoWarningContent() { saveInsight({ versionId: risk.versionId, riskSignature: signature, - insight: response.result, + insight: sanitizeRiskInsight(response.result), generatedAt: new Date().toISOString(), providerInfo: { model: response.meta.model }, }).catch(() => {}); @@ -165,7 +165,7 @@ function XiaobaoWarningContent() { const risksWithInsight = useMemo(() => risks.map((risk) => { const cached = getReusableInsight(insights, risk); - return cached ? { ...risk, aiInsight: cached.insight } : risk; + return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk; }), [insights, risks]); const filteredRisks = useMemo(() => risksWithInsight.filter((risk) => { diff --git a/apps/web/lib/xiaobao-warning-view.test.ts b/apps/web/lib/xiaobao-warning-view.test.ts index fa0ed37..2b2c2f0 100644 --- a/apps/web/lib/xiaobao-warning-view.test.ts +++ b/apps/web/lib/xiaobao-warning-view.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { VersionWithContext } from './derive'; -import { filterXiaobaoWarningVersions, formatRemainingWork } from './xiaobao-warning-view'; +import { filterXiaobaoWarningVersions, formatRemainingWork, sanitizeRiskInsight } from './xiaobao-warning-view'; function version(patch: Partial = {}): VersionWithContext { return { @@ -52,3 +52,19 @@ test('formatRemainingWork keeps hours and adds work-day conversion', () => { assert.equal(formatRemainingWork(12), '12h / 1.5天'); assert.equal(formatRemainingWork(0.5), '0.5h / 0.1天'); }); + +test('sanitizeRiskInsight filters invalid page refresh suggested actions', () => { + const result = sanitizeRiskInsight({ + summary: '风险上升', + why: ['P1 Bug 增加'], + forecast: '预计延期 1 天', + suggestedActions: [ + '手动触发页面刷新,等待小宝预警自动更新', + '优先处理 3 个 P1 Bug,并同步测试负责人复测', + ], + ownerHints: ['研发负责人协调修复顺序'], + generatedAt: '2026-06-30T10:00:00.000Z', + }); + + assert.deepEqual(result.suggestedActions, ['优先处理 3 个 P1 Bug,并同步测试负责人复测']); +}); diff --git a/apps/web/lib/xiaobao-warning-view.ts b/apps/web/lib/xiaobao-warning-view.ts index 230e928..457e991 100644 --- a/apps/web/lib/xiaobao-warning-view.ts +++ b/apps/web/lib/xiaobao-warning-view.ts @@ -1,7 +1,15 @@ import type { VersionWithContext } from './derive'; +import type { XiaobaoRiskInsight } from './xiaobao-risk-cache'; import { WORK_HOURS } from './work-hours'; const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']); +const PAGE_REFRESH_ADVICE_PATTERNS = [ + /(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i, + /(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i, + /(手动|主动).*(触发|刷新).*(更新|预警|分析)/i, + /(manual|manually).*(refresh|reload|trigger)/i, + /(refresh|reload).*(page|browser|xiaobao|warning)/i, +]; export interface XiaobaoWarningVersionFilter { canManage: boolean; @@ -26,7 +34,19 @@ export function formatRemainingWork(hours: number): string { return `${formatNumber(safeHours)}h / ${formatNumber(days)}天`; } +export function sanitizeRiskInsight(insight: XiaobaoRiskInsight): XiaobaoRiskInsight { + return { + ...insight, + suggestedActions: insight.suggestedActions.filter((action) => !isPageRefreshAdvice(action)), + ownerHints: insight.ownerHints.filter((hint) => !isPageRefreshAdvice(hint)), + }; +} + function formatNumber(value: number): string { if (Number.isInteger(value)) return String(value); return value.toFixed(1).replace(/\.0$/, ''); } + +function isPageRefreshAdvice(text: string): boolean { + return PAGE_REFRESH_ADVICE_PATTERNS.some((pattern) => pattern.test(text)); +}