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