feat(xiaobao-ai): 后台化风险解读队列

This commit is contained in:
2026-07-08 17:30:50 +08:00
parent c131a0a133
commit 58c98a3a3d
13 changed files with 904 additions and 6 deletions

View File

@@ -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 {}

View File

@@ -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> = {}): 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,
};
}

View File

@@ -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<XiaobaoAiEvaluateResult> {
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<XiaobaoAiEvaluateResult> {
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<XiaobaoAiGenerationResult> {
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<any | null> {
return this.prisma.xiaobaoRiskInsight.findFirst({
where: {
versionId: summary.versionId,
riskSignature: summary.riskSignature,
status: 'generated',
},
orderBy: { createdAt: 'desc' },
});
}
private findLatestInsight(versionId: string): Promise<any | null> {
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<string, unknown> {
return isRecord(value) ? value : {};
}
function isRecord(value: unknown): value is Record<string, unknown> {
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;
}

View File

@@ -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<XiaobaoAiRiskInterpretResponse>;
}
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<XiaobaoRiskLevel, number> = {
on_track: 0,
attention: 1,
at_risk: 2,
likely_delayed: 3,
blocked: 4,
};

View File

@@ -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();
});
});

View File

@@ -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() };
}

View File

@@ -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 });
});
});

View File

@@ -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;
}
}

View File

@@ -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 },

View File

@@ -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.

View File

@@ -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 直接参与业务状态流转。

View File

@@ -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.

View File

@@ -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 解读输入。