b2a5caf67d
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>
179 lines
6.0 KiB
JavaScript
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 };
|