diff --git a/scripts/rebuild-ums-snapshot.mjs b/scripts/rebuild-ums-snapshot.mjs new file mode 100644 index 0000000..efc52dc --- /dev/null +++ b/scripts/rebuild-ums-snapshot.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * Rebuild UMS candidates + snapshot from existing signals (no new evidence ingest). + * + * UMS_DATABASE_URL=... node scripts/rebuild-ums-snapshot.mjs [user_id] + */ +import { createUmsPool, isUmsDatabaseConfigured } from '../user-model-service/db.mjs'; +import { mergeCandidatesFromSignals } from '../user-model-service/candidates.mjs'; +import { materializeProfileAndSnapshot } from '../user-model-service/snapshot.mjs'; +import { resolveCanonicalUserId } from '../user-model-service/canonical-user.mjs'; + +async function main() { + if (!isUmsDatabaseConfigured()) { + console.error('Set UMS_DATABASE_URL'); + process.exit(1); + } + const userId = resolveCanonicalUserId( + process.argv[2] ?? process.env.UMS_REBUILD_USER_ID ?? 'a70ff537-8908-486e-9b6c-042e07cc25db', + ); + const pool = createUmsPool(); + try { + const candidates = await mergeCandidatesFromSignals(pool, userId); + const snapshot = await materializeProfileAndSnapshot(pool, userId, { reason: 'rebuild_script' }); + console.log('rebuild OK:', { + user_id: userId, + candidates_touched: candidates, + profile_version: snapshot.profile_version, + byte_size: snapshot.byte_size, + }); + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/user-model-service/candidates.mjs b/user-model-service/candidates.mjs index 7bc40cd..c6455c5 100644 --- a/user-model-service/candidates.mjs +++ b/user-model-service/candidates.mjs @@ -12,6 +12,14 @@ 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 @@ -43,13 +51,13 @@ export async function mergeCandidatesFromSignals(pool, userId) { let touched = 0; const ts = nowMs(); for (const [term, stats] of termCounts) { - if (stats.count < 5) continue; + if (stats.count < MIN_TERM_COUNT) continue; if (term.length < 2) continue; - const isProjectLike = /^[A-Z][a-zA-Z0-9]+$/.test(term) || term.includes('Input') || term.includes('Mind'); + const isProjectLike = isProjectLikeTerm(term); const candidateType = isProjectLike ? 'project' : 'focus'; - const confidence = Math.min(0.99, 0.4 + stats.count * 0.03); - const promotionScore = Math.min(0.99, confidence * Math.min(1, stats.count / 20)); + 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 }; @@ -166,3 +174,5 @@ async function upsertProjectGraph(pool, userId, name, confidence, evidenceIds, w ); } } + +export { isProjectLikeTerm, MIN_TERM_COUNT }; diff --git a/user-model-service/snapshot.mjs b/user-model-service/snapshot.mjs index 65a853f..52b0dd1 100644 --- a/user-model-service/snapshot.mjs +++ b/user-model-service/snapshot.mjs @@ -41,6 +41,15 @@ export async function materializeProfileAndSnapshot(pool, userId, options = {}) [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], @@ -55,6 +64,20 @@ export async function materializeProfileAndSnapshot(pool, userId, options = {}) 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 {