6f3e53a56a
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>
279 lines
9.4 KiB
JavaScript
279 lines
9.4 KiB
JavaScript
const MS_PER_HOUR = 3600000;
|
||
const MS_PER_DAY = 86400000;
|
||
|
||
const FALSE_STORE_PATTERNS = [
|
||
{
|
||
code: 'question_mark',
|
||
pattern: /[??]\s*$/,
|
||
message: 'Content ends with a question mark',
|
||
},
|
||
{
|
||
code: 'what_is_question',
|
||
pattern: /^(?:什么是|解释一下|介绍一下|请问|能不能|是否可以|怎么|如何|为什么|啥是)/u,
|
||
message: 'Content looks like a question rather than a fact',
|
||
},
|
||
{
|
||
code: 'imperative_only',
|
||
pattern: /^(?:查一下|搜一下|看看|继续|下一步|帮我|请帮我)/u,
|
||
message: 'Content looks like an instruction without durable facts',
|
||
},
|
||
{
|
||
code: 'trivial_greeting',
|
||
pattern: /^(?:你好|您好|谢谢|好的|可以|收到|再见|hi|hello|thanks)[!!。.\s]*$/iu,
|
||
message: 'Content is a trivial greeting or acknowledgement',
|
||
},
|
||
{
|
||
code: 'routing_leak',
|
||
pattern: /(?:\[Memory Context\]|【Memind 任务编排】|agent_orchestration)/u,
|
||
message: 'Content contains internal routing or memory envelope text',
|
||
},
|
||
];
|
||
|
||
export function parseSinceArg(value, now = Date.now()) {
|
||
const raw = String(value ?? '7d').trim().toLowerCase();
|
||
const match = raw.match(/^(\d+)(h|d|w)$/);
|
||
if (!match) {
|
||
throw new Error(`Invalid --since value "${value}". Expected formats like 24h, 7d, 2w.`);
|
||
}
|
||
const amount = Number(match[1]);
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
throw new Error(`Invalid --since value "${value}". Amount must be positive.`);
|
||
}
|
||
const unit = match[2];
|
||
const multiplier = unit === 'h' ? MS_PER_HOUR : unit === 'w' ? MS_PER_DAY * 7 : MS_PER_DAY;
|
||
return {
|
||
sinceMs: now - amount * multiplier,
|
||
label: raw,
|
||
};
|
||
}
|
||
|
||
export function detectFalseStoreCandidate(content) {
|
||
const text = String(content ?? '').replace(/\s+/g, ' ').trim();
|
||
if (!text) {
|
||
return { suspicious: true, code: 'empty_content', message: 'Content is empty' };
|
||
}
|
||
for (const rule of FALSE_STORE_PATTERNS) {
|
||
if (rule.pattern.test(text)) {
|
||
return { suspicious: true, code: rule.code, message: rule.message };
|
||
}
|
||
}
|
||
return { suspicious: false };
|
||
}
|
||
|
||
function normalizeCandidate(row) {
|
||
return {
|
||
id: String(row.id),
|
||
userId: String(row.user_id ?? row.userId),
|
||
sessionId: row.session_id == null ? null : String(row.session_id ?? row.sessionId),
|
||
memoryType: String(row.memory_type ?? row.memoryType ?? ''),
|
||
content: String(row.content ?? ''),
|
||
status: String(row.status ?? ''),
|
||
policyReason: String(row.policy_reason ?? row.policyReason ?? ''),
|
||
confidence: Number(row.confidence ?? 0),
|
||
importance: Number(row.importance ?? 0),
|
||
createdAt: Number(row.created_at ?? row.createdAt ?? 0),
|
||
updatedAt: Number(row.updated_at ?? row.updatedAt ?? 0),
|
||
};
|
||
}
|
||
|
||
function normalizeMemoryItem(row) {
|
||
return {
|
||
id: String(row.id),
|
||
userId: String(row.user_id ?? row.userId),
|
||
label: String(row.label ?? ''),
|
||
content: String(row.memory_text ?? row.content ?? ''),
|
||
status: String(row.status ?? ''),
|
||
createdAt: Number(row.created_at ?? row.createdAt ?? 0),
|
||
updatedAt: Number(row.updated_at ?? row.updatedAt ?? 0),
|
||
};
|
||
}
|
||
|
||
function countByField(items, field) {
|
||
const counts = new Map();
|
||
for (const item of items) {
|
||
const key = String(item[field] ?? 'unknown');
|
||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||
}
|
||
return Object.fromEntries([...counts.entries()].sort((a, b) => b[1] - a[1]));
|
||
}
|
||
|
||
function topEntries(counts, limit = 10) {
|
||
return Object.entries(counts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, limit)
|
||
.map(([key, count]) => ({ key, count }));
|
||
}
|
||
|
||
export function auditMemoryV2Shadow({
|
||
candidates = [],
|
||
memoryItems = [],
|
||
pgvectorMemoryIds = new Set(),
|
||
pgvectorConfigured = false,
|
||
agentMemoryEvents = [],
|
||
sinceMs = 0,
|
||
nowMs = Date.now(),
|
||
falseStoreSampleLimit = 20,
|
||
pgvectorLagSampleLimit = 20,
|
||
} = {}) {
|
||
const normalizedCandidates = candidates.map(normalizeCandidate);
|
||
const normalizedItems = memoryItems.map(normalizeMemoryItem);
|
||
const inWindowCandidates = normalizedCandidates.filter((item) => item.createdAt >= sinceMs);
|
||
const inWindowItems = normalizedItems.filter((item) => item.updatedAt >= sinceMs);
|
||
|
||
const candidateStatusCounts = countByField(inWindowCandidates, 'status');
|
||
const policyReasonCounts = countByField(inWindowCandidates, 'policyReason');
|
||
const memoryTypeCounts = countByField(inWindowCandidates, 'memoryType');
|
||
|
||
const suspiciousCandidates = inWindowCandidates
|
||
.filter((item) => ['candidate', 'accepted'].includes(item.status))
|
||
.map((item) => {
|
||
const verdict = detectFalseStoreCandidate(item.content);
|
||
if (!verdict.suspicious) return null;
|
||
return {
|
||
id: item.id,
|
||
userId: item.userId,
|
||
status: item.status,
|
||
policyReason: item.policyReason,
|
||
code: verdict.code,
|
||
message: verdict.message,
|
||
contentPreview: item.content.slice(0, 120),
|
||
createdAt: item.createdAt,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
|
||
const activeItems = normalizedItems.filter((item) => item.status === 'active');
|
||
const pgvectorLagUsers = new Map();
|
||
const pgvectorMissingSamples = [];
|
||
if (pgvectorConfigured) {
|
||
for (const item of activeItems) {
|
||
if (pgvectorMemoryIds.has(item.id)) continue;
|
||
pgvectorLagUsers.set(item.userId, (pgvectorLagUsers.get(item.userId) ?? 0) + 1);
|
||
if (pgvectorMissingSamples.length < pgvectorLagSampleLimit) {
|
||
pgvectorMissingSamples.push({
|
||
memoryId: item.id,
|
||
userId: item.userId,
|
||
label: item.label,
|
||
updatedAt: item.updatedAt,
|
||
contentPreview: item.content.slice(0, 120),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const resolvedEvents = agentMemoryEvents.filter((event) => {
|
||
const createdAt = Number(event.created_at ?? event.createdAt ?? 0);
|
||
return createdAt >= sinceMs;
|
||
});
|
||
const resolvedWithHits = resolvedEvents.filter((event) => {
|
||
const data = event.data_json ?? event.data ?? {};
|
||
const count = Number(data.count ?? data.memoryCount ?? data.memories?.length ?? 0);
|
||
return count > 0;
|
||
});
|
||
|
||
const acceptedCount = candidateStatusCounts.accepted ?? 0;
|
||
const candidateCount = candidateStatusCounts.candidate ?? 0;
|
||
const reviewedCount = acceptedCount + (candidateStatusCounts.rejected ?? 0);
|
||
const autoAcceptRate = reviewedCount > 0 ? acceptedCount / reviewedCount : null;
|
||
const falseStoreRate = inWindowCandidates.length > 0
|
||
? suspiciousCandidates.length / inWindowCandidates.length
|
||
: null;
|
||
const resolveHitRate = resolvedEvents.length > 0
|
||
? resolvedWithHits.length / resolvedEvents.length
|
||
: null;
|
||
|
||
const thresholds = {
|
||
falseStoreRateMax: 0.05,
|
||
pgvectorLagUsersMax: 0,
|
||
};
|
||
|
||
const issues = [];
|
||
if (falseStoreRate != null && falseStoreRate > thresholds.falseStoreRateMax) {
|
||
issues.push({
|
||
code: 'false_store_rate_high',
|
||
message: `False-store sample rate ${(falseStoreRate * 100).toFixed(1)}% exceeds ${thresholds.falseStoreRateMax * 100}%`,
|
||
});
|
||
}
|
||
if (pgvectorConfigured && pgvectorLagUsers.size > thresholds.pgvectorLagUsersMax) {
|
||
issues.push({
|
||
code: 'pgvector_sync_lag',
|
||
message: `${pgvectorLagUsers.size} user(s) have active memories missing from pgvector`,
|
||
});
|
||
}
|
||
|
||
return {
|
||
ok: issues.length === 0,
|
||
generatedAt: nowMs,
|
||
window: {
|
||
sinceMs,
|
||
untilMs: nowMs,
|
||
candidateCount: inWindowCandidates.length,
|
||
memoryItemCount: inWindowItems.length,
|
||
agentMemoryResolvedEvents: resolvedEvents.length,
|
||
},
|
||
summary: {
|
||
candidateStatusCounts,
|
||
policyReasonCounts,
|
||
memoryTypeCounts,
|
||
autoAcceptRate,
|
||
falseStoreRate,
|
||
resolveHitRate,
|
||
suspiciousCandidateCount: suspiciousCandidates.length,
|
||
pgvectorLagUserCount: pgvectorConfigured ? pgvectorLagUsers.size : null,
|
||
activeMemoryCount: activeItems.length,
|
||
pgvectorMemoryCount: pgvectorConfigured ? pgvectorMemoryIds.size : null,
|
||
},
|
||
topPolicyReasons: topEntries(policyReasonCounts),
|
||
pgvectorLagUsers: [...pgvectorLagUsers.entries()]
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, pgvectorLagSampleLimit)
|
||
.map(([userId, missingCount]) => ({ userId, missingCount })),
|
||
sampleFalseStores: suspiciousCandidates.slice(0, falseStoreSampleLimit),
|
||
pgvectorMissingSamples,
|
||
issues,
|
||
};
|
||
}
|
||
|
||
export function formatMemoryV2ShadowAuditReport(report) {
|
||
const lines = [
|
||
`memory v2 shadow audit: ${report.ok ? 'ok' : 'issues found'}`,
|
||
`window: ${new Date(report.window.sinceMs).toISOString()} -> ${new Date(report.window.untilMs).toISOString()}`,
|
||
`candidates: ${report.window.candidateCount}`,
|
||
`memory items updated: ${report.window.memoryItemCount}`,
|
||
`agent_memory_resolved events: ${report.window.agentMemoryResolvedEvents}`,
|
||
'',
|
||
'summary:',
|
||
JSON.stringify(report.summary, null, 2),
|
||
];
|
||
|
||
if (report.topPolicyReasons.length > 0) {
|
||
lines.push('', 'top policy reasons:');
|
||
for (const item of report.topPolicyReasons) {
|
||
lines.push(`- ${item.key}: ${item.count}`);
|
||
}
|
||
}
|
||
|
||
if (report.pgvectorLagUsers.length > 0) {
|
||
lines.push('', 'pgvector lag users:');
|
||
for (const item of report.pgvectorLagUsers) {
|
||
lines.push(`- ${item.userId}: ${item.missingCount} missing`);
|
||
}
|
||
}
|
||
|
||
if (report.sampleFalseStores.length > 0) {
|
||
lines.push('', 'sample false stores:');
|
||
for (const item of report.sampleFalseStores) {
|
||
lines.push(`- [${item.code}] ${item.id} (${item.policyReason}): ${item.contentPreview}`);
|
||
}
|
||
}
|
||
|
||
if (report.issues.length > 0) {
|
||
lines.push('', 'issues:');
|
||
for (const item of report.issues) {
|
||
lines.push(`- ${item.code}: ${item.message}`);
|
||
}
|
||
}
|
||
|
||
return lines.join('\n');
|
||
}
|