import crypto from 'node:crypto'; function newId() { return crypto.randomUUID(); } function nowMs() { return Date.now(); } function slugEntityName(name) { return String(name).trim().slice(0, 128); } const MIN_TERM_COUNT = Number(process.env.UMS_CANDIDATE_MIN_TERM_COUNT ?? 3); function isProjectLikeTerm(term) { if (/^[A-Z][a-zA-Z0-9]+$/.test(term)) return true; if (/meinput|memind|input|agent|rime|fcitx|tkmind|goosed|pgvector/i.test(term)) return true; return term.includes('Input') || term.includes('Mind'); } /** * V0.1:从 term_frequency signals 推断 project/focus candidates * @param {import('mysql2/promise').Pool} pool * @param {string} userId */ export async function mergeCandidatesFromSignals(pool, userId) { const [rows] = await pool.query( `SELECT signal_id, dimension_key, value_json, evidence_ids, window_end FROM um_signals WHERE user_id = ? AND signal_type = 'term_frequency' ORDER BY window_end DESC LIMIT 500`, [userId], ); const termCounts = new Map(); for (const row of rows) { const term = row.dimension_key.replace(/^term:/, ''); const value = typeof row.value_json === 'string' ? JSON.parse(row.value_json) : row.value_json; const evidenceIds = typeof row.evidence_ids === 'string' ? JSON.parse(row.evidence_ids) : row.evidence_ids; const prev = termCounts.get(term) ?? { count: 0, signal_ids: [], evidence_ids: [] }; prev.count += Number(value.count ?? 0); prev.signal_ids.push(row.signal_id); prev.evidence_ids.push(...(evidenceIds ?? [])); termCounts.set(term, prev); } let touched = 0; const ts = nowMs(); for (const [term, stats] of termCounts) { if (stats.count < MIN_TERM_COUNT) continue; if (term.length < 2) continue; const isProjectLike = isProjectLikeTerm(term); const candidateType = isProjectLike ? 'project' : 'focus'; const confidence = Math.min(0.99, 0.4 + stats.count * 0.05); const promotionScore = Math.min(0.99, 0.3 + stats.count * 0.1); const hypothesis = isProjectLike ? { type: 'project', name: term, status: 'active' } : { type: 'focus', topic: term }; const [existing] = await pool.query( isProjectLike ? `SELECT candidate_id FROM um_candidates WHERE user_id = ? AND candidate_type = 'project' AND JSON_UNQUOTE(JSON_EXTRACT(hypothesis_json, '$.name')) = ? LIMIT 1` : `SELECT candidate_id FROM um_candidates WHERE user_id = ? AND candidate_type = 'focus' AND JSON_UNQUOTE(JSON_EXTRACT(hypothesis_json, '$.topic')) = ? LIMIT 1`, [userId, term], ); const status = promotionScore >= 0.8 ? 'accepted' : promotionScore >= 0.55 ? 'open' : 'observed'; const uniqueEvidence = [...new Set(stats.evidence_ids)].slice(0, 200); if (existing[0]) { await pool.query( `UPDATE um_candidates SET promotion_score = ?, confidence = ?, status = ?, signal_ids = ?, evidence_ids = ?, last_seen_at = NOW(3), updated_at = ?, version = version + 1 WHERE candidate_id = ?`, [ promotionScore, confidence, status, JSON.stringify(stats.signal_ids), JSON.stringify(uniqueEvidence), ts, existing[0].candidate_id, ], ); } else { await pool.query( `INSERT INTO um_candidates (candidate_id, user_id, candidate_type, hypothesis_json, status, promotion_score, confidence, signal_ids, evidence_ids, first_seen_at, last_seen_at, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(3), NOW(3), 1, ?, ?)`, [ newId(), userId, candidateType, JSON.stringify(hypothesis), status, promotionScore, confidence, JSON.stringify(stats.signal_ids), JSON.stringify(uniqueEvidence), ts, ts, ], ); } touched += 1; if (status === 'accepted' && isProjectLike) { await upsertProjectGraph(pool, userId, term, confidence, uniqueEvidence, promotionScore); } } return touched; } async function upsertProjectGraph(pool, userId, name, confidence, evidenceIds, weight) { const ts = nowMs(); const canonical = slugEntityName(name); const [entities] = await pool.query( `SELECT entity_id FROM um_entities WHERE user_id = ? AND entity_type = 'project' AND canonical_name = ? LIMIT 1`, [userId, canonical], ); let entityId = entities[0]?.entity_id; if (!entityId) { entityId = newId(); await pool.query( `INSERT INTO um_entities (entity_id, user_id, entity_type, canonical_name, status, created_at, updated_at) VALUES (?, ?, 'project', ?, 'active', ?, ?)`, [entityId, userId, canonical, ts, ts], ); } const valueJson = { name: canonical, status: 'active' }; const contentHash = crypto.createHash('sha256').update(JSON.stringify(valueJson)).digest('hex'); const [attrs] = await pool.query( `SELECT attribute_id FROM um_attributes WHERE user_id = ? AND entity_id = ? AND attr_key = 'project.status' AND status = 'active' LIMIT 1`, [userId, entityId], ); if (attrs[0]) { await pool.query( `UPDATE um_attributes SET confidence = ?, effective_weight = ?, evidence_ids = ?, last_seen_at = NOW(3) WHERE attribute_id = ?`, [confidence, weight, JSON.stringify(evidenceIds.slice(0, 50)), attrs[0].attribute_id], ); } else { await pool.query( `INSERT INTO um_attributes (attribute_id, user_id, entity_id, attr_key, value_json, confidence, decay_halflife_days, effective_weight, evidence_ids, first_seen_at, last_seen_at, status, version, content_hash) VALUES (?, ?, ?, 'project.status', ?, ?, 90, ?, ?, NOW(3), NOW(3), 'active', 1, ?)`, [ newId(), userId, entityId, JSON.stringify(valueJson), confidence, weight, JSON.stringify(evidenceIds.slice(0, 50)), contentHash, ], ); } } export { isProjectLikeTerm, MIN_TERM_COUNT };