From 58c98a3a3dd77a8c4a758323de124856c8a75d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 17:30:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(xiaobao-ai):=20=E5=90=8E=E5=8F=B0=E5=8C=96?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E8=A7=A3=E8=AF=BB=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/xiaobao-ai/xiaobao-ai.module.ts | 20 + .../xiaobao-ai/xiaobao-ai.service.spec.ts | 259 ++++++++++++ .../modules/xiaobao-ai/xiaobao-ai.service.ts | 378 ++++++++++++++++++ .../modules/xiaobao-ai/xiaobao-ai.types.ts | 117 ++++++ .../xiaobao-ai/xiaobao-ai.worker.spec.ts | 38 ++ .../modules/xiaobao-ai/xiaobao-ai.worker.ts | 30 ++ .../xiaobao/xiaobao-risk.service.spec.ts | 35 +- .../modules/xiaobao/xiaobao-risk.service.ts | 6 +- .../src/modules/xiaobao/xiaobao.module.ts | 3 +- docs/architecture.md | 2 + docs/decisions.md | 16 + docs/roadmap.md | 2 + docs/workflow.md | 4 +- 13 files changed, 904 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts create mode 100644 apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts create mode 100644 apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts create mode 100644 apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts create mode 100644 apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts create mode 100644 apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts new file mode 100644 index 0000000..693bac1 --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { AiService } from '../ai/ai.service'; +import { AiModule } from '../ai/ai.module'; +import { JobsModule } from '../jobs/jobs.module'; +import { XiaobaoAiService } from './xiaobao-ai.service'; +import { XIAOBAO_AI_INTERPRETER, XIAOBAO_AI_PRISMA } from './xiaobao-ai.types'; +import { XiaobaoAiWorker } from './xiaobao-ai.worker'; + +@Module({ + imports: [AiModule, JobsModule], + providers: [ + { provide: XIAOBAO_AI_PRISMA, useExisting: PrismaService }, + { provide: XIAOBAO_AI_INTERPRETER, useExisting: AiService }, + XiaobaoAiService, + XiaobaoAiWorker, + ], + exports: [XiaobaoAiService, XiaobaoAiWorker], +}) +export class XiaobaoAiModule {} diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts new file mode 100644 index 0000000..329af0d --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts @@ -0,0 +1,259 @@ +import { JobsService } from '../jobs/jobs.service'; +import type { XiaobaoRiskSummaryPayload } from '../xiaobao/xiaobao-risk.types'; +import { XiaobaoAiService } from './xiaobao-ai.service'; +import { XIAOBAO_AI_INTERPRET_JOB } from './xiaobao-ai.types'; + +describe('XiaobaoAiService', () => { + function makeService() { + const prisma = { + xiaobaoRiskSummary: { + findUnique: jest.fn(), + }, + xiaobaoRiskInsight: { + findFirst: jest.fn(), + create: jest.fn(), + }, + version: { update: jest.fn() }, + devTask: { update: jest.fn() }, + testCase: { update: jest.fn() }, + bug: { update: jest.fn() }, + requirement: { update: jest.fn() }, + }; + const jobs = { + enqueue: jest.fn().mockResolvedValue({ id: 'job-1', status: 'queued' }), + }; + const ai = { + interpretRisk: jest.fn(), + }; + return { + prisma, + jobs, + ai, + service: new XiaobaoAiService(prisma as any, jobs as unknown as JobsService, ai as any), + }; + } + + it('enqueues an AI interpretation job for high-risk summaries without reusable cache', async () => { + const { prisma, jobs, service } = makeService(); + const summary = buildSummary({ riskLevel: 'at_risk', riskScore: 68, riskSignature: 'version-1|at_risk|68' }); + prisma.xiaobaoRiskInsight.findFirst.mockResolvedValue(null); + + const result = await service.evaluateSummary(summary, { now: new Date('2026-07-08T08:00:00.000Z') }); + + expect(result).toEqual({ enqueued: true, reason: 'queued', jobId: 'job-1' }); + expect(jobs.enqueue).toHaveBeenCalledWith({ + type: XIAOBAO_AI_INTERPRET_JOB, + dedupeKey: 'version-1:version-1|at_risk|68', + payload: { versionId: 'version-1', riskSignature: 'version-1|at_risk|68' }, + maxAttempts: 3, + }); + }); + + it('skips enqueue when the same risk signature already has a generated insight', async () => { + const { prisma, jobs, service } = makeService(); + const summary = buildSummary({ riskSignature: 'version-1|blocked|90' }); + prisma.xiaobaoRiskInsight.findFirst.mockResolvedValueOnce({ + versionId: 'version-1', + riskSignature: 'version-1|blocked|90', + createdAt: new Date('2026-07-08T07:00:00.000Z'), + }); + + const result = await service.evaluateSummary(summary, { now: new Date('2026-07-08T08:00:00.000Z') }); + + expect(result).toEqual({ enqueued: false, reason: 'cache_hit' }); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); + + it('respects the six-hour cooldown when risk level has not escalated', async () => { + const { prisma, jobs, service } = makeService(); + const summary = buildSummary({ riskLevel: 'at_risk', riskScore: 70, riskSignature: 'version-1|at_risk|70' }); + prisma.xiaobaoRiskInsight.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + versionId: 'version-1', + riskSignature: 'version-1|at_risk|64', + createdAt: new Date('2026-07-08T06:00:00.000Z'), + }); + + const result = await service.evaluateSummary(summary, { now: new Date('2026-07-08T08:00:00.000Z') }); + + expect(result).toEqual({ enqueued: false, reason: 'cooldown' }); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); + + it('bypasses cooldown when the risk level escalates', async () => { + const { prisma, jobs, service } = makeService(); + const summary = buildSummary({ riskLevel: 'blocked', riskScore: 92, riskSignature: 'version-1|blocked|92' }); + prisma.xiaobaoRiskInsight.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + versionId: 'version-1', + riskSignature: 'version-1|at_risk|70', + createdAt: new Date('2026-07-08T06:00:00.000Z'), + }); + + const result = await service.evaluateSummary(summary, { now: new Date('2026-07-08T08:00:00.000Z') }); + + expect(result).toEqual({ enqueued: true, reason: 'risk_escalated', jobId: 'job-1' }); + expect(jobs.enqueue).toHaveBeenCalled(); + }); + + it('does not enqueue on-track summaries', async () => { + const { jobs, service } = makeService(); + + const result = await service.evaluateSummary(buildSummary({ riskLevel: 'on_track', riskScore: 10 }), { + now: new Date('2026-07-08T08:00:00.000Z'), + }); + + expect(result).toEqual({ enqueued: false, reason: 'policy_skip' }); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); + + it('generates and stores an insight cache item without mutating business entities', async () => { + const { prisma, ai, service } = makeService(); + const now = new Date('2026-07-08T08:00:00.000Z'); + const summary = buildSummary({ riskLevel: 'blocked', riskScore: 90, riskSignature: 'version-1|blocked|90' }); + prisma.xiaobaoRiskSummary.findUnique.mockResolvedValue({ + versionId: 'version-1', + riskSignature: summary.riskSignature, + summary, + }); + prisma.xiaobaoRiskInsight.findFirst.mockResolvedValue(null); + ai.interpretRisk.mockResolvedValue({ + ok: true, + result: { + summary: '存在发布阻塞', + why: ['关键缺陷未关闭'], + forecast: '预计延期', + suggestedActions: ['优先修复阻塞项'], + ownerHints: ['测试负责人跟进复测'], + generatedAt: 'model-time-should-not-be-cache-time', + }, + meta: { model: 'test-model', inputTokens: 10, outputTokens: 20, durationMs: 100 }, + }); + + await service.runInterpretation( + { versionId: 'version-1', riskSignature: 'version-1|blocked|90' }, + { now }, + ); + + expect(ai.interpretRisk).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + riskLevel: 'blocked', + reasons: [{ key: 'critical_bug', label: '关键缺陷', severity: 'critical', detail: '仍有关键缺陷' }], + })); + expect(prisma.xiaobaoRiskInsight.create).toHaveBeenCalledWith({ + data: { + versionId: 'version-1', + riskSignature: 'version-1|blocked|90', + status: 'generated', + insight: { + versionId: 'version-1', + riskSignature: 'version-1|blocked|90', + generatedAt: now.toISOString(), + insight: expect.objectContaining({ summary: '存在发布阻塞' }), + providerInfo: { model: 'test-model' }, + }, + createdAt: now, + }, + }); + expect(prisma.version.update).not.toHaveBeenCalled(); + expect(prisma.devTask.update).not.toHaveBeenCalled(); + expect(prisma.testCase.update).not.toHaveBeenCalled(); + expect(prisma.bug.update).not.toHaveBeenCalled(); + expect(prisma.requirement.update).not.toHaveBeenCalled(); + }); + + it('skips stale interpretation jobs when the summary signature has moved on', async () => { + const { prisma, ai, service } = makeService(); + prisma.xiaobaoRiskSummary.findUnique.mockResolvedValue({ + versionId: 'version-1', + riskSignature: 'version-1|blocked|95', + summary: buildSummary({ riskSignature: 'version-1|blocked|95' }), + }); + + await service.runInterpretation({ versionId: 'version-1', riskSignature: 'version-1|blocked|90' }); + + expect(ai.interpretRisk).not.toHaveBeenCalled(); + expect(prisma.xiaobaoRiskInsight.create).not.toHaveBeenCalled(); + }); + + it('throws when AI interpretation fails so the background job can retry', async () => { + const { prisma, ai, service } = makeService(); + const summary = buildSummary({ riskSignature: 'version-1|blocked|90' }); + prisma.xiaobaoRiskSummary.findUnique.mockResolvedValue({ + versionId: 'version-1', + riskSignature: summary.riskSignature, + summary, + }); + prisma.xiaobaoRiskInsight.findFirst.mockResolvedValue(null); + ai.interpretRisk.mockResolvedValue({ ok: false, code: 'API_ERROR', error: 'provider down' }); + + await expect(service.runInterpretation({ versionId: 'version-1', riskSignature: summary.riskSignature })) + .rejects.toThrow('provider down'); + expect(prisma.xiaobaoRiskInsight.create).not.toHaveBeenCalled(); + }); +}); + +function buildSummary(patch: Partial = {}): XiaobaoRiskSummaryPayload { + const riskLevel = patch.riskLevel ?? 'blocked'; + const riskScore = (patch.riskScore as number | undefined) ?? 88; + return { + versionId: 'version-1', + versionName: 'V1.0', + productName: 'FTB', + projectName: '智能项目管理', + expectedReleaseDate: '2026-07-09', + forecastReleaseDate: '2026-07-12T08:00:00.000Z', + delayDays: 3, + remainingWorkHours: 24, + riskLevel, + riskScore, + confidence: 80, + riskSignature: patch.riskSignature ?? `version-1|${riskLevel}|${riskScore}`, + signals: { + unfinishedCount: 4, + openBugCount: 2, + criticalBugCount: 1, + failedTestCount: 1, + blockedCount: 1, + silentRiskCount: 0, + daysToExpectedRelease: 1, + }, + reasons: [ + { + key: 'critical_bug', + title: '关键缺陷', + severity: 'danger', + detail: '仍有关键缺陷', + }, + ], + dailyEvidence: { + todayDeliveries: [{ summary: '提交修复分支' }], + todayProgress: [], + todayCreations: [], + todayRisks: [{ summary: '关键缺陷仍未关闭' }], + progressNotes: [], + needsProgressItems: [], + recentActivityCount: 1, + totalActivityCount: 4, + todayActualHours: 2, + lastActivityAt: '2026-07-08T07:00:00.000Z', + silentRisks: [], + }, + currentSnapshot: { + versionId: 'version-1', + date: '2026-07-08', + riskScore, + riskLevel, + openBugCount: 2, + criticalBugCount: 1, + failedTestCount: 1, + blockedCount: 1, + silentRiskCount: 0, + confidence: 80, + createdAt: '2026-07-08T08:00:00.000Z', + }, + ...patch, + }; +} diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts new file mode 100644 index 0000000..6a6fe70 --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts @@ -0,0 +1,378 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { JobsService } from '../jobs/jobs.service'; +import type { XiaobaoRiskLevel, XiaobaoRiskSummaryPayload } from '../xiaobao/xiaobao-risk.types'; +import { + XIAOBAO_AI_COOLDOWN_MS, + XIAOBAO_AI_INTERPRETER, + XIAOBAO_AI_INTERPRET_JOB, + XIAOBAO_AI_PRISMA, + XIAOBAO_RISK_LEVEL_RANK, + XiaobaoAiEvaluateOptions, + XiaobaoAiEvaluateResult, + XiaobaoAiGenerationResult, + XiaobaoAiInterpreter, + XiaobaoAiInterpretPayload, + XiaobaoAiRiskInterpretRequest, +} from './xiaobao-ai.types'; + +@Injectable() +export class XiaobaoAiService { + constructor( + @Inject(XIAOBAO_AI_PRISMA) private readonly prisma: any, + private readonly jobs: JobsService, + @Inject(XIAOBAO_AI_INTERPRETER) private readonly ai: XiaobaoAiInterpreter, + ) {} + + async evaluateSummary( + summary: XiaobaoRiskSummaryPayload, + options: XiaobaoAiEvaluateOptions = {}, + ): Promise { + const now = options.now ?? new Date(); + const gate = await this.evaluateGenerationGate(summary, now); + if (!gate.allow) return { enqueued: false, reason: gate.reason }; + + const job = await this.jobs.enqueue({ + type: XIAOBAO_AI_INTERPRET_JOB, + dedupeKey: buildDedupeKey(summary.versionId, summary.riskSignature), + payload: { + versionId: summary.versionId, + riskSignature: summary.riskSignature, + }, + maxAttempts: 3, + }); + + return { + enqueued: true, + reason: gate.escalated ? 'risk_escalated' : 'queued', + jobId: job?.id, + }; + } + + async evaluateCurrentSummary( + versionId: string, + options: XiaobaoAiEvaluateOptions = {}, + ): Promise { + const row = await this.prisma.xiaobaoRiskSummary.findUnique({ where: { versionId } }); + if (!row) throw new NotFoundException('Xiaobao risk summary not found'); + return this.evaluateSummary(coerceSummary(row), options); + } + + async runInterpretation( + payload: XiaobaoAiInterpretPayload, + options: XiaobaoAiEvaluateOptions = {}, + ): Promise { + const now = options.now ?? new Date(); + const row = await this.prisma.xiaobaoRiskSummary.findUnique({ where: { versionId: payload.versionId } }); + if (!row) throw new NotFoundException('Xiaobao risk summary not found'); + + const summary = coerceSummary(row); + if (summary.riskSignature !== payload.riskSignature) { + return { generated: false, reason: 'stale' }; + } + + const gate = await this.evaluateGenerationGate(summary, now); + if (!gate.allow) return { generated: false, reason: gate.reason }; + + const response = await this.ai.interpretRisk(buildRiskInterpretRequest(summary)); + if (!response.ok) { + throw new Error(response.error || 'AI risk interpretation failed'); + } + + await this.prisma.xiaobaoRiskInsight.create({ + data: { + versionId: summary.versionId, + riskSignature: summary.riskSignature, + status: 'generated', + insight: { + versionId: summary.versionId, + riskSignature: summary.riskSignature, + generatedAt: now.toISOString(), + insight: response.result, + providerInfo: { model: response.meta.model }, + }, + createdAt: now, + }, + }); + + return { generated: true, reason: 'generated' }; + } + + private async evaluateGenerationGate( + summary: XiaobaoRiskSummaryPayload, + now: Date, + ): Promise< + | { allow: true; escalated: boolean } + | { allow: false; reason: 'policy_skip' | 'cache_hit' | 'cooldown' } + > { + if (!shouldRequestRiskInsight(summary)) { + return { allow: false, reason: 'policy_skip' }; + } + + const reusable = await this.findReusableInsight(summary); + if (reusable) return { allow: false, reason: 'cache_hit' }; + + const latest = await this.findLatestInsight(summary.versionId); + const escalated = latest ? isRiskLevelEscalation(summary.riskLevel, latest.riskSignature) : false; + if (latest && !escalated && isWithinCooldown(latest, now)) { + return { allow: false, reason: 'cooldown' }; + } + + return { allow: true, escalated }; + } + + private findReusableInsight(summary: XiaobaoRiskSummaryPayload): Promise { + return this.prisma.xiaobaoRiskInsight.findFirst({ + where: { + versionId: summary.versionId, + riskSignature: summary.riskSignature, + status: 'generated', + }, + orderBy: { createdAt: 'desc' }, + }); + } + + private findLatestInsight(versionId: string): Promise { + return this.prisma.xiaobaoRiskInsight.findFirst({ + where: { + versionId, + status: 'generated', + }, + orderBy: { createdAt: 'desc' }, + }); + } +} + +function buildRiskInterpretRequest(summary: XiaobaoRiskSummaryPayload): XiaobaoAiRiskInterpretRequest { + return { + versionId: summary.versionId, + versionName: summary.versionName, + productName: summary.productName, + projectName: summary.projectName, + riskScore: summary.riskScore, + riskLevel: summary.riskLevel, + expectedReleaseDate: summary.expectedReleaseDate, + forecastReleaseDate: summary.forecastReleaseDate, + delayDays: summary.delayDays, + confidence: summary.confidence, + signals: summary.signals, + trendSummary: buildTrendSummary(summary), + reasons: summary.reasons.map((reason) => ({ + key: reason.key, + label: reason.title, + severity: mapReasonSeverity(reason), + detail: reason.detail, + })), + silentRisks: summarizeSilentRisks(summary.dailyEvidence.silentRisks), + dailyEvidence: { + todayDeliveries: summarizeEvidence(summary.dailyEvidence.todayDeliveries), + todayProgress: summarizeEvidence(summary.dailyEvidence.todayProgress), + todayCreations: summarizeEvidence(summary.dailyEvidence.todayCreations), + todayRisks: summarizeEvidence(summary.dailyEvidence.todayRisks), + progressNotes: summarizeEvidence(summary.dailyEvidence.progressNotes), + needsProgressItems: summarizeEvidence(summary.dailyEvidence.needsProgressItems), + recentActivityCount: summary.dailyEvidence.recentActivityCount, + totalActivityCount: summary.dailyEvidence.totalActivityCount, + todayActualHours: summary.dailyEvidence.todayActualHours, + lastActivityAt: summary.dailyEvidence.lastActivityAt, + }, + }; +} + +function shouldRequestRiskInsight(summary: XiaobaoRiskSummaryPayload): boolean { + if (summary.riskLevel === 'on_track') return false; + if (summary.riskLevel === 'at_risk' || summary.riskLevel === 'likely_delayed' || summary.riskLevel === 'blocked') { + return true; + } + + return ( + summary.riskLevel === 'attention' + && summary.signals.daysToExpectedRelease !== undefined + && summary.signals.daysToExpectedRelease <= 1 + && summary.signals.unfinishedCount > 0 + ); +} + +function isWithinCooldown(latest: any, now: Date): boolean { + const createdAt = toDate(latest?.createdAt) ?? toDate(latest?.updatedAt); + if (!createdAt) return false; + return now.getTime() - createdAt.getTime() < XIAOBAO_AI_COOLDOWN_MS; +} + +function isRiskLevelEscalation(currentLevel: XiaobaoRiskLevel, latestSignature: string): boolean { + const previousLevel = parseRiskLevel(latestSignature); + if (!previousLevel) return false; + return XIAOBAO_RISK_LEVEL_RANK[currentLevel] > XIAOBAO_RISK_LEVEL_RANK[previousLevel]; +} + +function parseRiskLevel(signature: string): XiaobaoRiskLevel | undefined { + try { + const parsed = JSON.parse(signature) as { riskLevel?: XiaobaoRiskLevel }; + if (parsed.riskLevel && parsed.riskLevel in XIAOBAO_RISK_LEVEL_RANK) return parsed.riskLevel; + } catch { + // Fall through to pipe-string compatibility. + } + + const parts = signature.split('|'); + return parts.find((part): part is XiaobaoRiskLevel => part in XIAOBAO_RISK_LEVEL_RANK); +} + +function buildDedupeKey(versionId: string, riskSignature: string): string { + return `${versionId}:${riskSignature}`; +} + +function buildTrendSummary(summary: XiaobaoRiskSummaryPayload): string { + if (summary.delayDays > 0) { + return `当前风险等级 ${summary.riskLevel},风险分 ${summary.riskScore},预计延期 ${summary.delayDays} 天。`; + } + return `当前风险等级 ${summary.riskLevel},风险分 ${summary.riskScore}。`; +} + +function mapReasonSeverity(reason: XiaobaoRiskSummaryPayload['reasons'][number]): XiaobaoAiRiskInterpretRequest['reasons'][number]['severity'] { + if (reason.severity === 'info') return 'low'; + if (reason.severity === 'warning') return 'medium'; + if (reason.key === 'critical_bug' || reason.key === 'blocked_work') return 'critical'; + return 'high'; +} + +function summarizeEvidence(items: unknown[]): string[] { + return items.map(summarizeEvidenceItem).filter((item) => item.length > 0); +} + +function summarizeEvidenceItem(item: unknown): string { + if (typeof item === 'string') return item.trim(); + if (!isRecord(item)) return ''; + const summary = readString(item.summary); + if (summary) return summary; + const title = readString(item.title); + const detail = readString(item.detail); + return [title, detail].filter(Boolean).join(':').trim(); +} + +function summarizeSilentRisks(items: unknown[]): XiaobaoAiRiskInterpretRequest['silentRisks'] { + return items + .map((item) => { + if (!isRecord(item)) return null; + const key = readString(item.key); + const detail = readString(item.detail) || readString(item.summary); + if (!key || !detail) return null; + const days = readNumber(item.days); + return days === undefined ? { key, detail } : { key, detail, days }; + }) + .filter((item): item is XiaobaoAiRiskInterpretRequest['silentRisks'][number] => Boolean(item)); +} + +function coerceSummary(row: any): XiaobaoRiskSummaryPayload { + const source = isRecord(row?.summary) ? row.summary : {}; + const versionId = readString(source.versionId) ?? String(row.versionId); + const riskLevel = readRiskLevel(source.riskLevel) ?? readRiskLevel(row.riskLevel) ?? 'attention'; + const riskScore = readNumber(source.riskScore) ?? readNumber(row.riskScore) ?? 0; + const confidence = readNumber(source.confidence) ?? readNumber(row.confidence) ?? 0; + const riskSignature = readString(source.riskSignature) ?? String(row.riskSignature ?? `${versionId}|${riskLevel}|${riskScore}`); + const dailyEvidence = readRecord(source.dailyEvidence); + const signals = readRecord(source.signals); + const currentSnapshot = readRecord(source.currentSnapshot); + + return { + versionId, + versionName: readString(source.versionName) ?? versionId, + productId: readString(source.productId) ?? undefined, + productName: readString(source.productName) ?? undefined, + projectId: readString(source.projectId), + projectName: readString(source.projectName) ?? undefined, + expectedReleaseDate: readString(source.expectedReleaseDate) ?? null, + riskScore, + riskLevel, + confidence, + forecastReleaseDate: readString(source.forecastReleaseDate) ?? undefined, + delayDays: readNumber(source.delayDays) ?? 0, + remainingWorkHours: readNumber(source.remainingWorkHours) ?? 0, + riskSignature, + signals: { + unfinishedCount: readNumber(signals.unfinishedCount) ?? 0, + openBugCount: readNumber(signals.openBugCount) ?? 0, + criticalBugCount: readNumber(signals.criticalBugCount) ?? 0, + failedTestCount: readNumber(signals.failedTestCount) ?? 0, + blockedCount: readNumber(signals.blockedCount) ?? 0, + silentRiskCount: readNumber(signals.silentRiskCount) ?? 0, + daysToExpectedRelease: readNumber(signals.daysToExpectedRelease), + }, + reasons: readReasons(source.reasons), + dailyEvidence: { + todayDeliveries: readArray(dailyEvidence.todayDeliveries), + todayProgress: readArray(dailyEvidence.todayProgress), + todayCreations: readArray(dailyEvidence.todayCreations), + todayRisks: readArray(dailyEvidence.todayRisks), + progressNotes: readArray(dailyEvidence.progressNotes), + needsProgressItems: readArray(dailyEvidence.needsProgressItems), + recentActivityCount: readNumber(dailyEvidence.recentActivityCount) ?? 0, + totalActivityCount: readNumber(dailyEvidence.totalActivityCount) ?? 0, + todayActualHours: readNumber(dailyEvidence.todayActualHours) ?? 0, + lastActivityAt: readString(dailyEvidence.lastActivityAt) ?? undefined, + silentRisks: readArray(dailyEvidence.silentRisks), + }, + currentSnapshot: { + versionId, + date: readString(currentSnapshot.date) ?? new Date().toISOString().slice(0, 10), + riskScore, + riskLevel, + forecastReleaseDate: readString(currentSnapshot.forecastReleaseDate) ?? undefined, + openBugCount: readNumber(currentSnapshot.openBugCount) ?? 0, + criticalBugCount: readNumber(currentSnapshot.criticalBugCount) ?? 0, + failedTestCount: readNumber(currentSnapshot.failedTestCount) ?? 0, + blockedCount: readNumber(currentSnapshot.blockedCount) ?? 0, + silentRiskCount: readNumber(currentSnapshot.silentRiskCount) ?? 0, + confidence, + createdAt: readString(currentSnapshot.createdAt) ?? new Date().toISOString(), + }, + }; +} + +function readReasons(value: unknown): XiaobaoRiskSummaryPayload['reasons'] { + return readArray(value) + .map((item) => { + if (!isRecord(item)) return null; + const key = readString(item.key); + const title = readString(item.title) ?? readString(item.label); + const detail = readString(item.detail); + const severity = readSummarySeverity(item.severity); + if (!key || !title || !detail || !severity) return null; + const count = readNumber(item.count); + return count === undefined ? { key, title, detail, severity } : { key, title, detail, severity, count }; + }) + .filter((item): item is XiaobaoRiskSummaryPayload['reasons'][number] => Boolean(item)); +} + +function readSummarySeverity(value: unknown): XiaobaoRiskSummaryPayload['reasons'][number]['severity'] | undefined { + return value === 'info' || value === 'warning' || value === 'danger' ? value : undefined; +} + +function readRiskLevel(value: unknown): XiaobaoRiskLevel | undefined { + return typeof value === 'string' && value in XIAOBAO_RISK_LEVEL_RANK ? value as XiaobaoRiskLevel : undefined; +} + +function toDate(value: unknown): Date | undefined { + if (value instanceof Date && Number.isFinite(value.getTime())) return value; + if (typeof value !== 'string') return undefined; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : undefined; +} + +function readArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts new file mode 100644 index 0000000..14ecb5a --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts @@ -0,0 +1,117 @@ +import type { XiaobaoRiskLevel } from '../xiaobao/xiaobao-risk.types'; + +export const XIAOBAO_AI_PRISMA = 'XIAOBAO_AI_PRISMA'; +export const XIAOBAO_AI_INTERPRETER = 'XIAOBAO_AI_INTERPRETER'; +export const XIAOBAO_AI_INTERPRET_JOB = 'xiaobao.ai.interpret'; +export const XIAOBAO_AI_COOLDOWN_MS = 6 * 60 * 60 * 1000; + +export type XiaobaoAiQueueReason = 'queued' | 'risk_escalated'; +export type XiaobaoAiSkipReason = 'policy_skip' | 'cache_hit' | 'cooldown' | 'stale'; + +export interface XiaobaoAiEvaluateOptions { + now?: Date; +} + +export interface XiaobaoAiInterpretPayload { + versionId: string; + riskSignature: string; +} + +export interface XiaobaoAiRiskInterpretRequest { + versionId: string; + versionName: string; + productName?: string; + projectName?: string; + riskScore: number; + riskLevel: 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; + expectedReleaseDate?: string | null; + forecastReleaseDate?: string; + delayDays: number; + confidence: number; + signals: { + unfinishedCount: number; + openBugCount: number; + criticalBugCount: number; + failedTestCount: number; + blockedCount: number; + silentRiskCount: number; + daysToExpectedRelease?: number; + }; + trendSummary: string; + reasons: Array<{ + key: string; + label: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + detail: string; + }>; + silentRisks: Array<{ key: string; days?: number; detail: string }>; + dailyEvidence: { + todayDeliveries: string[]; + todayProgress: string[]; + todayCreations: string[]; + todayRisks: string[]; + progressNotes: string[]; + needsProgressItems: string[]; + recentActivityCount: number; + totalActivityCount: number; + todayActualHours: number; + lastActivityAt?: string; + }; +} + +export interface XiaobaoAiRiskInsight { + summary: string; + why: string[]; + forecast: string; + recommendedReleaseWindow?: string; + suggestedActions: string[]; + ownerHints: string[]; + generatedAt: string; +} + +export type XiaobaoAiRiskInterpretResponse = + | { + ok: true; + result: XiaobaoAiRiskInsight; + meta: { + model: string; + inputTokens: number; + outputTokens: number; + durationMs: number; + }; + } + | { + ok: false; + error: string; + code: 'API_ERROR' | 'PARSE_ERROR' | 'NO_PROVIDER' | 'UNKNOWN'; + }; + +export interface XiaobaoAiInterpreter { + interpretRisk(req: XiaobaoAiRiskInterpretRequest): Promise; +} + +export interface XiaobaoAiEvaluateQueuedResult { + enqueued: true; + reason: XiaobaoAiQueueReason; + jobId?: string; +} + +export interface XiaobaoAiEvaluateSkippedResult { + enqueued: false; + reason: XiaobaoAiSkipReason; +} + +export type XiaobaoAiEvaluateResult = XiaobaoAiEvaluateQueuedResult | XiaobaoAiEvaluateSkippedResult; + +export interface XiaobaoAiGenerationResult { + generated: boolean; + reason: 'generated' | XiaobaoAiSkipReason; +} + +export const XIAOBAO_RISK_LEVEL_RANK: Record = { + on_track: 0, + attention: 1, + at_risk: 2, + likely_delayed: 3, + blocked: 4, +}; diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts new file mode 100644 index 0000000..e18e013 --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts @@ -0,0 +1,38 @@ +import { BackgroundJobWorker } from '../jobs/background-job.worker'; +import { XiaobaoAiWorker } from './xiaobao-ai.worker'; + +describe('XiaobaoAiWorker', () => { + it('registers the AI interpretation handler on module init', async () => { + const worker = { + registerHandler: jest.fn(), + }; + const service = { + runInterpretation: jest.fn().mockResolvedValue(undefined), + }; + const aiWorker = new XiaobaoAiWorker(worker as unknown as BackgroundJobWorker, service as any); + + aiWorker.onModuleInit(); + + expect(worker.registerHandler).toHaveBeenCalledWith('xiaobao.ai.interpret', expect.any(Function)); + const handler = worker.registerHandler.mock.calls[0][1]; + await handler({ versionId: 'version-1', riskSignature: 'sig-1' }, { id: 'job-1' }); + expect(service.runInterpretation).toHaveBeenCalledWith({ versionId: 'version-1', riskSignature: 'sig-1' }); + }); + + it('rejects malformed interpretation payloads', async () => { + const worker = { + registerHandler: jest.fn(), + }; + const service = { + runInterpretation: jest.fn(), + }; + const aiWorker = new XiaobaoAiWorker(worker as unknown as BackgroundJobWorker, service as any); + + aiWorker.onModuleInit(); + const handler = worker.registerHandler.mock.calls[0][1]; + + await expect(handler({ versionId: 'version-1' }, { id: 'job-1' })) + .rejects.toThrow('xiaobao.ai.interpret requires payload.versionId and payload.riskSignature'); + expect(service.runInterpretation).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts new file mode 100644 index 0000000..8f8a917 --- /dev/null +++ b/apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts @@ -0,0 +1,30 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { BackgroundJobWorker } from '../jobs/background-job.worker'; +import { XIAOBAO_AI_INTERPRET_JOB, XiaobaoAiInterpretPayload } from './xiaobao-ai.types'; +import { XiaobaoAiService } from './xiaobao-ai.service'; + +@Injectable() +export class XiaobaoAiWorker implements OnModuleInit { + constructor( + private readonly worker: BackgroundJobWorker, + private readonly xiaobaoAi: XiaobaoAiService, + ) {} + + onModuleInit() { + this.worker.registerHandler(XIAOBAO_AI_INTERPRET_JOB, async (payload) => { + await this.xiaobaoAi.runInterpretation(readPayload(payload)); + }); + } +} + +function readPayload(payload: unknown): XiaobaoAiInterpretPayload { + if (!payload || typeof payload !== 'object') { + throw new Error('xiaobao.ai.interpret requires payload.versionId and payload.riskSignature'); + } + const versionId = (payload as { versionId?: unknown }).versionId; + const riskSignature = (payload as { riskSignature?: unknown }).riskSignature; + if (typeof versionId !== 'string' || !versionId.trim() || typeof riskSignature !== 'string' || !riskSignature.trim()) { + throw new Error('xiaobao.ai.interpret requires payload.versionId and payload.riskSignature'); + } + return { versionId: versionId.trim(), riskSignature: riskSignature.trim() }; +} diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts index a341e7c..3edd679 100644 --- a/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.service.spec.ts @@ -2,7 +2,7 @@ import { JobsService } from '../jobs/jobs.service'; import { XiaobaoRiskService } from './xiaobao-risk.service'; describe('XiaobaoRiskService', () => { - function makeService() { + function makeService(xiaobaoAi?: { evaluateSummary: jest.Mock }) { const prisma = { version: { findUnique: jest.fn() }, devTask: { findMany: jest.fn() }, @@ -18,7 +18,7 @@ describe('XiaobaoRiskService', () => { const jobs = { enqueue: jest.fn(), }; - return { prisma, jobs, service: new XiaobaoRiskService(prisma as any, jobs as unknown as JobsService) }; + return { prisma, jobs, service: new XiaobaoRiskService(prisma as any, jobs as unknown as JobsService, xiaobaoAi as any) }; } it('refreshes a version risk summary from relational rows without opening the page', async () => { @@ -84,4 +84,35 @@ describe('XiaobaoRiskService', () => { maxAttempts: 5, }); }); + + it('evaluates AI interpretation enqueue policy after refreshing the summary', async () => { + const xiaobaoAi = { + evaluateSummary: jest.fn().mockResolvedValue({ enqueued: true, reason: 'queued' }), + }; + const { prisma, service } = makeService(xiaobaoAi); + const now = new Date('2026-07-08T09:00:00.000Z'); + prisma.version.findUnique.mockResolvedValue({ + id: 'version-1', + name: 'V1.0', + productId: 'product-1', + projectId: 'project-1', + expectedReleaseDate: new Date('2026-07-08T18:00:00.000Z'), + product: { id: 'product-1', name: 'FTB' }, + project: { id: 'project-1', name: 'PM' }, + }); + prisma.devTask.findMany.mockResolvedValue([ + { id: 'dev-1', status: 'in_progress', estimateHours: 16, aiEstimateHours: null, isBlocked: false }, + ]); + prisma.testCase.findMany.mockResolvedValue([]); + prisma.bug.findMany.mockResolvedValue([ + { id: 'bug-1', status: 'open', severity: 'critical', priority: 1, estimateHours: null, aiEstimateHours: null }, + ]); + prisma.workActivity.findMany.mockResolvedValue([]); + prisma.taskWorklog.findMany.mockResolvedValue([]); + prisma.xiaobaoRiskSummary.upsert.mockImplementation(({ create }) => create); + + const payload = await service.refreshSummary('version-1', { now }); + + expect(xiaobaoAi.evaluateSummary).toHaveBeenCalledWith(payload, { now }); + }); }); diff --git a/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts b/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts index b71655e..88452a9 100644 --- a/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts +++ b/apps/server/src/modules/xiaobao/xiaobao-risk.service.ts @@ -1,5 +1,6 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { JobsService } from '../jobs/jobs.service'; +import { XiaobaoAiService } from '../xiaobao-ai/xiaobao-ai.service'; import { RefreshXiaobaoRiskOptions, XIAOBAO_PRISMA, @@ -22,6 +23,7 @@ export class XiaobaoRiskService { constructor( @Inject(XIAOBAO_PRISMA) private readonly prisma: any, private readonly jobs: JobsService, + @Optional() private readonly xiaobaoAi?: XiaobaoAiService, ) {} async markDirtyAndEnqueue(versionId: string) { @@ -116,6 +118,8 @@ export class XiaobaoRiskService { }, }); + await this.xiaobaoAi?.evaluateSummary(payload, { now }); + return payload; } } diff --git a/apps/server/src/modules/xiaobao/xiaobao.module.ts b/apps/server/src/modules/xiaobao/xiaobao.module.ts index c4db5ce..15e4327 100644 --- a/apps/server/src/modules/xiaobao/xiaobao.module.ts +++ b/apps/server/src/modules/xiaobao/xiaobao.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { JobsModule } from '../jobs/jobs.module'; +import { XiaobaoAiModule } from '../xiaobao-ai/xiaobao-ai.module'; import { XiaobaoRiskController } from './xiaobao-risk.controller'; import { XiaobaoRiskService } from './xiaobao-risk.service'; import { XiaobaoRiskWorker } from './xiaobao-risk.worker'; import { XIAOBAO_PRISMA } from './xiaobao-risk.types'; @Module({ - imports: [JobsModule], + imports: [JobsModule, XiaobaoAiModule], controllers: [XiaobaoRiskController], providers: [ { provide: XIAOBAO_PRISMA, useExisting: PrismaService }, diff --git a/docs/architecture.md b/docs/architecture.md index 572efa1..6648448 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -255,6 +255,8 @@ V2.6 moves the current risk summary refresh to the server: - `XiaobaoRiskService.refreshSummary(versionId)` recomputes deterministic rule output from Version, DevTask, TestCase, Bug, WorkActivity, and TaskWorklog relation rows, then upserts `xiaobao_risk_summaries` with `dirty=false`. - `XiaobaoRiskWorker` registers the `xiaobao.summary.refresh` background job handler, so dirty summaries can be refreshed without opening `/xiaobao-warning`. - Domain writes that produce work activity already mark the affected version dirty; V2.6 also enqueues a deduped refresh job. Plain update/delete paths for version plans, dev tasks, test cases, and bugs explicitly mark the version dirty as well. +- `XiaobaoAiService` evaluates the refreshed summary and enqueues `xiaobao.ai.interpret` when policy allows. The worker reloads the latest summary, skips stale signatures, calls the existing `AiService.interpretRisk()` prompt path, and writes only `xiaobao_risk_insights`. +- AI interpretation cache uses the summary `riskSignature`, exact cache reuse, a six-hour cooldown, and risk-level escalation bypass. Until the server has full daily trend snapshots, `attention` summaries trigger server-side AI only when release is within one day and unfinished work remains. - Frontend `/xiaobao-warning` still consumes V2.2 summary reads first and only falls back to AppData calculation when summaries are empty or unavailable. Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet. diff --git a/docs/decisions.md b/docs/decisions.md index 84d5a34..30463e0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -605,3 +605,19 @@ - 领域写入侧继续通过 `WorkActivityService.markXiaobaoSummaryDirty()` 收口;普通 update/delete 没有 activity 证据时显式标脏,避免风险摘要漏刷新。 **理由**:把 deterministic summary 放到服务端后,读路径不再依赖页面打开,且所有前端仍可沿用 V2.2 summary API。AI 解读仍是后续独立队列,只消费 summary/signature 并写 insight cache;本决策不让 AI 或后台 worker 直接改业务实体。 + +## 47. V2.6 小宝 AI 解读改为服务端队列,只写 insight cache + +**问题**:小宝 AI 解读原先由 `/xiaobao-warning` 页面触发。即使 V2.6 已经把 deterministic summary 刷新移到服务端,如果 AI 解读仍依赖页面打开,高风险版本在无人访问时仍不会产生新的解释缓存,也不利于后续 V2.7 通知使用同一解读结果。 + +**决策**: +- 新增 `XiaobaoAiModule`,通过 `XiaobaoAiService` 和 `XiaobaoAiWorker` 注册 `xiaobao.ai.interpret` job。 +- `XiaobaoRiskService.refreshSummary()` upsert summary 后调用 `XiaobaoAiService.evaluateSummary()`,按 policy 判断是否排入 AI 解读 job。 +- AI 触发策略复用页面规则的核心边界:`at_risk`、`likely_delayed`、`blocked` 可触发;精确 `riskSignature` 命中时复用缓存;同版本最近 6 小时内已有解读时 cooldown;风险等级升级可绕过 cooldown。 +- 服务端当前没有完整前端趋势快照上下文,因此 `attention` 只在“距离预期发版日小于等于 1 天且仍有未完成工作”时触发。趋势、置信度下降和明细信号变化的完整 attention 策略等待服务端趋势快照补齐后再扩展。 +- Worker 执行时重新读取 `xiaobao_risk_summaries`,若 job payload 的 `riskSignature` 已过期则跳过,避免为旧风险写新解释。 +- AI 调用只走现有 `AiService.interpretRisk()` 和 risk prompt/provider 抽象,不新增 SDK 调用、不绕过 AI 配置。 +- AI 成功后只写 `xiaobao_risk_insights`,缓存保存时间使用服务端 `now`,不信任模型返回的 `generatedAt` 作为缓存新鲜度;失败抛错交给 background job retry。 +- Worker 不修改 Version、Requirement、DevTask、TestCase、Bug、Member 等业务实体,也不写通知。V2.7 通知如需消费结果,应通过 insight cache 或 adapter 读取。 + +**理由**:AI 解读是对确定性规则结果的解释层,不是业务事实源。把它做成 summary 后置队列,能让无人打开页面时也生成解释,同时通过 signature/cooldown/escalation 控制成本和重复调用。只写 cache 能保持 AI 与业务实体解耦,后续通知和审计可以复用缓存,而不是让 AI worker 直接参与业务状态流转。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 7346be8..6841eab 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,10 +18,12 @@ V2.4 已完成高增长和核心业务领域从“AppData 主写 + 关系表同 - V2.6.2 已新增 hot query explain/index audit 脚本、热查询索引迁移和 `docs/performance-hot-queries.md`。 - V2.6.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。 - V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。 +- V2.6.5 已新增服务端小宝 AI 解读队列,summary 刷新后按 signature/cooldown/escalation policy 入队,只写 `xiaobao_risk_insights` 缓存。 ### 已完成(按时间倒序) **2026-07-08** +- V2.6.5 moved Xiaobao AI interpretation behind the background job runtime, reusing `AiService.interpretRisk()` and writing only insight cache rows. - V2.6.4 moved deterministic Xiaobao risk summary refresh into the server, registered the `xiaobao.summary.refresh` background job handler, and enqueue refresh jobs from dirty domain writes. - V2.6.3 added DB-backed background jobs with active dedupe keys, lease-based claiming, expired lock recovery, retry/terminal-failure handling, and a small handler worker. - V2.6.2 added `perf:explain`, hot query explain targets, index audit documentation, and V2.6 hot-path indexes for workspace, Xiaobao warning/dirty queues, project/version lists, and evidence scans. diff --git a/docs/workflow.md b/docs/workflow.md index 1ffbd55..288aac9 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -307,9 +307,9 @@ Implementation convention: - `xiaobao.warning:manage`:查看所有未结束版本的预警。 - `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。 -页面打开时会聚合版本下的计划、开发任务、测试用例、Bug、日报和工作活动,计算当前风险并保存当天快照。页面使用 `buildXiaobaoWorkItems` 做版本级聚合,不使用个人工作台的 `aggregateWorkItems(userName, ...)` 过滤。快照按同版本同日节流保存:重大变化立即保存,普通变化 10 分钟内不重复写入。 +V2.6 后小宝当前 summary 由服务端后台刷新:领域写入标记 `xiaobao_risk_summaries.dirty=true` 并排入 `xiaobao.summary.refresh`,worker 从版本下的计划、开发任务、测试用例、Bug、日报和工作活动重新计算风险。页面仍可用前端 `buildXiaobaoWorkItems` 做兼容聚合和快照保存,但默认优先读取 V2.2 summary。 -AI 解读不由人工按钮触发。`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 在风险分明显上升、趋势连续上升、关键 Bug 增加、测试失败、阻塞增加、静默风险增加、置信度下降或预测发版日延后时触发。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用客户端时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。 +AI 解读不由人工按钮触发。服务端 summary 刷新后按 policy 排入 `xiaobao.ai.interpret`:`at_risk`、`likely_delayed`、`blocked` 自动触发;`attention` 当前服务端只在临近发版且仍有未完成工作时触发,页面完整趋势策略仍保留作为兼容。缓存命中时复用解读;同版本最近 6 小时内已有解读时进入 cooldown,不重复请求,风险等级升级时可绕过;缓存保存时间使用服务端写入时间,不信任模型返回的 `generatedAt` 作为缓存新鲜度。 静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。