feat(memory-v2): close Phase A with auto-review, product events, and H5 recall UI.
Add candidate auto-review pipeline, shadow audit tooling, admin metrics page, and user-visible memory recall hints in chat with phase-a readiness checks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchMemoryV2Candidates,
|
||||
fetchMemoryV2Metrics,
|
||||
reviewMemoryV2Candidate,
|
||||
runMemoryV2CandidateAutoReview,
|
||||
type MemoryV2CandidateRow,
|
||||
type MemoryV2MetricsResponse,
|
||||
} from '../../api/admin';
|
||||
|
||||
function formatTime(value: number | null | undefined) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function MemoryV2Page() {
|
||||
const [metrics, setMetrics] = useState<MemoryV2MetricsResponse | null>(null);
|
||||
const [candidates, setCandidates] = useState<MemoryV2CandidateRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
||||
const [autoReviewBusy, setAutoReviewBusy] = useState(false);
|
||||
const [autoReviewNote, setAutoReviewNote] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [metricsRes, candidatesRes] = await Promise.all([
|
||||
fetchMemoryV2Metrics({ since: '7d' }),
|
||||
fetchMemoryV2Candidates({ status: 'candidate', limit: 50 }),
|
||||
]);
|
||||
setMetrics(metricsRes);
|
||||
setCandidates(candidatesRes.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const runAutoReview = async () => {
|
||||
setAutoReviewBusy(true);
|
||||
setAutoReviewNote(null);
|
||||
try {
|
||||
const result = await runMemoryV2CandidateAutoReview({ limit: 200 });
|
||||
setAutoReviewNote(
|
||||
`自动审核完成:接受 ${result.accepted},拒绝 ${result.rejected},仍待人工 ${result.pending}`,
|
||||
);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '自动审核失败');
|
||||
} finally {
|
||||
setAutoReviewBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const review = async (id: string, status: 'accepted' | 'rejected') => {
|
||||
setReviewingId(id);
|
||||
try {
|
||||
await reviewMemoryV2Candidate(id, status);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '审核失败');
|
||||
} finally {
|
||||
setReviewingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !metrics) return <p>加载中…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
{error && <p className="alert">{error}</p>}
|
||||
{autoReviewNote && <p className="alert">{autoReviewNote}</p>}
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0, marginBottom: 4 }}>Memory V2 指标</h2>
|
||||
<p style={{ color: '#68716c', margin: 0 }}>近 {metrics?.metrics.window.since ?? '7d'} 产品事件与 shadow 审计</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={autoReviewBusy}
|
||||
onClick={() => void runAutoReview()}
|
||||
>
|
||||
{autoReviewBusy ? '自动审核中…' : '执行自动审核'}
|
||||
</button>
|
||||
<button type="button" className="btn secondary" onClick={() => void reload()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{metrics && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{Object.entries(metrics.metrics.events).map(([key, count]) => (
|
||||
<div key={key}>
|
||||
<p style={{ color: '#68716c', marginBottom: 4 }}>{key}</p>
|
||||
<strong style={{ fontSize: 24 }}>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, display: 'grid', gap: 8 }}>
|
||||
<p style={{ margin: 0 }}>误存率:{formatPercent(metrics.audit.falseStoreRate)}</p>
|
||||
<p style={{ margin: 0 }}>自动接受率:{formatPercent(metrics.audit.autoAcceptRate)}</p>
|
||||
<p style={{ margin: 0 }}>resolve 命中率:{formatPercent(metrics.audit.resolveHitRate)}</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
候选队列:
|
||||
{Object.entries(metrics.candidateCounts)
|
||||
.map(([status, count]) => `${status} × ${count}`)
|
||||
.join(',') || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>需人工复核({candidates.length})</h3>
|
||||
<p style={{ color: '#68716c', marginTop: 0 }}>
|
||||
canary 模式下写入时会自动审核:显式/偏好/目标类接受,decision_signal 与问句类拒绝。
|
||||
此处仅展示自动审核未覆盖的例外项。
|
||||
</p>
|
||||
{candidates.length === 0 ? (
|
||||
<p style={{ color: '#68716c' }}>没有待人工复核的候选。</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{candidates.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
border: '1px solid #e6ece8',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<strong>{item.policyReason}</strong>
|
||||
<span style={{ color: '#68716c' }}>{formatTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<p style={{ margin: 0 }}>{item.content}</p>
|
||||
<p style={{ margin: 0, color: '#68716c', fontSize: 13 }}>
|
||||
user={item.userId} · type={item.memoryType} · session={item.sessionId ?? '—'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={reviewingId === item.id}
|
||||
onClick={() => void review(item.id, 'accepted')}
|
||||
>
|
||||
接受
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={reviewingId === item.id}
|
||||
onClick={() => void review(item.id, 'rejected')}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,9 @@ export function SummaryPage() {
|
||||
<Link className="btn secondary" to="/admin/llm" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
统一大模型 / H5 路由
|
||||
</Link>
|
||||
<Link className="btn secondary" to="/admin/memory-v2" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
Memory V2 指标
|
||||
</Link>
|
||||
<Link className="btn secondary" to="/admin/goal-runs" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
Goal Run 观测
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user