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 [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, })); 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, }; }