feat(ai): complete v3.2 risk watch agent
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import { loadV22XiaobaoWarnings, type V22XiaobaoWarningSummary } from '@/lib/v22
|
||||
import { buildVersionDataScopeMap } from '@/lib/version-data-scope';
|
||||
import {
|
||||
buildXiaobaoRisksFromV22Summaries,
|
||||
collectV22XiaobaoLatestInsights,
|
||||
filterV22XiaobaoSummariesForVisibleVersions,
|
||||
mergeV22XiaobaoLatestInsights,
|
||||
shouldLoadXiaobaoAppDataFallback,
|
||||
type V22XiaobaoSummaryLoadState,
|
||||
} from '@/lib/xiaobao-v22-summary';
|
||||
@@ -54,6 +56,11 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
||||
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
||||
const v22UserId = user?.name || user?.id || '';
|
||||
const shouldLoadFallback = shouldLoadXiaobaoAppDataFallback(v22SummaryState);
|
||||
const relationInsights = useMemo(() => collectV22XiaobaoLatestInsights(v22Summaries), [v22Summaries]);
|
||||
const mergedInsights = useMemo(
|
||||
() => mergeV22XiaobaoLatestInsights(relationInsights, insights),
|
||||
[insights, relationInsights],
|
||||
);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => {
|
||||
@@ -178,7 +185,7 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
||||
return {
|
||||
risks,
|
||||
snapshots,
|
||||
insights,
|
||||
insights: mergedInsights,
|
||||
pendingInsightKeys,
|
||||
insightRequestAttempts,
|
||||
riskDataLoaded,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Priority } from './derive';
|
||||
import type { Requirement, RequirementStatus, SourceType } from './requirement';
|
||||
import type { TestCase, TestCaseStatus } from './test-case';
|
||||
import type { VersionDataScope } from './version-data-scope';
|
||||
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||
import type {
|
||||
PlanTask,
|
||||
VersionPlan,
|
||||
@@ -14,6 +15,11 @@ import type {
|
||||
|
||||
type V22DateValue = string | Date | null | undefined;
|
||||
|
||||
type V22XiaobaoRiskInsightCacheItem = Omit<XiaobaoRiskInsightCacheItem, 'generatedAt' | 'insight'> & {
|
||||
generatedAt?: V22DateValue;
|
||||
insight: XiaobaoRiskInsightCacheItem['insight'] & { generatedAt?: V22DateValue };
|
||||
};
|
||||
|
||||
interface V22VersionRow {
|
||||
id: string;
|
||||
productId: string;
|
||||
@@ -188,6 +194,7 @@ export interface V22XiaobaoWarningSummary {
|
||||
forecastReleaseDate?: string;
|
||||
riskSignature: string;
|
||||
summary: unknown;
|
||||
latestInsight?: XiaobaoRiskInsightCacheItem;
|
||||
dirty?: boolean;
|
||||
recomputedAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -231,10 +238,11 @@ export async function loadV22WorkspaceData(userId: string): Promise<V22Workspace
|
||||
export async function loadV22XiaobaoWarnings(
|
||||
query: V22XiaobaoWarningQuery,
|
||||
): Promise<V22XiaobaoWarningSummary[]> {
|
||||
const response = await api.get<Array<Omit<V22XiaobaoWarningSummary, 'forecastReleaseDate' | 'recomputedAt' | 'updatedAt'> & {
|
||||
const response = await api.get<Array<Omit<V22XiaobaoWarningSummary, 'forecastReleaseDate' | 'recomputedAt' | 'updatedAt' | 'latestInsight'> & {
|
||||
forecastReleaseDate?: V22DateValue;
|
||||
recomputedAt?: V22DateValue;
|
||||
updatedAt?: V22DateValue;
|
||||
latestInsight?: V22XiaobaoRiskInsightCacheItem;
|
||||
}>>(`/v2.2/xiaobao-warning?${queryString({
|
||||
userId: query.userId,
|
||||
manager: query.manager ? 'true' : undefined,
|
||||
@@ -244,6 +252,7 @@ export async function loadV22XiaobaoWarnings(
|
||||
forecastReleaseDate: optionalIso(row.forecastReleaseDate),
|
||||
recomputedAt: optionalIso(row.recomputedAt),
|
||||
updatedAt: optionalIso(row.updatedAt),
|
||||
latestInsight: mapLatestXiaobaoInsight(row.latestInsight),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -421,6 +430,18 @@ function optionalIso(value: V22DateValue): string | undefined {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function mapLatestXiaobaoInsight(value?: V22XiaobaoRiskInsightCacheItem): XiaobaoRiskInsightCacheItem | undefined {
|
||||
if (!value) return undefined;
|
||||
return {
|
||||
...value,
|
||||
generatedAt: optionalIso(value.generatedAt) ?? new Date(0).toISOString(),
|
||||
insight: {
|
||||
...value.insight,
|
||||
generatedAt: optionalIso(value.insight.generatedAt) ?? value.insight.generatedAt ?? new Date(0).toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitCsv(value?: string | null): string[] {
|
||||
if (!value) return [];
|
||||
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { VersionWithContext } from './derive';
|
||||
import type { V22XiaobaoWarningSummary } from './v22-api';
|
||||
import {
|
||||
buildXiaobaoRisksFromV22Summaries,
|
||||
collectV22XiaobaoLatestInsights,
|
||||
filterV22XiaobaoSummariesForVisibleVersions,
|
||||
mergeV22XiaobaoLatestInsights,
|
||||
shouldLoadXiaobaoAppDataFallback,
|
||||
} from './xiaobao-v22-summary';
|
||||
|
||||
@@ -165,6 +167,69 @@ test('filterV22XiaobaoSummariesForVisibleVersions keeps only visible version sum
|
||||
assert.deepEqual(result.map((item) => item.versionId), ['visible-1', 'visible-2']);
|
||||
});
|
||||
|
||||
test('collectV22XiaobaoLatestInsights exposes relation-backed AI insights', () => {
|
||||
const result = collectV22XiaobaoLatestInsights([
|
||||
summary({
|
||||
versionId: 'version-1',
|
||||
latestInsight: {
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'signature-1',
|
||||
generatedAt: '2026-07-08T10:00:00.000Z',
|
||||
providerInfo: { model: 'claude-test' },
|
||||
insight: {
|
||||
summary: '高风险版本需要处理阻塞。',
|
||||
why: ['存在阻塞项'],
|
||||
forecast: '预计延期 1 天。',
|
||||
suggestedActions: ['先解除阻塞'],
|
||||
ownerHints: ['确认阻塞负责人'],
|
||||
generatedAt: '2026-07-08T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
summary({ versionId: 'version-2' }),
|
||||
]);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].versionId, 'version-1');
|
||||
assert.equal(result[0].riskSignature, 'signature-1');
|
||||
assert.equal(result[0].insight.summary, '高风险版本需要处理阻塞。');
|
||||
assert.equal(result[0].providerInfo?.model, 'claude-test');
|
||||
});
|
||||
|
||||
test('mergeV22XiaobaoLatestInsights prefers relation-backed insights over legacy cache duplicates', () => {
|
||||
const relationInsight = {
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'signature-1',
|
||||
generatedAt: '2026-07-08T10:00:00.000Z',
|
||||
insight: {
|
||||
summary: '关系表新解读。',
|
||||
why: ['new'],
|
||||
forecast: 'new forecast',
|
||||
suggestedActions: ['new action'],
|
||||
ownerHints: ['new owner'],
|
||||
generatedAt: '2026-07-08T10:00:00.000Z',
|
||||
},
|
||||
};
|
||||
const legacyInsight = {
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'signature-1',
|
||||
generatedAt: '2026-07-08T09:00:00.000Z',
|
||||
insight: {
|
||||
summary: '旧缓存解读。',
|
||||
why: ['old'],
|
||||
forecast: 'old forecast',
|
||||
suggestedActions: ['old action'],
|
||||
ownerHints: ['old owner'],
|
||||
generatedAt: '2026-07-08T09:00:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
const result = mergeV22XiaobaoLatestInsights([relationInsight], [legacyInsight]);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].insight.summary, '关系表新解读。');
|
||||
});
|
||||
|
||||
function summary(patch: Partial<V22XiaobaoWarningSummary> = {}): V22XiaobaoWarningSummary {
|
||||
return {
|
||||
versionId: 'version-1',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { VersionWithContext } from './derive';
|
||||
import type { V22XiaobaoWarningSummary } from './v22-api';
|
||||
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||
import type {
|
||||
RiskReason,
|
||||
SilentRisk,
|
||||
@@ -37,6 +38,25 @@ export function filterV22XiaobaoSummariesForVisibleVersions(
|
||||
return summaries.filter((summary) => visibleVersionIds.has(summary.versionId));
|
||||
}
|
||||
|
||||
export function collectV22XiaobaoLatestInsights(
|
||||
summaries: V22XiaobaoWarningSummary[],
|
||||
): XiaobaoRiskInsightCacheItem[] {
|
||||
return summaries
|
||||
.map((summary) => summary.latestInsight)
|
||||
.filter((item): item is XiaobaoRiskInsightCacheItem => Boolean(item));
|
||||
}
|
||||
|
||||
export function mergeV22XiaobaoLatestInsights(
|
||||
relationInsights: XiaobaoRiskInsightCacheItem[],
|
||||
legacyInsights: XiaobaoRiskInsightCacheItem[],
|
||||
): XiaobaoRiskInsightCacheItem[] {
|
||||
const relationKeys = new Set(relationInsights.map((item) => insightKey(item)));
|
||||
return [
|
||||
...relationInsights,
|
||||
...legacyInsights.filter((item) => !relationKeys.has(insightKey(item))),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildXiaobaoRisksFromV22Summaries(
|
||||
input: BuildXiaobaoRisksFromV22SummariesInput,
|
||||
): XiaobaoVersionRisk[] {
|
||||
@@ -118,6 +138,10 @@ function getRiskPayload(summary: unknown): Record<string, unknown> {
|
||||
return Object.keys(nested).length > 0 ? nested : root;
|
||||
}
|
||||
|
||||
function insightKey(item: XiaobaoRiskInsightCacheItem): string {
|
||||
return `${item.versionId}::${item.riskSignature}`;
|
||||
}
|
||||
|
||||
function buildCurrentSnapshot(input: {
|
||||
payload: Record<string, unknown>;
|
||||
row: V22XiaobaoWarningSummary;
|
||||
|
||||
Reference in New Issue
Block a user