Add User Model Service and Temporal Recall for MeMind V0.1.
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>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { loadSessionUserModelSnapshot } from '../user-model-service/session-snapshot.mjs';
|
||||
|
||||
export function attachPortalAccountFeedbackRoutes({
|
||||
app,
|
||||
jsonBody,
|
||||
@@ -6,6 +8,7 @@ export function attachPortalAccountFeedbackRoutes({
|
||||
getLegacyAuth = () => null,
|
||||
getSubscriptionService = () => null,
|
||||
getFeedbackService = () => null,
|
||||
getUmsPool = () => null,
|
||||
userToken,
|
||||
legacySessionToken,
|
||||
clearUserLoginCookies,
|
||||
@@ -47,6 +50,7 @@ export function attachPortalAccountFeedbackRoutes({
|
||||
subscription,
|
||||
skillRuntime,
|
||||
agentCodeRun,
|
||||
userModelSnapshot,
|
||||
] = await Promise.all([
|
||||
userAuth.listPathGrants(me.id),
|
||||
userAuth.resolveUserCapabilities(
|
||||
@@ -59,6 +63,13 @@ export function attachPortalAccountFeedbackRoutes({
|
||||
: null,
|
||||
resolveSkillRuntimeForClient(),
|
||||
resolveAgentCodeRunForClient(me.id),
|
||||
loadSessionUserModelSnapshot(getUmsPool?.(), me.id).catch((err) => {
|
||||
logger?.warn?.(
|
||||
'[auth/me] user model snapshot skipped:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
return res.json({
|
||||
user: {
|
||||
@@ -72,6 +83,7 @@ export function attachPortalAccountFeedbackRoutes({
|
||||
unrestricted: capabilityState.unrestricted,
|
||||
skillRuntime,
|
||||
agentCodeRun,
|
||||
userModelSnapshot,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export function createPortalApiAuthMiddleware({
|
||||
isPageDataPublicPath = () => false,
|
||||
isLegacyPageDataApiPath = () => false,
|
||||
isProductAnalyticsPublicPath = () => false,
|
||||
isUmsIngestPublicPath = () => false,
|
||||
isTemporalRecallInfoPublicPath = () => false,
|
||||
accessPolicyMode = PORTAL_ACCESS_POLICY_MODE.OFF,
|
||||
accessEnforcementConfig = Object.freeze({
|
||||
masterEnabled: false,
|
||||
@@ -104,6 +106,8 @@ export function createPortalApiAuthMiddleware({
|
||||
req.path,
|
||||
req.method,
|
||||
);
|
||||
const umsIngestPublic = isUmsIngestPublicPath(req.path, req.method);
|
||||
const temporalRecallInfoPublic = isTemporalRecallInfoPublicPath(req.path, req.method);
|
||||
// The retired namespace must reach its explicit 410 route instead of
|
||||
// being converted into a misleading global 401/403 response.
|
||||
const legacyPageDataApi = isLegacyPageDataApiPath(req.path);
|
||||
@@ -114,7 +118,9 @@ export function createPortalApiAuthMiddleware({
|
||||
plazaPublic ||
|
||||
pageDataPublic ||
|
||||
legacyPageDataApi ||
|
||||
productAnalyticsPublic
|
||||
productAnalyticsPublic ||
|
||||
umsIngestPublic ||
|
||||
temporalRecallInfoPublic
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
@@ -129,7 +135,9 @@ export function createPortalApiAuthMiddleware({
|
||||
plazaPublic ||
|
||||
pageDataPublic ||
|
||||
legacyPageDataApi ||
|
||||
productAnalyticsPublic
|
||||
productAnalyticsPublic ||
|
||||
umsIngestPublic ||
|
||||
temporalRecallInfoPublic
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ export function bootstrapPortalGatewayServices({
|
||||
syncUserGeneratedPages,
|
||||
isSessionPageDeliveryActive,
|
||||
experienceService = null,
|
||||
getUmsPool = null,
|
||||
createTkmindProxyFn = createTkmindProxy,
|
||||
createToolGatewayFn = createToolGateway,
|
||||
createAgentRunGatewayFn = createAgentRunGateway,
|
||||
@@ -256,6 +257,7 @@ export function bootstrapPortalGatewayServices({
|
||||
conversationMemoryService,
|
||||
goalRunService,
|
||||
experienceService,
|
||||
getUmsPool,
|
||||
observeWorkflowRun: workflowShadowObserver,
|
||||
observeWorkflowValidation:
|
||||
workflowShadowObserver?.observeValidation ?? null,
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function bootstrapPortalMemorySessionServices({
|
||||
llmProviderService,
|
||||
userAuth,
|
||||
sessionAccess,
|
||||
getUmsPool = null,
|
||||
logger = console,
|
||||
createMemoryV2AdminConfigServiceFn =
|
||||
createMemoryV2AdminConfigService,
|
||||
@@ -149,6 +150,8 @@ export async function bootstrapPortalMemorySessionServices({
|
||||
memoryV2,
|
||||
conversationMemoryService,
|
||||
episodicMemoryService,
|
||||
pool,
|
||||
getUmsPool,
|
||||
});
|
||||
const chatIntentRouter =
|
||||
createManagedChatIntentRouterFn({
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { buildContextPlan } from '../temporal-recall-service/context-planner.mjs';
|
||||
import { queryTemporalRecall } from '../temporal-recall-service/recall.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('attachPortalTemporalRecallRoutes requires an Express-compatible router');
|
||||
}
|
||||
}
|
||||
|
||||
function parseBearerToken(req) {
|
||||
const auth = req.headers.authorization ?? '';
|
||||
return auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
}
|
||||
|
||||
function createResolveUser(getUserAuth) {
|
||||
return async function resolveTemporalUser(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 {
|
||||
/* ignore */
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
function createRequireUser() {
|
||||
return function requireUser(req, res, next) {
|
||||
if (!req.currentUser?.id) {
|
||||
return res.status(401).json({ message: '未授权,请重新登录' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export function attachPortalTemporalRecallRoutes(api, { getPool, getUserAuth }) {
|
||||
assertRouter(api);
|
||||
const resolveTemporalUser = createResolveUser(getUserAuth);
|
||||
const requireUser = createRequireUser();
|
||||
|
||||
api.use('/v1/context', resolveTemporalUser);
|
||||
api.use('/v1/temporal-recall', resolveTemporalUser);
|
||||
|
||||
api.post('/v1/context/plan', requireUser, (req, res) => {
|
||||
try {
|
||||
const query = String(req.body?.query ?? '').trim();
|
||||
if (!query) return res.status(400).json({ error: 'invalid_query' });
|
||||
const plannerLevel = String(req.body?.planner_level ?? 'auto');
|
||||
if (plannerLevel === 'semantic') {
|
||||
return res.status(501).json({ error: 'not_implemented', message: 'semantic planner v0.2' });
|
||||
}
|
||||
const plan = buildContextPlan({
|
||||
query,
|
||||
user_id: resolveCanonicalUserId(req.currentUser.id),
|
||||
now: req.body?.now ? new Date(req.body.now) : new Date(),
|
||||
planner_level: plannerLevel,
|
||||
});
|
||||
return res.json({ plan });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'invalid_query') {
|
||||
return res.status(400).json({ error: 'invalid_query' });
|
||||
}
|
||||
console.error('[temporal] plan failed', err);
|
||||
return res.status(500).json({ error: err instanceof Error ? err.message : 'plan failed' });
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/v1/temporal-recall/info', (_req, res) => {
|
||||
return res.json({
|
||||
schema_version: 1,
|
||||
supported_sources: ['calendar', 'meinput', 'chat'],
|
||||
planned_sources: ['memory_v2', 'email', 'browser', 'tasks'],
|
||||
temporal_modes: [
|
||||
'OCCURRED_IN',
|
||||
'MENTIONED_IN',
|
||||
'PLANNED_IN',
|
||||
'CREATED_IN',
|
||||
'DUE_IN',
|
||||
'AMBIGUOUS',
|
||||
],
|
||||
default_limit: 50,
|
||||
max_limit: 200,
|
||||
});
|
||||
});
|
||||
|
||||
api.post('/v1/temporal-recall/query', requireUser, async (req, res) => {
|
||||
try {
|
||||
const pool = getPool?.();
|
||||
const userId = resolveCanonicalUserId(req.currentUser.id);
|
||||
const body = req.body ?? {};
|
||||
const result = await queryTemporalRecall(pool, {
|
||||
query: body.query,
|
||||
plan: body.plan,
|
||||
user_id: userId,
|
||||
now: body.now ? new Date(body.now) : new Date(),
|
||||
session_id: body.session_id,
|
||||
limit: body.limit,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[temporal] query failed', err);
|
||||
return res.status(500).json({ error: err instanceof Error ? err.message : 'query failed' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user