fix(小宝预警): 保证建议不为空并显示更新中
This commit is contained in:
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { CalendarClock, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
|
import { CalendarClock, Loader2, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
|
||||||
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
||||||
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
||||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { buildRiskInsightSignature, findPreviousRiskSnapshot, getReusableInsight, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
|
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
|
||||||
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||||||
|
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
||||||
import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view';
|
import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
@@ -41,7 +42,18 @@ export default function XiaobaoWarningPage() {
|
|||||||
function XiaobaoWarningContent() {
|
function XiaobaoWarningContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const canManage = useHasPermission('xiaobao.warning:manage');
|
const canManage = useHasPermission('xiaobao.warning:manage');
|
||||||
const { risks, snapshots, insights, riskDataLoaded, saveSnapshot, saveInsight, today } = useXiaobaoWarningRisks({ loadRiskCache: true });
|
const {
|
||||||
|
risks,
|
||||||
|
snapshots,
|
||||||
|
insights,
|
||||||
|
pendingInsightKeys,
|
||||||
|
riskDataLoaded,
|
||||||
|
saveSnapshot,
|
||||||
|
saveInsight,
|
||||||
|
beginInsightUpdate,
|
||||||
|
finishInsightUpdate,
|
||||||
|
today,
|
||||||
|
} = useXiaobaoWarningRisks({ loadRiskCache: true });
|
||||||
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
||||||
const [selectedProductId, setSelectedProductId] = useState('');
|
const [selectedProductId, setSelectedProductId] = useState('');
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||||||
@@ -67,26 +79,40 @@ function XiaobaoWarningContent() {
|
|||||||
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
||||||
if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return;
|
if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return;
|
||||||
const signature = buildRiskInsightSignature(risk);
|
const signature = buildRiskInsightSignature(risk);
|
||||||
const key = `${risk.versionId}:${signature}`;
|
const key = buildXiaobaoRiskInsightPendingKey(risk);
|
||||||
|
if (pendingInsightKeys.includes(key)) return;
|
||||||
if (requestedInsightKeysRef.current.has(key)) return;
|
if (requestedInsightKeysRef.current.has(key)) return;
|
||||||
requestedInsightKeysRef.current.add(key);
|
requestedInsightKeysRef.current.add(key);
|
||||||
|
beginInsightUpdate(key);
|
||||||
requestRiskInsight(risk).then((response) => {
|
requestRiskInsight(risk).then((response) => {
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
saveInsight({
|
return saveInsight({
|
||||||
versionId: risk.versionId,
|
versionId: risk.versionId,
|
||||||
riskSignature: signature,
|
riskSignature: signature,
|
||||||
insight: sanitizeRiskInsight(response.result),
|
insight: sanitizeRiskInsight(response.result),
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
providerInfo: { model: response.meta.model },
|
providerInfo: { model: response.meta.model },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}).catch(() => {});
|
}).catch(() => {}).finally(() => {
|
||||||
|
finishInsightUpdate(key);
|
||||||
});
|
});
|
||||||
}, [insights, riskDataLoaded, risks, saveInsight, snapshots, today]);
|
});
|
||||||
|
}, [
|
||||||
|
beginInsightUpdate,
|
||||||
|
finishInsightUpdate,
|
||||||
|
insights,
|
||||||
|
pendingInsightKeys,
|
||||||
|
riskDataLoaded,
|
||||||
|
risks,
|
||||||
|
saveInsight,
|
||||||
|
snapshots,
|
||||||
|
today,
|
||||||
|
]);
|
||||||
|
|
||||||
const risksWithInsight = useMemo(() => risks.map((risk) => {
|
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
|
||||||
const cached = getReusableInsight(insights, risk);
|
insights,
|
||||||
return cached ? { ...risk, aiInsight: sanitizeRiskInsight(cached.insight) } : risk;
|
pendingInsightKeys,
|
||||||
}), [insights, risks]);
|
})), [insights, pendingInsightKeys, risks]);
|
||||||
|
|
||||||
const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]);
|
const warningRisks = useMemo(() => filterXiaobaoRiskWarnings(risksWithInsight), [risksWithInsight]);
|
||||||
const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]);
|
const productOptions = useMemo(() => buildProductOptions(warningRisks), [warningRisks]);
|
||||||
@@ -261,6 +287,7 @@ function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionR
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Section title="小宝建议">
|
<Section title="小宝建议">
|
||||||
|
{risk.aiInsightUpdating && <InsightUpdatingNotice source={risk.aiInsightSource} />}
|
||||||
{risk.aiInsight ? (
|
{risk.aiInsight ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
@@ -357,6 +384,18 @@ function XiaobaoWarningDetailPanel({ risk, onNavigate }: { risk: XiaobaoVersionR
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InsightUpdatingNotice({ source }: { source?: XiaobaoVersionRisk['aiInsightSource'] }) {
|
||||||
|
const detail = source === 'previous_ai'
|
||||||
|
? '当前先显示上一条建议,生成完成后会自动替换。'
|
||||||
|
: '当前先显示规则建议,生成完成后会自动替换。';
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] leading-5 text-amber-800">
|
||||||
|
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||||
|
<span>新的小宝建议更新中。{detail}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) {
|
function RiskLevelBadge({ level }: { level: XiaobaoRiskLevel }) {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[level]}`}>
|
<span className={`inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[level]}`}>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CalendarClock, Sparkles, X } from 'lucide-react';
|
import { CalendarClock, Loader2, Sparkles, X } from 'lucide-react';
|
||||||
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import { formatRemainingWork } from '@/lib/xiaobao-warning-view';
|
import { formatRemainingWork } from '@/lib/xiaobao-warning-view';
|
||||||
|
import { buildRuleBasedRiskInsight } from '@/lib/xiaobao-risk-suggestion';
|
||||||
|
|
||||||
export function XiaobaoWarningDrawer({
|
export function XiaobaoWarningDrawer({
|
||||||
risk,
|
risk,
|
||||||
@@ -22,6 +23,7 @@ export function XiaobaoWarningDrawer({
|
|||||||
...(evidence?.todayRisks ?? []),
|
...(evidence?.todayRisks ?? []),
|
||||||
...(evidence?.progressNotes ?? []),
|
...(evidence?.progressNotes ?? []),
|
||||||
];
|
];
|
||||||
|
const aiInsight = risk.aiInsight ?? buildRuleBasedRiskInsight(risk);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
|
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
|
||||||
@@ -95,16 +97,17 @@ export function XiaobaoWarningDrawer({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="小宝建议">
|
<Section title="小宝建议">
|
||||||
{risk.aiInsight ? (
|
{risk.aiInsightUpdating && <InsightUpdatingNotice source={risk.aiInsightSource} />}
|
||||||
|
{aiInsight ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3">
|
||||||
<p className="flex items-start gap-1.5 text-[12px] font-medium text-[var(--ink)]">
|
<p className="flex items-start gap-1.5 text-[12px] font-medium text-[var(--ink)]">
|
||||||
<Sparkles className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
|
<Sparkles className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--accent)]" />
|
||||||
<span>{risk.aiInsight.summary}</span>
|
<span>{aiInsight.summary}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">{risk.aiInsight.forecast}</p>
|
<p className="mt-2 text-[12px] leading-5 text-[var(--ink-soft)]">{aiInsight.forecast}</p>
|
||||||
</div>
|
</div>
|
||||||
{risk.aiInsight.suggestedActions.map((action) => (
|
{aiInsight.suggestedActions.map((action) => (
|
||||||
<div key={action} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
|
<div key={action} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] leading-5 text-[var(--ink-soft)]">
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +149,18 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InsightUpdatingNotice({ source }: { source?: XiaobaoVersionRisk['aiInsightSource'] }) {
|
||||||
|
const detail = source === 'previous_ai'
|
||||||
|
? '当前先显示上一条建议,生成完成后会自动替换。'
|
||||||
|
: '当前先显示规则建议,生成完成后会自动替换。';
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] leading-5 text-amber-800">
|
||||||
|
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||||
|
<span>新的小宝建议更新中。{detail}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Empty({ text }: { text: string }) {
|
function Empty({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-muted)]">
|
<div className="rounded-lg border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-3 text-[12px] text-[var(--ink-muted)]">
|
||||||
|
|||||||
@@ -28,7 +28,17 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
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, riskDataLoaded, fetchRiskData, saveSnapshot, saveInsight } = useXiaobaoRiskStore();
|
const {
|
||||||
|
snapshots,
|
||||||
|
insights,
|
||||||
|
pendingInsightKeys,
|
||||||
|
riskDataLoaded,
|
||||||
|
fetchRiskData,
|
||||||
|
saveSnapshot,
|
||||||
|
saveInsight,
|
||||||
|
beginInsightUpdate,
|
||||||
|
finishInsightUpdate,
|
||||||
|
} = useXiaobaoRiskStore();
|
||||||
const [calculationNow] = useState(() => new Date());
|
const [calculationNow] = useState(() => new Date());
|
||||||
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
const today = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
||||||
|
|
||||||
@@ -111,9 +121,12 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
risks,
|
risks,
|
||||||
snapshots,
|
snapshots,
|
||||||
insights,
|
insights,
|
||||||
|
pendingInsightKeys,
|
||||||
riskDataLoaded,
|
riskDataLoaded,
|
||||||
saveSnapshot,
|
saveSnapshot,
|
||||||
saveInsight,
|
saveInsight,
|
||||||
|
beginInsightUpdate,
|
||||||
|
finishInsightUpdate,
|
||||||
today,
|
today,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
127
apps/web/lib/xiaobao-risk-suggestion.test.ts
Normal file
127
apps/web/lib/xiaobao-risk-suggestion.test.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildRiskInsightSignature } from './xiaobao-risk-ai';
|
||||||
|
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||||
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
|
import { attachXiaobaoRiskSuggestion } from './xiaobao-risk-suggestion';
|
||||||
|
|
||||||
|
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
||||||
|
return {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
versionName: 'V1.0',
|
||||||
|
productId: 'prod-1',
|
||||||
|
productName: 'Product A',
|
||||||
|
projectId: 'proj-1',
|
||||||
|
projectName: 'Project A',
|
||||||
|
riskScore: 68,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
expectedReleaseDate: '2026-07-05',
|
||||||
|
confidence: 80,
|
||||||
|
confidenceLevel: 'high',
|
||||||
|
forecastReleaseDate: '2026-07-05T10:00:00.000Z',
|
||||||
|
delayDays: 0,
|
||||||
|
remainingWorkHours: 8,
|
||||||
|
reasons: [
|
||||||
|
{
|
||||||
|
key: 'remaining_work',
|
||||||
|
title: '剩余工作量',
|
||||||
|
detail: '预计还剩 8h 工作量。',
|
||||||
|
severity: 'warning',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
silentRisks: [],
|
||||||
|
signals: {
|
||||||
|
unfinishedCount: 1,
|
||||||
|
openBugCount: 0,
|
||||||
|
criticalBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
daysToExpectedRelease: 2,
|
||||||
|
},
|
||||||
|
currentSnapshot: {
|
||||||
|
versionId: 'ver-1',
|
||||||
|
date: '2026-06-30',
|
||||||
|
riskScore: 68,
|
||||||
|
riskLevel: 'attention',
|
||||||
|
forecastReleaseDate: '2026-07-05T10:00:00.000Z',
|
||||||
|
openBugCount: 0,
|
||||||
|
criticalBugCount: 0,
|
||||||
|
failedTestCount: 0,
|
||||||
|
blockedCount: 0,
|
||||||
|
silentRiskCount: 0,
|
||||||
|
confidence: 80,
|
||||||
|
createdAt: '2026-06-30T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
trend: { direction: 'flat', delta: 0, summary: '风险保持稳定。', pattern: 'stable' },
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function insight(itemRisk: XiaobaoVersionRisk, patch: Partial<XiaobaoRiskInsightCacheItem> = {}): XiaobaoRiskInsightCacheItem {
|
||||||
|
return {
|
||||||
|
versionId: itemRisk.versionId,
|
||||||
|
riskSignature: buildRiskInsightSignature(itemRisk),
|
||||||
|
insight: {
|
||||||
|
summary: '旧的小宝建议',
|
||||||
|
why: ['仍有工作未完成'],
|
||||||
|
forecast: '当前建议继续观察。',
|
||||||
|
suggestedActions: ['确认剩余任务负责人和完成时间。'],
|
||||||
|
ownerHints: [],
|
||||||
|
generatedAt: '2026-06-30T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
generatedAt: '2026-06-30T09:00:00.000Z',
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('attachXiaobaoRiskSuggestion provides a rule suggestion when AI cache is empty', () => {
|
||||||
|
const result = attachXiaobaoRiskSuggestion(risk(), {
|
||||||
|
insights: [],
|
||||||
|
pendingInsightKeys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.aiInsightSource, 'rule');
|
||||||
|
assert.equal(result.aiInsightUpdating, false);
|
||||||
|
assert.ok(result.aiInsight?.summary);
|
||||||
|
assert.ok(result.aiInsight?.forecast);
|
||||||
|
assert.ok((result.aiInsight?.suggestedActions.length ?? 0) > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attachXiaobaoRiskSuggestion keeps previous AI suggestion while a new one is updating', () => {
|
||||||
|
const previous = risk({ riskScore: 52, riskLevel: 'attention' });
|
||||||
|
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
|
||||||
|
const pendingKey = `${current.versionId}:${buildRiskInsightSignature(current)}`;
|
||||||
|
|
||||||
|
const result = attachXiaobaoRiskSuggestion(current, {
|
||||||
|
insights: [insight(previous)],
|
||||||
|
pendingInsightKeys: [pendingKey],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.aiInsightSource, 'previous_ai');
|
||||||
|
assert.equal(result.aiInsightUpdating, true);
|
||||||
|
assert.equal(result.aiInsight?.summary, '旧的小宝建议');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attachXiaobaoRiskSuggestion uses the current AI suggestion when the signature matches', () => {
|
||||||
|
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
|
||||||
|
|
||||||
|
const result = attachXiaobaoRiskSuggestion(current, {
|
||||||
|
insights: [insight(current, {
|
||||||
|
insight: {
|
||||||
|
summary: '新的小宝建议',
|
||||||
|
why: ['风险已升高'],
|
||||||
|
forecast: '预计延期 1 天。',
|
||||||
|
suggestedActions: ['先处理阻塞和失败用例。'],
|
||||||
|
ownerHints: [],
|
||||||
|
generatedAt: '2026-06-30T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
generatedAt: '2026-06-30T10:00:00.000Z',
|
||||||
|
})],
|
||||||
|
pendingInsightKeys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.aiInsightSource, 'current_ai');
|
||||||
|
assert.equal(result.aiInsightUpdating, false);
|
||||||
|
assert.equal(result.aiInsight?.summary, '新的小宝建议');
|
||||||
|
});
|
||||||
128
apps/web/lib/xiaobao-risk-suggestion.ts
Normal file
128
apps/web/lib/xiaobao-risk-suggestion.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { buildRiskInsightSignature, findLatestRiskInsightForVersion, getReusableInsight } from './xiaobao-risk-ai';
|
||||||
|
import type { XiaobaoRiskInsight, XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||||
|
import type { RiskReason, XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
|
import { sanitizeRiskInsight } from './xiaobao-warning-view';
|
||||||
|
|
||||||
|
interface AttachXiaobaoRiskSuggestionInput {
|
||||||
|
insights: XiaobaoRiskInsightCacheItem[];
|
||||||
|
pendingInsightKeys: string[];
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_LEVEL_LABEL: Record<XiaobaoVersionRisk['riskLevel'], string> = {
|
||||||
|
on_track: '按期',
|
||||||
|
attention: '需关注',
|
||||||
|
at_risk: '有风险',
|
||||||
|
likely_delayed: '大概率延期',
|
||||||
|
blocked: '阻塞',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildXiaobaoRiskInsightPendingKey(risk: XiaobaoVersionRisk): string {
|
||||||
|
return `${risk.versionId}:${buildRiskInsightSignature(risk)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachXiaobaoRiskSuggestion(
|
||||||
|
risk: XiaobaoVersionRisk,
|
||||||
|
input: AttachXiaobaoRiskSuggestionInput,
|
||||||
|
): XiaobaoVersionRisk {
|
||||||
|
const pendingKey = buildXiaobaoRiskInsightPendingKey(risk);
|
||||||
|
const isUpdating = input.pendingInsightKeys.includes(pendingKey);
|
||||||
|
const reusable = getReusableInsight(input.insights, risk);
|
||||||
|
if (reusable) {
|
||||||
|
return {
|
||||||
|
...risk,
|
||||||
|
aiInsight: sanitizeRiskInsight(reusable.insight),
|
||||||
|
aiInsightSource: 'current_ai',
|
||||||
|
aiInsightUpdating: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = findLatestRiskInsightForVersion(input.insights, risk.versionId);
|
||||||
|
if (latest) {
|
||||||
|
return {
|
||||||
|
...risk,
|
||||||
|
aiInsight: sanitizeRiskInsight(latest.insight),
|
||||||
|
aiInsightSource: 'previous_ai',
|
||||||
|
aiInsightUpdating: isUpdating,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...risk,
|
||||||
|
aiInsight: buildRuleBasedRiskInsight(risk, input.now),
|
||||||
|
aiInsightSource: 'rule',
|
||||||
|
aiInsightUpdating: isUpdating,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRuleBasedRiskInsight(risk: XiaobaoVersionRisk, now = new Date()): XiaobaoRiskInsight {
|
||||||
|
const firstReason = risk.reasons[0];
|
||||||
|
const why = risk.reasons.length > 0
|
||||||
|
? risk.reasons.slice(0, 4).map((reason) => reason.detail || reason.title)
|
||||||
|
: [risk.trend.summary || '当前版本存在规则层识别到的发布风险。'];
|
||||||
|
const forecastDate = formatDate(risk.forecastReleaseDate);
|
||||||
|
const expectedDate = formatDate(risk.expectedReleaseDate);
|
||||||
|
const forecast = risk.delayDays > 0
|
||||||
|
? `按当前剩余工作量和风险信号,预计较期望发版日期${expectedDate ? `(${expectedDate})` : ''}延期约 ${risk.delayDays} 天,可发窗口约为 ${forecastDate || '重新评估后确认'}。`
|
||||||
|
: `按当前规则预测,版本仍有风险需要收敛;预计可发窗口为 ${forecastDate || expectedDate || '待补齐计划后确认'}。`;
|
||||||
|
|
||||||
|
return sanitizeRiskInsight({
|
||||||
|
summary: `${risk.versionName} 当前风险分 ${risk.riskScore},状态为${RISK_LEVEL_LABEL[risk.riskLevel]}。${firstReason?.title ? `主要关注:${firstReason.title}。` : ''}`,
|
||||||
|
why,
|
||||||
|
forecast,
|
||||||
|
recommendedReleaseWindow: buildReleaseWindow(risk),
|
||||||
|
suggestedActions: buildRuleActions(risk),
|
||||||
|
ownerHints: buildOwnerHints(risk),
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReleaseWindow(risk: XiaobaoVersionRisk): string | undefined {
|
||||||
|
const forecastDate = formatDate(risk.forecastReleaseDate);
|
||||||
|
if (!forecastDate) return undefined;
|
||||||
|
if (risk.delayDays > 0) return `建议优先以 ${forecastDate} 作为新的候选发版窗口,并在关键风险关闭后再确认。`;
|
||||||
|
return `建议继续以 ${forecastDate} 附近作为候选发版窗口,但需要先完成当前风险项收敛。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRuleActions(risk: XiaobaoVersionRisk): string[] {
|
||||||
|
const actions = risk.reasons.flatMap(reasonToActions);
|
||||||
|
const unique = Array.from(new Set(actions));
|
||||||
|
if (unique.length > 0) return unique.slice(0, 4);
|
||||||
|
return ['确认剩余任务、测试失败、Bug 和阻塞项的负责人及预计完成时间。'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonToActions(reason: RiskReason): string[] {
|
||||||
|
switch (reason.key) {
|
||||||
|
case 'critical_bug':
|
||||||
|
return ['优先收敛 P1 或致命 Bug,明确修复人、复测人和关闭时间。'];
|
||||||
|
case 'failed_test':
|
||||||
|
return ['安排测试负责人复测失败用例,并同步失败原因和修复进展。'];
|
||||||
|
case 'blocked_work':
|
||||||
|
return ['先解除阻塞项,无法当天解除的需要明确替代方案或延期影响。'];
|
||||||
|
case 'forecast_delay':
|
||||||
|
case 'remaining_work':
|
||||||
|
return ['按剩余工作量重排今天到发版日前的完成顺序,先处理影响发版的主链路事项。'];
|
||||||
|
case 'silent_risk':
|
||||||
|
return ['要求未更新的负责人补充今日日报或活动证据,避免静默风险继续扩大。'];
|
||||||
|
default:
|
||||||
|
return reason.detail ? [`跟进:${reason.detail}`] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOwnerHints(risk: XiaobaoVersionRisk): string[] {
|
||||||
|
const hints: string[] = [];
|
||||||
|
if (risk.signals.unfinishedCount > 0) hints.push(`仍有 ${risk.signals.unfinishedCount} 个未完成事项,需要逐项确认负责人。`);
|
||||||
|
if (risk.signals.openBugCount > 0) hints.push(`仍有 ${risk.signals.openBugCount} 个未关闭 Bug,需要确认修复和复测节奏。`);
|
||||||
|
if (risk.signals.silentRiskCount > 0) hints.push(`存在 ${risk.signals.silentRiskCount} 个静默风险,需要负责人补充进展。`);
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const raw = value.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return raw.slice(0, 10);
|
||||||
|
const time = new Date(raw).getTime();
|
||||||
|
if (!Number.isFinite(time)) return undefined;
|
||||||
|
return new Date(time).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
@@ -70,6 +70,8 @@ export interface XiaobaoVersionRisk {
|
|||||||
signals: XiaobaoRiskSignals;
|
signals: XiaobaoRiskSignals;
|
||||||
currentSnapshot: XiaobaoRiskSnapshot;
|
currentSnapshot: XiaobaoRiskSnapshot;
|
||||||
aiInsight?: XiaobaoRiskInsight;
|
aiInsight?: XiaobaoRiskInsight;
|
||||||
|
aiInsightUpdating?: boolean;
|
||||||
|
aiInsightSource?: 'current_ai' | 'previous_ai' | 'rule';
|
||||||
trend: {
|
trend: {
|
||||||
direction: 'up' | 'down' | 'flat' | 'unknown';
|
direction: 'up' | 'down' | 'flat' | 'unknown';
|
||||||
delta: number;
|
delta: number;
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ import type { XiaobaoRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
|||||||
interface XiaobaoRiskState {
|
interface XiaobaoRiskState {
|
||||||
snapshots: XiaobaoRiskSnapshot[];
|
snapshots: XiaobaoRiskSnapshot[];
|
||||||
insights: XiaobaoRiskInsightCacheItem[];
|
insights: XiaobaoRiskInsightCacheItem[];
|
||||||
|
pendingInsightKeys: string[];
|
||||||
riskDataLoaded: boolean;
|
riskDataLoaded: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
fetchRiskData: () => Promise<void>;
|
fetchRiskData: () => Promise<void>;
|
||||||
saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise<void>;
|
saveSnapshot: (item: XiaobaoRiskSnapshot) => Promise<void>;
|
||||||
saveInsight: (item: XiaobaoRiskInsightCacheItem) => Promise<void>;
|
saveInsight: (item: XiaobaoRiskInsightCacheItem) => Promise<void>;
|
||||||
|
beginInsightUpdate: (key: string) => void;
|
||||||
|
finishInsightUpdate: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSnapshots(): Promise<XiaobaoRiskSnapshot[] | null> {
|
async function loadSnapshots(): Promise<XiaobaoRiskSnapshot[] | null> {
|
||||||
@@ -42,6 +45,7 @@ let insightSaveQueue: Promise<void> = Promise.resolve();
|
|||||||
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||||
snapshots: [],
|
snapshots: [],
|
||||||
insights: [],
|
insights: [],
|
||||||
|
pendingInsightKeys: [],
|
||||||
riskDataLoaded: false,
|
riskDataLoaded: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
|
||||||
@@ -91,4 +95,15 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
beginInsightUpdate: (key) => {
|
||||||
|
if (!key) return;
|
||||||
|
if (get().pendingInsightKeys.includes(key)) return;
|
||||||
|
set({ pendingInsightKeys: [...get().pendingInsightKeys, key] });
|
||||||
|
},
|
||||||
|
|
||||||
|
finishInsightUpdate: (key) => {
|
||||||
|
if (!key) return;
|
||||||
|
set({ pendingInsightKeys: get().pendingInsightKeys.filter((item) => item !== key) });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user