From f8db9aa384bed4847e5c1c7ec78e84d2df7e4c1f Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 19:56:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6):=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=20AI=20=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E8=A7=A3=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/xiaobao-warning/page.tsx | 62 ++++++++++++++++++++++++--- apps/web/lib/xiaobao-risk-ai.test.ts | 16 +++++++ apps/web/lib/xiaobao-risk-ai.ts | 10 +++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 44f1e04..3224624 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Layers, ShieldCheck, TriangleAlert } from 'lucide-react'; import { RouteGuard, useHasPermission } from '@/components/auth/Guard'; @@ -15,9 +15,12 @@ import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; +import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore'; import { flattenVersions } from '@/lib/derive'; 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 { buildRiskSignature } from '@/lib/xiaobao-risk-trend'; import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; type RiskFilter = 'all' | 'attention' | 'high'; @@ -42,8 +45,13 @@ function XiaobaoWarningContent() { const { bugs, fetchBugs } = useBugStore(); const { activities, fetchActivities } = useWorkActivityStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore(); + const { snapshots, insights, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore(); const [selectedRiskId, setSelectedRiskId] = useState(null); const [filter, setFilter] = useState('all'); + const [calculationNow] = useState(() => new Date()); + const savedSnapshotKeysRef = useRef(new Set()); + const requestedInsightKeysRef = useRef(new Set()); + const today = useMemo(() => new Date().toISOString().slice(0, 10), []); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchPlans(); }, [fetchPlans]); @@ -53,6 +61,7 @@ function XiaobaoWarningContent() { useEffect(() => { fetchBugs(); }, [fetchBugs]); useEffect(() => { fetchActivities(); }, [fetchActivities]); useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); + useEffect(() => { fetchRiskData(); }, [fetchRiskData]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); const visibleVersions = useMemo( @@ -100,7 +109,8 @@ function XiaobaoWarningContent() { dailyEvidence, recentActivityCount: dailyEvidence.recentActivityCount, lastActivityAt: dailyEvidence.lastActivityAt, - snapshots: [], + snapshots: snapshots.filter((snapshot) => snapshot.versionId === version.id && snapshot.date < today), + now: calculationNow, }); }).sort((a, b) => b.riskScore - a.riskScore), [ activities, @@ -111,15 +121,57 @@ function XiaobaoWarningContent() { visibleVersions, workItems, 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 === 'high') return ['at_risk', 'likely_delayed', 'blocked'].includes(risk.riskLevel); 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 attentionCount = risks.filter((risk) => risk.riskLevel !== 'on_track').length; const avgConfidence = risks.length > 0 diff --git a/apps/web/lib/xiaobao-risk-ai.test.ts b/apps/web/lib/xiaobao-risk-ai.test.ts index 288fa56..7abe5ec 100644 --- a/apps/web/lib/xiaobao-risk-ai.test.ts +++ b/apps/web/lib/xiaobao-risk-ai.test.ts @@ -5,6 +5,7 @@ import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; import { buildRiskInsightSignature, buildRiskInterpretRequest, + findPreviousRiskSnapshot, shouldRequestRiskInsight, } 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.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); +}); diff --git a/apps/web/lib/xiaobao-risk-ai.ts b/apps/web/lib/xiaobao-risk-ai.ts index b5f1c6e..e32d48f 100644 --- a/apps/web/lib/xiaobao-risk-ai.ts +++ b/apps/web/lib/xiaobao-risk-ai.ts @@ -102,6 +102,16 @@ export function getReusableInsight( 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 { return { versionId: risk.versionId,