feat(小宝预警): 自动生成 AI 风险解读
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Layers, ShieldCheck, TriangleAlert } from 'lucide-react';
|
import { Layers, ShieldCheck, TriangleAlert } from 'lucide-react';
|
||||||
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
||||||
@@ -15,9 +15,12 @@ import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
|||||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||||
|
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
||||||
import { flattenVersions } from '@/lib/derive';
|
import { flattenVersions } from '@/lib/derive';
|
||||||
import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import { calcXiaobaoVersionRisk, type XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
|
import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsight } from '@/lib/xiaobao-risk-ai';
|
||||||
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
||||||
|
import { buildRiskSignature } from '@/lib/xiaobao-risk-trend';
|
||||||
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
||||||
|
|
||||||
type RiskFilter = 'all' | 'attention' | 'high';
|
type RiskFilter = 'all' | 'attention' | 'high';
|
||||||
@@ -42,8 +45,13 @@ function XiaobaoWarningContent() {
|
|||||||
const { bugs, fetchBugs } = useBugStore();
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
const { activities, fetchActivities } = useWorkActivityStore();
|
const { activities, fetchActivities } = useWorkActivityStore();
|
||||||
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
||||||
|
const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore();
|
||||||
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState<RiskFilter>('all');
|
const [filter, setFilter] = useState<RiskFilter>('all');
|
||||||
|
const [calculationNow] = useState(() => new Date());
|
||||||
|
const savedSnapshotKeysRef = useRef(new Set<string>());
|
||||||
|
const requestedInsightKeysRef = useRef(new Set<string>());
|
||||||
|
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||||
@@ -53,6 +61,7 @@ function XiaobaoWarningContent() {
|
|||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||||
useEffect(() => { fetchActivities(); }, [fetchActivities]);
|
useEffect(() => { fetchActivities(); }, [fetchActivities]);
|
||||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
||||||
|
useEffect(() => { fetchRiskData(); }, [fetchRiskData]);
|
||||||
|
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
const visibleVersions = useMemo(
|
const visibleVersions = useMemo(
|
||||||
@@ -100,7 +109,8 @@ function XiaobaoWarningContent() {
|
|||||||
dailyEvidence,
|
dailyEvidence,
|
||||||
recentActivityCount: dailyEvidence.recentActivityCount,
|
recentActivityCount: dailyEvidence.recentActivityCount,
|
||||||
lastActivityAt: dailyEvidence.lastActivityAt,
|
lastActivityAt: dailyEvidence.lastActivityAt,
|
||||||
snapshots: [],
|
snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today),
|
||||||
|
now: calculationNow,
|
||||||
});
|
});
|
||||||
}).sort((a, b) => b.riskScore - a.riskScore), [
|
}).sort((a, b) => b.riskScore - a.riskScore), [
|
||||||
activities,
|
activities,
|
||||||
@@ -111,15 +121,57 @@ function XiaobaoWarningContent() {
|
|||||||
visibleVersions,
|
visibleVersions,
|
||||||
workItems,
|
workItems,
|
||||||
worklogs,
|
worklogs,
|
||||||
|
snapshots,
|
||||||
|
today,
|
||||||
|
calculationNow,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filteredRisks = useMemo(() => risks.filter((risk) => {
|
useEffect(() => {
|
||||||
|
risks.forEach((risk) => {
|
||||||
|
const snapshot = risk.currentSnapshot;
|
||||||
|
const key = `${snapshot.versionId}:${snapshot.date}:${buildRiskSignature(snapshot)}`;
|
||||||
|
if (savedSnapshotKeysRef.current.has(key)) return;
|
||||||
|
savedSnapshotKeysRef.current.add(key);
|
||||||
|
saveSnapshot(snapshot).catch(() => {
|
||||||
|
savedSnapshotKeysRef.current.delete(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [risks, saveSnapshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
risks.forEach((risk) => {
|
||||||
|
if (getReusableInsight(insights, risk)) return;
|
||||||
|
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
||||||
|
if (!shouldRequestRiskInsight(risk, previous)) return;
|
||||||
|
const signature = buildRiskInsightSignature(risk);
|
||||||
|
const key = `${risk.versionId}:${signature}`;
|
||||||
|
if (requestedInsightKeysRef.current.has(key)) return;
|
||||||
|
requestedInsightKeysRef.current.add(key);
|
||||||
|
requestRiskInsight(risk).then((response) => {
|
||||||
|
if (!response.ok) return;
|
||||||
|
saveInsight({
|
||||||
|
versionId: risk.versionId,
|
||||||
|
riskSignature: signature,
|
||||||
|
insight: response.result,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
providerInfo: { model: response.meta.model },
|
||||||
|
}).catch(() => {});
|
||||||
|
}).catch(() => {});
|
||||||
|
});
|
||||||
|
}, [insights, risks, saveInsight, snapshots, today]);
|
||||||
|
|
||||||
|
const risksWithInsight = useMemo(() => risks.map((risk) => {
|
||||||
|
const cached = getReusableInsight(insights, risk);
|
||||||
|
return cached ? { ...risk, aiInsight: cached.insight } : risk;
|
||||||
|
}), [insights, risks]);
|
||||||
|
|
||||||
|
const filteredRisks = useMemo(() => risksWithInsight.filter((risk) => {
|
||||||
if (filter === 'attention') return risk.riskLevel !== 'on_track';
|
if (filter === 'attention') return risk.riskLevel !== 'on_track';
|
||||||
if (filter === 'high') return ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel);
|
if (filter === 'high') return ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel);
|
||||||
return true;
|
return true;
|
||||||
}), [filter, risks]);
|
}), [filter, risksWithInsight]);
|
||||||
|
|
||||||
const selectedRisk = selectedRiskId ? risks.find((risk) => risk.versionId === selectedRiskId) ?? null : null;
|
const selectedRisk = selectedRiskId ? risksWithInsight.find((risk) => risk.versionId === selectedRiskId) ?? null : null;
|
||||||
const highRiskCount = risks.filter((risk) => ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel)).length;
|
const highRiskCount = risks.filter((risk) => ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel)).length;
|
||||||
const attentionCount = risks.filter((risk) => risk.riskLevel !== 'on_track').length;
|
const attentionCount = risks.filter((risk) => risk.riskLevel !== 'on_track').length;
|
||||||
const avgConfidence = risks.length > 0
|
const avgConfidence = risks.length > 0
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
|||||||
import {
|
import {
|
||||||
buildRiskInsightSignature,
|
buildRiskInsightSignature,
|
||||||
buildRiskInterpretRequest,
|
buildRiskInterpretRequest,
|
||||||
|
findPreviousRiskSnapshot,
|
||||||
shouldRequestRiskInsight,
|
shouldRequestRiskInsight,
|
||||||
} from './xiaobao-risk-ai';
|
} from './xiaobao-risk-ai';
|
||||||
|
|
||||||
@@ -243,3 +244,18 @@ test('buildRiskInterpretRequest compresses frontend risk evidence for the backen
|
|||||||
assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']);
|
assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']);
|
||||||
assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.');
|
assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('findPreviousRiskSnapshot returns latest snapshot before today for the version', () => {
|
||||||
|
const previous = findPreviousRiskSnapshot(
|
||||||
|
[
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 70, createdAt: '2026-06-29T10:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-28', riskScore: 52, createdAt: '2026-06-28T10:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-1', date: '2026-06-27', riskScore: 38, createdAt: '2026-06-27T10:00:00.000Z' }),
|
||||||
|
snapshot({ versionId: 'ver-2', date: '2026-06-28', riskScore: 90, createdAt: '2026-06-28T11:00:00.000Z' }),
|
||||||
|
],
|
||||||
|
'ver-1',
|
||||||
|
'2026-06-29',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(previous?.riskScore, 52);
|
||||||
|
});
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ export function getReusableInsight(
|
|||||||
return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk));
|
return findCachedInsight(cache, risk.versionId, buildRiskInsightSignature(risk));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findPreviousRiskSnapshot(
|
||||||
|
snapshots: XiaobaoRiskSnapshot[],
|
||||||
|
versionId: string,
|
||||||
|
today: string,
|
||||||
|
): XiaobaoRiskSnapshot | undefined {
|
||||||
|
return snapshots
|
||||||
|
.filter((snapshot) => snapshot.versionId === versionId && snapshot.date < today)
|
||||||
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRiskInterpretRequest(risk: XiaobaoVersionRisk): AgentRiskInterpretRequest {
|
export function buildRiskInterpretRequest(risk: XiaobaoVersionRisk): AgentRiskInterpretRequest {
|
||||||
return {
|
return {
|
||||||
versionId: risk.versionId,
|
versionId: risk.versionId,
|
||||||
|
|||||||
Reference in New Issue
Block a user