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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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