212ff3ff80
Introduce UMS ingest/snapshot pipeline, Context Planner with multi-source recall, runtime context injection, canonical user mapping, and session snapshot loading on auth/me. Co-authored-by: Cursor <cursoragent@cursor.com>
168 lines
6.7 KiB
JavaScript
168 lines
6.7 KiB
JavaScript
import { getActiveSnapshot, getSnapshotInfo } from '../user-model-service/snapshot.mjs';
|
|
import { processIngestBatch } from '../user-model-service/service.mjs';
|
|
import { resolveCanonicalUserId } from '../user-model-service/canonical-user.mjs';
|
|
|
|
function assertRouter(api) {
|
|
if (!api || typeof api.get !== 'function' || typeof api.post !== 'function') {
|
|
throw new Error('attachPortalUserModelRoutes requires an Express-compatible router');
|
|
}
|
|
}
|
|
|
|
function parseBearerToken(req) {
|
|
const auth = req.headers.authorization ?? '';
|
|
return auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
|
}
|
|
|
|
function createResolveUmsUser(getUserAuth) {
|
|
return async function resolveUmsUser(req, _res, next) {
|
|
if (req.currentUser?.id) return next();
|
|
const bearer = parseBearerToken(req);
|
|
const userAuth = getUserAuth?.();
|
|
if (!bearer || !userAuth?.getMe) return next();
|
|
try {
|
|
const me = await userAuth.getMe(bearer);
|
|
if (me) req.currentUser = me;
|
|
} catch (err) {
|
|
console.warn('[ums] bearer session verify failed:', err instanceof Error ? err.message : err);
|
|
}
|
|
return next();
|
|
};
|
|
}
|
|
|
|
function createIngestAuth(getUserAuth) {
|
|
return function ingestAuth(req, res, next) {
|
|
const serviceToken = process.env.UMS_INGEST_TOKEN ?? '';
|
|
const bearer = parseBearerToken(req);
|
|
if (serviceToken && bearer === serviceToken) return next();
|
|
if (req.currentUser?.id) return next();
|
|
if (bearer && getUserAuth?.()?.verify) {
|
|
return getUserAuth()
|
|
.verify(bearer)
|
|
.then((session) => {
|
|
if (session?.userId) return next();
|
|
return res.status(401).json({ error: 'unauthenticated' });
|
|
})
|
|
.catch(() => res.status(401).json({ error: 'unauthenticated' }));
|
|
}
|
|
return res.status(401).json({ error: 'unauthenticated' });
|
|
};
|
|
}
|
|
|
|
function createRequireUser() {
|
|
return function requireUser(req, res, next) {
|
|
if (!req.currentUser?.id) {
|
|
return res.status(401).json({ message: '未授权,请重新登录' });
|
|
}
|
|
return next();
|
|
};
|
|
}
|
|
|
|
export function attachPortalUserModelRoutes(api, { getUmsPool, getUserAuth }) {
|
|
assertRouter(api);
|
|
const resolveUmsUser = createResolveUmsUser(getUserAuth);
|
|
const ingestAuth = createIngestAuth(getUserAuth);
|
|
const requireUser = createRequireUser();
|
|
|
|
api.use('/v1/user-model', resolveUmsUser);
|
|
|
|
api.post('/v1/user-model/ingest', ingestAuth, async (req, res) => {
|
|
try {
|
|
const pool = getUmsPool?.();
|
|
if (!pool) return res.status(503).json({ error: 'user model service not configured' });
|
|
const result = await processIngestBatch(pool, {
|
|
items: req.body?.items ?? [],
|
|
source_type: req.body?.source_type,
|
|
dry_run: Boolean(req.body?.dry_run),
|
|
});
|
|
return res.json(result);
|
|
} catch (err) {
|
|
console.error('[ums] ingest failed', err);
|
|
return res.status(500).json({ error: err instanceof Error ? err.message : 'ingest failed' });
|
|
}
|
|
});
|
|
|
|
api.get('/v1/user-model/snapshot/info', requireUser, async (req, res) => {
|
|
try {
|
|
const pool = getUmsPool?.();
|
|
if (!pool) return res.status(503).json({ error: 'user model service not configured' });
|
|
const userId = resolveCanonicalUserId(req.currentUser.id);
|
|
const projection = String(req.query.projection ?? 'default');
|
|
const info = await getSnapshotInfo(pool, userId, projection);
|
|
if (!info) return res.status(404).json({ error: 'snapshot_not_ready' });
|
|
if (req.headers['if-none-match'] === info.content_hash) return res.status(304).end();
|
|
return res.json(info);
|
|
} catch (err) {
|
|
console.error('[ums] snapshot info failed', err);
|
|
return res.status(500).json({ error: err instanceof Error ? err.message : 'snapshot info failed' });
|
|
}
|
|
});
|
|
|
|
api.get('/v1/user-model/snapshot', requireUser, async (req, res) => {
|
|
try {
|
|
const pool = getUmsPool?.();
|
|
if (!pool) return res.status(503).json({ error: 'user model service not configured' });
|
|
const userId = resolveCanonicalUserId(req.currentUser.id);
|
|
const projection = String(req.query.projection ?? 'default');
|
|
const snap = await getActiveSnapshot(pool, userId, projection);
|
|
if (!snap) return res.status(404).json({ error: 'snapshot_not_ready' });
|
|
if (req.headers['if-none-match'] === snap.content_hash) return res.status(304).end();
|
|
res.setHeader('ETag', snap.content_hash);
|
|
return res.json({
|
|
snapshot_id: snap.snapshot_id,
|
|
user_id: snap.user_id,
|
|
projection: snap.projection,
|
|
profile_version: snap.profile_version,
|
|
fast_revision: snap.fast_revision,
|
|
content_hash: snap.content_hash,
|
|
byte_size: snap.byte_size,
|
|
stale_after_sec: snap.stale_after_sec,
|
|
core: snap.core,
|
|
meta: snap.meta,
|
|
});
|
|
} catch (err) {
|
|
console.error('[ums] snapshot failed', err);
|
|
return res.status(500).json({ error: err instanceof Error ? err.message : 'snapshot failed' });
|
|
}
|
|
});
|
|
|
|
api.get('/v1/user-model/candidates', requireUser, async (req, res) => {
|
|
try {
|
|
const pool = getUmsPool?.();
|
|
if (!pool) return res.status(503).json({ error: 'user model service not configured' });
|
|
const userId = resolveCanonicalUserId(req.currentUser.id);
|
|
const status = String(req.query.status ?? 'open,accepted');
|
|
const statuses = status.split(',').map((s) => s.trim()).filter(Boolean);
|
|
const placeholders = statuses.map(() => '?').join(',');
|
|
const [rows] = await pool.query(
|
|
`SELECT candidate_id, candidate_type, hypothesis_json, status, promotion_score, confidence,
|
|
signal_ids, evidence_ids, first_seen_at, last_seen_at
|
|
FROM um_candidates
|
|
WHERE user_id = ? AND status IN (${placeholders})
|
|
ORDER BY promotion_score DESC
|
|
LIMIT ?`,
|
|
[userId, ...statuses, Math.min(100, Number(req.query.limit ?? 50))],
|
|
);
|
|
return res.json({
|
|
items: rows.map((row) => ({
|
|
candidate_id: row.candidate_id,
|
|
candidate_type: row.candidate_type,
|
|
hypothesis:
|
|
typeof row.hypothesis_json === 'string'
|
|
? JSON.parse(row.hypothesis_json)
|
|
: row.hypothesis_json,
|
|
status: row.status,
|
|
promotion_score: Number(row.promotion_score),
|
|
confidence: Number(row.confidence),
|
|
signal_ids:
|
|
typeof row.signal_ids === 'string' ? JSON.parse(row.signal_ids) : row.signal_ids,
|
|
evidence_ids:
|
|
typeof row.evidence_ids === 'string' ? JSON.parse(row.evidence_ids) : row.evidence_ids,
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error('[ums] candidates failed', err);
|
|
return res.status(500).json({ error: err instanceof Error ? err.message : 'candidates failed' });
|
|
}
|
|
});
|
|
}
|