Files
memind/user-model-service/snapshot.mjs
T
john b2a5caf67d Improve UMS candidate scoring and snapshot materialization for sparse data.
Lower term threshold, tune promotion scores, project candidate fallback in snapshot, and add rebuild-ums-snapshot script for production backfill.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 08:40:07 +08:00

229 lines
6.9 KiB
JavaScript

import crypto from 'node:crypto';
function newId() {
return crypto.randomUUID();
}
function nowMs() {
return Date.now();
}
function hashJson(obj) {
return crypto.createHash('sha256').update(JSON.stringify(obj)).digest('hex');
}
/**
* @param {import('mysql2/promise').Pool} pool
* @param {string} userId
* @param {object} options
*/
export async function materializeProfileAndSnapshot(pool, userId, options = {}) {
const projection = options.projection ?? 'default';
const reason = options.reason ?? 'candidate_accepted';
const ts = nowMs();
const [projects] = await pool.query(
`SELECT e.canonical_name, a.confidence, a.effective_weight, a.last_seen_at
FROM um_entities e
JOIN um_attributes a ON a.entity_id = e.entity_id AND a.status = 'active'
WHERE e.user_id = ? AND e.entity_type = 'project' AND e.status = 'active'
ORDER BY a.effective_weight DESC
LIMIT 8`,
[userId],
);
const [focusCandidates] = await pool.query(
`SELECT hypothesis_json, confidence, promotion_score, last_seen_at
FROM um_candidates
WHERE user_id = ? AND candidate_type = 'focus' AND status IN ('open', 'accepted')
ORDER BY promotion_score DESC
LIMIT 10`,
[userId],
);
const [projectCandidates] = await pool.query(
`SELECT hypothesis_json, confidence, promotion_score, last_seen_at
FROM um_candidates
WHERE user_id = ? AND candidate_type = 'project' AND status IN ('open', 'accepted', 'observed')
ORDER BY promotion_score DESC
LIMIT 8`,
[userId],
);
const [versionRows] = await pool.query(
`SELECT COALESCE(MAX(profile_version), 0) AS v FROM um_profile_versions WHERE user_id = ?`,
[userId],
);
const profileVersion = Number(versionRows[0]?.v ?? 0) + 1;
const activeProjects = projects.map((row) => ({
id: `project_${String(row.canonical_name).toLowerCase().replace(/[^a-z0-9]+/g, '_')}`,
name: row.canonical_name,
status: 'active',
confidence: Number(row.confidence),
last_seen: row.last_seen_at,
}));
if (activeProjects.length === 0 && projectCandidates.length > 0) {
for (const row of projectCandidates) {
const h = typeof row.hypothesis_json === 'string' ? JSON.parse(row.hypothesis_json) : row.hypothesis_json;
const name = h.name ?? h.topic ?? 'unknown';
activeProjects.push({
id: `project_${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '_')}`,
name,
status: h.status ?? 'active',
confidence: Number(row.confidence),
last_seen: row.last_seen_at,
});
}
}
const recentFocus = focusCandidates.map((row) => {
const h = typeof row.hypothesis_json === 'string' ? JSON.parse(row.hypothesis_json) : row.hypothesis_json;
return {
topic: h.topic ?? h.name ?? 'unknown',
weight: Number(row.promotion_score),
ttl_days: 14,
};
});
const structured = {
identity: [],
active_projects: activeProjects,
recent_focus: recentFocus,
technical_preferences: [],
working_style: [],
};
const structuredHash = hashJson(structured);
await pool.query(
`UPDATE um_profile_versions SET status = 'superseded' WHERE user_id = ? AND status = 'active'`,
[userId],
);
await pool.query(
`INSERT INTO um_profile_versions
(profile_version, user_id, structured_json, content_hash, parent_version, materialize_reason, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')`,
[
profileVersion,
userId,
JSON.stringify(structured),
structuredHash,
profileVersion > 1 ? profileVersion - 1 : null,
reason,
ts,
],
);
const [fastRows] = await pool.query(
`SELECT COALESCE(MAX(fast_revision), 0) AS r FROM um_profile_snapshots WHERE user_id = ? AND projection = ?`,
[userId, projection],
);
const fastRevision = Number(fastRows[0]?.r ?? 0) + 1;
const agentHints = [];
if (activeProjects[0]) agentHints.push(`近期重点:${activeProjects[0].name}`);
if (recentFocus[0]) agentHints.push(`关注话题:${recentFocus[0].topic}`);
const snapshotCore = {
identity: structured.identity,
active_projects: activeProjects,
recent_focus: recentFocus,
technical_preferences: structured.technical_preferences,
working_style: structured.working_style,
agent_hints: agentHints,
};
const snapshotBody = {
profile_version: profileVersion,
fast_revision: fastRevision,
projection,
core: snapshotCore,
meta: {
graph_entity_count: activeProjects.length,
open_candidates: recentFocus.length,
},
};
const snapshotJson = {
...snapshotBody,
stale_after_sec: 3600,
};
const snapshotStr = JSON.stringify(snapshotCore);
const byteSize = Buffer.byteLength(snapshotStr, 'utf8');
const contentHash = hashJson(snapshotBody);
await pool.query(
`UPDATE um_profile_snapshots SET status = 'superseded' WHERE user_id = ? AND projection = ? AND status = 'active'`,
[userId, projection],
);
const snapshotId = newId();
await pool.query(
`INSERT INTO um_profile_snapshots
(snapshot_id, user_id, projection, profile_version, fast_revision, snapshot_json, byte_size,
content_hash, created_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`,
[
snapshotId,
userId,
projection,
profileVersion,
fastRevision,
JSON.stringify(snapshotJson),
byteSize,
contentHash,
ts,
],
);
return {
profile_version: profileVersion,
fast_revision: fastRevision,
snapshot_id: snapshotId,
content_hash: contentHash,
byte_size: byteSize,
};
}
export async function getActiveSnapshot(pool, userId, projection = 'default') {
const [rows] = await pool.query(
`SELECT snapshot_id, user_id, projection, profile_version, fast_revision, snapshot_json,
byte_size, content_hash, created_at
FROM um_profile_snapshots
WHERE user_id = ? AND projection = ? AND status = 'active'
ORDER BY created_at DESC
LIMIT 1`,
[userId, projection],
);
const row = rows[0];
if (!row) return null;
const snapshotJson =
typeof row.snapshot_json === 'string' ? JSON.parse(row.snapshot_json) : row.snapshot_json;
return {
snapshot_id: row.snapshot_id,
user_id: row.user_id,
projection: row.projection,
profile_version: row.profile_version,
fast_revision: row.fast_revision,
content_hash: row.content_hash,
byte_size: row.byte_size,
stale_after_sec: snapshotJson.stale_after_sec ?? 3600,
core: snapshotJson.core ?? snapshotJson,
meta: snapshotJson.meta ?? {},
created_at: row.created_at,
};
}
export async function getSnapshotInfo(pool, userId, projection = 'default') {
const snap = await getActiveSnapshot(pool, userId, projection);
if (!snap) return null;
return {
profile_version: snap.profile_version,
fast_revision: snap.fast_revision,
content_hash: snap.content_hash,
projection: snap.projection,
snapshot_id: snap.snapshot_id,
};
}