feat(ai): complete v3.2 risk watch agent

This commit is contained in:
2026-07-08 19:57:38 +08:00
parent deebc5404a
commit e949d0f5d3
10 changed files with 445 additions and 30 deletions

View File

@@ -27,6 +27,9 @@ function buildPrismaMock() {
xiaobaoRiskSummary: {
findMany: jest.fn(),
},
xiaobaoRiskInsight: {
findMany: jest.fn(),
},
};
}
@@ -184,6 +187,7 @@ describe('V22QueryService', () => {
prisma.testCase.findMany.mockResolvedValue([{ versionId: 'version-2' }]);
prisma.bug.findMany.mockResolvedValue([{ versionId: 'version-3' }]);
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([{ versionId: 'version-2', riskScore: 70 }]);
prisma.xiaobaoRiskInsight.findMany.mockResolvedValue([]);
const service = new V22QueryService(prisma as any);
const userResult = await service.getXiaobaoWarnings({ userId: 'member-1' });
@@ -203,6 +207,75 @@ describe('V22QueryService', () => {
});
});
it('attaches latest generated Xiaobao AI insight to warning summaries', async () => {
const prisma = buildPrismaMock();
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
{ versionId: 'version-1', riskLevel: 'at_risk', riskScore: 70 },
{ versionId: 'version-2', riskLevel: 'blocked', riskScore: 95 },
]);
prisma.xiaobaoRiskInsight.findMany.mockResolvedValue([
{
versionId: 'version-1',
riskSignature: 'signature-current',
status: 'generated',
createdAt: new Date('2026-07-08T10:00:00.000Z'),
insight: {
versionId: 'version-1',
riskSignature: 'signature-current',
generatedAt: '2026-07-08T10:00:00.000Z',
insight: {
summary: '高风险版本需要收敛关键 Bug。',
why: ['仍有 P1 Bug'],
forecast: '预计延期 1 天。',
suggestedActions: ['优先修复 P1 Bug'],
ownerHints: ['确认修复负责人'],
generatedAt: '2026-07-08T10:00:00.000Z',
},
providerInfo: { model: 'claude-test' },
},
},
{
versionId: 'version-1',
riskSignature: 'signature-old',
status: 'generated',
createdAt: new Date('2026-07-08T09:00:00.000Z'),
insight: {
summary: '旧解读不应被选中。',
why: [],
forecast: '',
suggestedActions: [],
ownerHints: [],
generatedAt: '2026-07-08T09:00:00.000Z',
},
},
]);
const service = new V22QueryService(prisma as any);
const result = await service.getXiaobaoWarnings({ manager: 'true' });
expect(prisma.xiaobaoRiskInsight.findMany).toHaveBeenCalledWith({
where: {
versionId: { in: ['version-1', 'version-2'] },
status: 'generated',
},
orderBy: [{ createdAt: 'desc' }],
});
expect(result[0]).toEqual(expect.objectContaining({
versionId: 'version-1',
latestInsight: {
versionId: 'version-1',
riskSignature: 'signature-current',
generatedAt: '2026-07-08T10:00:00.000Z',
insight: expect.objectContaining({
summary: '高风险版本需要收敛关键 Bug。',
forecast: '预计延期 1 天。',
}),
providerInfo: { model: 'claude-test' },
},
}));
expect(result[1]).not.toHaveProperty('latestInsight');
});
it('throws when a version detail query targets a missing version', async () => {
const prisma = buildPrismaMock();
prisma.version.findUnique.mockResolvedValue(null);

View File

@@ -163,10 +163,11 @@ export class V22QueryService {
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
if (query.manager === 'true') {
return this.prisma.xiaobaoRiskSummary.findMany({
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
where: { riskLevel: { not: 'on_track' } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
});
return this.attachLatestXiaobaoInsights(rows);
}
const userId = query.userId?.trim();
@@ -174,13 +175,40 @@ export class V22QueryService {
const versionIds = await this.getUserOwnedVersionIds(userId);
if (versionIds.length === 0) return [];
return this.prisma.xiaobaoRiskSummary.findMany({
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
where: {
versionId: { in: versionIds },
riskLevel: { not: 'on_track' },
},
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
});
return this.attachLatestXiaobaoInsights(rows);
}
private async attachLatestXiaobaoInsights(rows: any[]) {
const versionIds = Array.from(new Set(rows.map((row) => readString(row?.versionId)).filter(isText)));
if (versionIds.length === 0) return rows;
const insights = await this.prisma.xiaobaoRiskInsight.findMany({
where: {
versionId: { in: versionIds },
status: 'generated',
},
orderBy: [{ createdAt: 'desc' }],
});
const latestByVersion = new Map<string, any>();
for (const insight of insights) {
const versionId = readString(insight?.versionId);
if (versionId && !latestByVersion.has(versionId)) {
latestByVersion.set(versionId, insight);
}
}
return rows.map((row) => {
const latest = latestByVersion.get(String(row.versionId));
const latestInsight = latest ? normalizeXiaobaoInsight(latest) : undefined;
return latestInsight ? { ...row, latestInsight } : row;
});
}
private async getUserOwnedVersionIds(userId: string): Promise<string[]> {
@@ -264,3 +292,52 @@ function parsePriority(raw?: string): number | undefined {
if (!Number.isFinite(parsed)) return undefined;
return Math.max(0, Math.min(4, Math.floor(parsed)));
}
function normalizeXiaobaoInsight(row: any) {
const payload = readRecord(row?.insight);
const nestedInsight = readRecord(payload.insight);
const insight = Object.keys(nestedInsight).length > 0 ? nestedInsight : payload;
const versionId = readString(payload.versionId) ?? readString(row?.versionId);
const riskSignature = readString(payload.riskSignature) ?? readString(row?.riskSignature);
if (!versionId || !riskSignature || Object.keys(insight).length === 0) return undefined;
return {
versionId,
riskSignature,
insight,
generatedAt: readString(payload.generatedAt) ?? toIso(row?.createdAt) ?? new Date(0).toISOString(),
providerInfo: readProviderInfo(payload.providerInfo),
};
}
function readProviderInfo(value: unknown): { providerId?: string; model?: string } | undefined {
const row = readRecord(value);
const providerId = readString(row.providerId);
const model = readString(row.model);
if (!providerId && !model) return undefined;
return {
...(providerId ? { providerId } : {}),
...(model ? { model } : {}),
};
}
function readRecord(value: unknown): Record<string, any> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, any> : {};
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function isText(value: string | undefined): value is string {
return Boolean(value);
}
function toIso(value: unknown): string | undefined {
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') {
const time = new Date(value).getTime();
if (Number.isFinite(time)) return new Date(time).toISOString();
}
return undefined;
}