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:
john
2026-08-01 17:14:06 +08:00
parent 666db0b939
commit 6f3e53a56a
50 changed files with 3730 additions and 38 deletions
+2
View File
@@ -17,6 +17,7 @@ import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
import { OrchestratorPage } from './pages/admin/OrchestratorPage';
import { LlmProvidersPage } from './pages/admin/LlmProvidersPage';
import { GoalRunPage } from './pages/admin/GoalRunPage';
import { MemoryV2Page } from './pages/admin/MemoryV2Page';
export function App() {
return (
@@ -54,6 +55,7 @@ export function App() {
<Route path="orchestrator" element={<OrchestratorPage />} />
<Route path="llm" element={<LlmProvidersPage />} />
<Route path="goal-runs" element={<GoalRunPage />} />
<Route path="memory-v2" element={<MemoryV2Page />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
+90
View File
@@ -893,6 +893,96 @@ export async function patchMemoryV2Config(patch: Record<string, unknown>) {
export async function fetchMemoryV2Runtime() {
return adminFetch<MemoryV2RuntimeState>('/admin-api/memory-v2/runtime');
}
export type MemoryV2ProductMetrics = {
window: { since: string; sinceMs: number; untilMs: number };
userId: string | null;
events: {
memory_candidate_saved: number;
memory_promoted: number;
memory_resolved_injected: number;
memory_recall_hit: number;
};
sources: Record<string, string>;
};
export type MemoryV2ShadowAuditSummary = {
falseStoreRate: number | null;
autoAcceptRate: number | null;
resolveHitRate: number | null;
suspiciousCandidateCount: number;
pgvectorLagUserCount: number;
};
export type MemoryV2MetricsResponse = {
metrics: MemoryV2ProductMetrics;
audit: MemoryV2ShadowAuditSummary;
candidateCounts: Record<string, number>;
};
export type MemoryV2CandidateRow = {
id: string;
userId: string;
sessionId: string | null;
memoryType: string;
content: string;
importance: number;
confidence: number;
status: string;
policyReason: string;
createdAt: number;
updatedAt: number;
};
export async function fetchMemoryV2Metrics(params?: { since?: string; userId?: string }) {
const query = new URLSearchParams();
if (params?.since) query.set('since', params.since);
if (params?.userId) query.set('userId', params.userId);
const suffix = query.toString() ? `?${query.toString()}` : '';
return adminFetch<MemoryV2MetricsResponse>(`/admin-api/memory-v2/metrics${suffix}`);
}
export async function fetchMemoryV2Candidates(params?: {
status?: string;
userId?: string;
limit?: number;
offset?: number;
}) {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.userId) query.set('userId', params.userId);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
const suffix = query.toString() ? `?${query.toString()}` : '';
return adminFetch<{ items: MemoryV2CandidateRow[]; status: string; limit: number; offset: number }>(
`/admin-api/memory-v2/candidates${suffix}`,
);
}
export async function runMemoryV2CandidateAutoReview(params?: { userId?: string; limit?: number }) {
return adminFetch<{
ok: true;
scanned: number;
accepted: number;
rejected: number;
pending: number;
samples?: Array<{ id: string; action: string; reason: string }>;
}>('/admin-api/memory-v2/candidates/auto-review', {
method: 'POST',
body: JSON.stringify({
userId: params?.userId,
limit: params?.limit ?? 200,
}),
});
}
export async function reviewMemoryV2Candidate(id: string, status: 'accepted' | 'rejected') {
return adminFetch<{ ok: true; updated: boolean }>(`/admin-api/memory-v2/candidates/${encodeURIComponent(id)}/review`, {
method: 'POST',
body: JSON.stringify({ status }),
});
}
export type GoalRunRuntimeState = {
enabled: boolean;
canaryUserIds: string[];
+1
View File
@@ -9,6 +9,7 @@ const links = [
{ to: '/admin/policy', label: '策略中心' },
{ to: '/admin/orchestrator', label: '任务编排' },
{ to: '/admin/llm', label: '统一大模型' },
{ to: '/admin/memory-v2', label: 'Memory V2' },
{ to: '/admin/goal-runs', label: 'Goal Run' },
];
+192
View File
@@ -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>
);
}
+3
View File
@@ -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>