Files
memind/user-model-service/candidates.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

179 lines
6.0 KiB
JavaScript

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 };