feat(xiaobao-ai): 后台化风险解读队列
This commit is contained in:
20
apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts
Normal file
20
apps/server/src/modules/xiaobao-ai/xiaobao-ai.module.ts
Normal 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 {}
|
||||||
259
apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts
Normal file
259
apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.spec.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
378
apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts
Normal file
378
apps/server/src/modules/xiaobao-ai/xiaobao-ai.service.ts
Normal 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;
|
||||||
|
}
|
||||||
117
apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts
Normal file
117
apps/server/src/modules/xiaobao-ai/xiaobao-ai.types.ts
Normal 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,
|
||||||
|
};
|
||||||
38
apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts
Normal file
38
apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts
Normal file
30
apps/server/src/modules/xiaobao-ai/xiaobao-ai.worker.ts
Normal 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() };
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { JobsService } from '../jobs/jobs.service';
|
|||||||
import { XiaobaoRiskService } from './xiaobao-risk.service';
|
import { XiaobaoRiskService } from './xiaobao-risk.service';
|
||||||
|
|
||||||
describe('XiaobaoRiskService', () => {
|
describe('XiaobaoRiskService', () => {
|
||||||
function makeService() {
|
function makeService(xiaobaoAi?: { evaluateSummary: jest.Mock }) {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
version: { findUnique: jest.fn() },
|
version: { findUnique: jest.fn() },
|
||||||
devTask: { findMany: jest.fn() },
|
devTask: { findMany: jest.fn() },
|
||||||
@@ -18,7 +18,7 @@ describe('XiaobaoRiskService', () => {
|
|||||||
const jobs = {
|
const jobs = {
|
||||||
enqueue: jest.fn(),
|
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 () => {
|
it('refreshes a version risk summary from relational rows without opening the page', async () => {
|
||||||
@@ -84,4 +84,35 @@ describe('XiaobaoRiskService', () => {
|
|||||||
maxAttempts: 5,
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { JobsService } from '../jobs/jobs.service';
|
||||||
|
import { XiaobaoAiService } from '../xiaobao-ai/xiaobao-ai.service';
|
||||||
import {
|
import {
|
||||||
RefreshXiaobaoRiskOptions,
|
RefreshXiaobaoRiskOptions,
|
||||||
XIAOBAO_PRISMA,
|
XIAOBAO_PRISMA,
|
||||||
@@ -22,6 +23,7 @@ export class XiaobaoRiskService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(XIAOBAO_PRISMA) private readonly prisma: any,
|
@Inject(XIAOBAO_PRISMA) private readonly prisma: any,
|
||||||
private readonly jobs: JobsService,
|
private readonly jobs: JobsService,
|
||||||
|
@Optional() private readonly xiaobaoAi?: XiaobaoAiService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async markDirtyAndEnqueue(versionId: string) {
|
async markDirtyAndEnqueue(versionId: string) {
|
||||||
@@ -116,6 +118,8 @@ export class XiaobaoRiskService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.xiaobaoAi?.evaluateSummary(payload, { now });
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { JobsModule } from '../jobs/jobs.module';
|
import { JobsModule } from '../jobs/jobs.module';
|
||||||
|
import { XiaobaoAiModule } from '../xiaobao-ai/xiaobao-ai.module';
|
||||||
import { XiaobaoRiskController } from './xiaobao-risk.controller';
|
import { XiaobaoRiskController } from './xiaobao-risk.controller';
|
||||||
import { XiaobaoRiskService } from './xiaobao-risk.service';
|
import { XiaobaoRiskService } from './xiaobao-risk.service';
|
||||||
import { XiaobaoRiskWorker } from './xiaobao-risk.worker';
|
import { XiaobaoRiskWorker } from './xiaobao-risk.worker';
|
||||||
import { XIAOBAO_PRISMA } from './xiaobao-risk.types';
|
import { XIAOBAO_PRISMA } from './xiaobao-risk.types';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [JobsModule],
|
imports: [JobsModule, XiaobaoAiModule],
|
||||||
controllers: [XiaobaoRiskController],
|
controllers: [XiaobaoRiskController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: XIAOBAO_PRISMA, useExisting: PrismaService },
|
{ provide: XIAOBAO_PRISMA, useExisting: PrismaService },
|
||||||
|
|||||||
@@ -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`.
|
- `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`.
|
- `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.
|
- 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.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -605,3 +605,19 @@
|
|||||||
- 领域写入侧继续通过 `WorkActivityService.markXiaobaoSummaryDirty()` 收口;普通 update/delete 没有 activity 证据时显式标脏,避免风险摘要漏刷新。
|
- 领域写入侧继续通过 `WorkActivityService.markXiaobaoSummaryDirty()` 收口;普通 update/delete 没有 activity 证据时显式标脏,避免风险摘要漏刷新。
|
||||||
|
|
||||||
**理由**:把 deterministic summary 放到服务端后,读路径不再依赖页面打开,且所有前端仍可沿用 V2.2 summary API。AI 解读仍是后续独立队列,只消费 summary/signature 并写 insight cache;本决策不让 AI 或后台 worker 直接改业务实体。
|
**理由**:把 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 直接参与业务状态流转。
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ V2.4 已完成高增长和核心业务领域从“AppData 主写 + 关系表同
|
|||||||
- V2.6.2 已新增 hot query explain/index audit 脚本、热查询索引迁移和 `docs/performance-hot-queries.md`。
|
- 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.3 已新增 PostgreSQL-backed `background_jobs` 运行时、去重/lease/retry 语义和 jobs 单元测试。
|
||||||
- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。
|
- V2.6.4 已新增服务端小宝风险 summary refresh、后台 job handler,以及领域写入 dirty/enqueue 桥接。
|
||||||
|
- V2.6.5 已新增服务端小宝 AI 解读队列,summary 刷新后按 signature/cooldown/escalation policy 入队,只写 `xiaobao_risk_insights` 缓存。
|
||||||
|
|
||||||
### 已完成(按时间倒序)
|
### 已完成(按时间倒序)
|
||||||
|
|
||||||
**2026-07-08**
|
**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.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.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.
|
- 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.
|
||||||
|
|||||||
@@ -307,9 +307,9 @@ Implementation convention:
|
|||||||
- `xiaobao.warning:manage`:查看所有未结束版本的预警。
|
- `xiaobao.warning:manage`:查看所有未结束版本的预警。
|
||||||
- `xiaobao.warning:view`:仅查看当前用户在 `version.members` 中的未结束版本。
|
- `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 解读输入。
|
静默风险包括长期无更新、无日报、无活动、进行中事项无人处理等信号。日报和工作活动是风险解释的重要证据,必须进入 AI 解读输入。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user