From 212ff3ff8030c56ff3f6149227a45afddfa68987 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 3 Sep 2026 23:25:18 +0800 Subject: [PATCH] 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 --- .env.example | 15 + agent-run-gateway.mjs | 30 ++ chat-intent-router.mjs | 9 +- direct-chat-service.mjs | 18 +- docs/architecture/calendar-adapter-v0.2.md | 81 ++++ docs/architecture/context-planner-v1.md | 151 +++++++ docs/architecture/temporal-recall-v0.1.md | 373 ++++++++++++++++++ docs/architecture/temporal-recall-v1.md | 190 +++++++++ schemas/context-plan.schema.json | 175 ++++++++ schemas/evidence-envelope.schema.json | 146 +++++++ schemas/memind_user_model-v0.sql | 187 +++++++++ schemas/timeline-item.schema.json | 105 +++++ scripts/setup-ums-rds.mjs | 59 +++ scripts/smoke-temporal-recall.mjs | 66 ++++ server.mjs | 34 +- server/portal-account-feedback-routes.mjs | 12 + server/portal-api-auth-middleware.mjs | 12 +- server/portal-gateway-services-bootstrap.mjs | 2 + ...rtal-memory-session-services-bootstrap.mjs | 3 + server/portal-temporal-recall-routes.mjs | 110 ++++++ server/portal-user-model-routes.mjs | 167 ++++++++ src/api/client.ts | 2 + src/types.ts | 19 + temporal-recall-service/adapters/calendar.mjs | 87 ++++ temporal-recall-service/adapters/chat.mjs | 107 +++++ temporal-recall-service/adapters/meinput.mjs | 154 ++++++++ temporal-recall-service/calendar.test.mjs | 40 ++ temporal-recall-service/context-planner.mjs | 133 +++++++ temporal-recall-service/dedupe.mjs | 68 ++++ .../event-time-extract.mjs | 273 +++++++++++++ .../event-time-extract.test.mjs | 41 ++ temporal-recall-service/keyword-rules.mjs | 151 +++++++ temporal-recall-service/rank.mjs | 91 +++++ temporal-recall-service/recall.mjs | 125 ++++++ temporal-recall-service/recall.test.mjs | 88 +++++ temporal-recall-service/runtime-context.mjs | 177 +++++++++ temporal-recall-service/time-parser.mjs | 158 ++++++++ user-model-service/candidates.mjs | 168 ++++++++ user-model-service/canonical-user.mjs | 50 +++ user-model-service/canonical-user.test.mjs | 26 ++ user-model-service/db.mjs | 57 +++ user-model-service/ingest.mjs | 104 +++++ user-model-service/migrate.mjs | 21 + user-model-service/service.mjs | 66 ++++ user-model-service/session-snapshot.mjs | 30 ++ user-model-service/signals.mjs | 154 ++++++++ user-model-service/snapshot.mjs | 205 ++++++++++ 47 files changed, 4534 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/calendar-adapter-v0.2.md create mode 100644 docs/architecture/context-planner-v1.md create mode 100644 docs/architecture/temporal-recall-v0.1.md create mode 100644 docs/architecture/temporal-recall-v1.md create mode 100644 schemas/context-plan.schema.json create mode 100644 schemas/evidence-envelope.schema.json create mode 100644 schemas/memind_user_model-v0.sql create mode 100644 schemas/timeline-item.schema.json create mode 100644 scripts/setup-ums-rds.mjs create mode 100644 scripts/smoke-temporal-recall.mjs create mode 100644 server/portal-temporal-recall-routes.mjs create mode 100644 server/portal-user-model-routes.mjs create mode 100644 temporal-recall-service/adapters/calendar.mjs create mode 100644 temporal-recall-service/adapters/chat.mjs create mode 100644 temporal-recall-service/adapters/meinput.mjs create mode 100644 temporal-recall-service/calendar.test.mjs create mode 100644 temporal-recall-service/context-planner.mjs create mode 100644 temporal-recall-service/dedupe.mjs create mode 100644 temporal-recall-service/event-time-extract.mjs create mode 100644 temporal-recall-service/event-time-extract.test.mjs create mode 100644 temporal-recall-service/keyword-rules.mjs create mode 100644 temporal-recall-service/rank.mjs create mode 100644 temporal-recall-service/recall.mjs create mode 100644 temporal-recall-service/recall.test.mjs create mode 100644 temporal-recall-service/runtime-context.mjs create mode 100644 temporal-recall-service/time-parser.mjs create mode 100644 user-model-service/candidates.mjs create mode 100644 user-model-service/canonical-user.mjs create mode 100644 user-model-service/canonical-user.test.mjs create mode 100644 user-model-service/db.mjs create mode 100644 user-model-service/ingest.mjs create mode 100644 user-model-service/migrate.mjs create mode 100644 user-model-service/service.mjs create mode 100644 user-model-service/session-snapshot.mjs create mode 100644 user-model-service/signals.mjs create mode 100644 user-model-service/snapshot.mjs diff --git a/.env.example b/.env.example index 88aafd8..6ec10db 100644 --- a/.env.example +++ b/.env.example @@ -571,3 +571,18 @@ MEMIND_RUNTIME_PROFILE=local # MEMIND_CURSOR_CHAT_BRIDGE_ENTRYPOINT=1 # CURSOR_API_KEY=your-cursor-api-key # memindadm 选择「Cursor Chat Bridge (实验)」后 sync 到 goosed + +# User Model Service (UMS) — Evidence → Signals → Candidates → Snapshot +# 独立库 memind_user_model,与 goose / meinput 分离 +# UMS_DATABASE_URL=mysql://boot:888888@localhost:3306/memind_user_model +# UMS_INGEST_TOKEN=local-dev-ums-ingest-token +# tang19821002 → 微信「唐」:同一人的多账号归并到 canonical user_id +# MEMIND_CANONICAL_USER_MAP=d0678bbc-2a50-4e08-8bf0-6b6c9301e2d6=a70ff537-8908-486e-9b6c-042e07cc25db +# 迁移:node user-model-service/migrate.mjs + +# Temporal Recall — 时间回忆域(Context Planner + Multi-source Retrieval) +# MEINPUT_DATABASE_URL=mysql://boot:888888@localhost:3306/meinput +# MEINPUT_BASE_URL=http://127.0.0.1:8090 +# 测试:node scripts/smoke-temporal-recall.mjs "我这周有什么重要的事?" +# MEMIND_RUNTIME_CONTEXT_ENABLED=1 +# MEMIND_TEMPORAL_RECALL_CALENDAR_ENABLED=1 # scheduleService → Calendar adapter diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 68ba987..ed7ead1 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -37,6 +37,7 @@ import { recordMemoryV2ProductEvent, } from './memory-v2-product-events.mjs'; import { buildMemoryRecallPreviews } from './memory-v2-user-feedback.mjs'; +import { resolveRuntimeContext } from './temporal-recall-service/runtime-context.mjs'; import { pickTkmindLoadingTip } from './tkmind-loading-tips.mjs'; import { resolveExecutorDisplayLabel, @@ -898,6 +899,7 @@ export function createAgentRunGateway({ goalRunService = null, workerIdentity = null, directEscalationContextPolicy = null, + getUmsPool = null, }) { const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {}); const sessionStore = resolveSessionAccess({ userAuth, sessionAccess }); @@ -1874,6 +1876,33 @@ export function createAgentRunGateway({ if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.applyAgentOrchestration) { const grantedSkills = await resolveGrantedSkills(row.user_id); let goalContext = null; + let runtimeContext = null; + const orchestrationDisplayText = userMessage?.metadata?.displayText + ?? userMessage?.content?.find?.((item) => item?.type === 'text')?.text + ?? ''; + try { + runtimeContext = await resolveRuntimeContext({ + pool, + getUmsPool, + userId: row.user_id, + query: orchestrationDisplayText, + sessionId: row.agent_session_id ?? null, + }); + if (runtimeContext?.injectionEnabled) { + await appendEvent(runId, 'runtime_context_resolved', { + query_type: runtimeContext.plan?.query_type ?? null, + temporal_mode: runtimeContext.plan?.temporal_mode ?? null, + temporal_items: runtimeContext.temporalRecall?.stats?.returned_count ?? 0, + has_snapshot: Boolean(runtimeContext.blocks?.snapshot), + injection_chars: runtimeContext.injectionText?.length ?? 0, + }); + } + } catch (err) { + console.warn( + '[AgentRun] runtime context resolve skipped:', + err instanceof Error ? err.message : err, + ); + } if (goalRunService && row.goal_run_id) { try { const goal = await goalRunService.getGoalRun({ @@ -1895,6 +1924,7 @@ export function createAgentRunGateway({ grantedSkills, memoryContext: agentMemoryContext, goalContext, + runtimeContext, }); } } diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index eaf0a1c..9042321 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -1128,6 +1128,7 @@ export function buildAgentOrchestrationAgentText({ skillPrompt = '', memoryContext = null, goalContext = null, + runtimeContext = null, }) { const taskBody = String(displayText ?? '').trim(); const memoryLines = memoryContext?.injectionEnabled @@ -1143,6 +1144,10 @@ export function buildAgentOrchestrationAgentText({ const goalEnvelope = goalContext?.injectionEnabled ? String(goalContext.envelope ?? '').trim() : ''; + const runtimeBlock = + runtimeContext?.injectionEnabled && runtimeContext.injectionText + ? String(runtimeContext.injectionText).trim() + : ''; const lines = [ `${AGENT_ORCHESTRATION_HEADER}以下为用户任务,请使用工具与技能实际执行并产出结果,不要只做文字描述。`, `路由判定:${classification.reason}`, @@ -1151,6 +1156,7 @@ export function buildAgentOrchestrationAgentText({ buildImageGenerationInstruction(classification.imageGeneration), skillPrompt, goalEnvelope, + runtimeBlock, memoryLines.length ? [ '[Memory Context]', @@ -1168,7 +1174,7 @@ export function buildAgentOrchestrationAgentText({ export function applyAgentOrchestrationToUserMessage( userMessage, classification, - { grantedSkills = [], memoryContext = null, goalContext = null } = {}, + { grantedSkills = [], memoryContext = null, goalContext = null, runtimeContext = null } = {}, ) { const displayText = messageDisplayText(userMessage); const skillPrompt = resolveSkillPrompt(classification?.suggestedSkill, grantedSkills, displayText); @@ -1178,6 +1184,7 @@ export function applyAgentOrchestrationToUserMessage( skillPrompt, memoryContext, goalContext, + runtimeContext, }); const content = Array.isArray(userMessage?.content) ? userMessage.content.map((item, index) => { diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs index 85d868e..80ad17c 100644 --- a/direct-chat-service.mjs +++ b/direct-chat-service.mjs @@ -1,4 +1,4 @@ -import crypto from 'node:crypto'; +import { resolveRuntimeContext } from './temporal-recall-service/runtime-context.mjs'; import { MEMORY_INTERVENTION_LIMIT } from './memory-intervention.mjs'; import { resolveMemoriesWithLegacyFallback } from './memory-legacy-fallback.mjs'; import { isMemoryRecallQuestion } from './chat-intent-router.mjs'; @@ -135,10 +135,11 @@ function buildMemorySystemBlock({ memories, routingMemoryContent } = {}) { return renderMemoryLines(memories); } -function buildModelMessages({ previousMessages, userMessage, memories, routingMemoryContent }) { +function buildModelMessages({ previousMessages, userMessage, memories, routingMemoryContent, runtimeContextText = '' }) { const system = [ '你是 TKMind H5 聊天助手。', '优先直接回答用户问题;不要调用工具。涉及需要执行代码、改文件、生成页面或操作外部系统的任务时,说明当前只能文字讨论,并提示用户开启「深度推理」或用更明确的任务描述。', + runtimeContextText, buildMemorySystemBlock({ memories, routingMemoryContent }), ].filter(Boolean).join('\n\n'); @@ -268,6 +269,8 @@ export function createDirectChatService({ memoryV2 = null, conversationMemoryService = null, episodicMemoryService = null, + pool = null, + getUmsPool = null, logger = console, enabled = envFlag(process.env.MEMIND_DIRECT_CHAT_ENABLED, true), } = {}) { @@ -388,12 +391,23 @@ export function createDirectChatService({ const memories = routedMemoryContent ? [] : await resolveMemories(userId, activeSessionId, messageText(userMessage)); + const runtimeContext = await resolveRuntimeContext({ + pool, + getUmsPool, + userId, + query: messageText(userMessage), + sessionId: activeSessionId, + }).catch((err) => { + logger?.warn?.(`[direct-chat] runtime context skipped: ${err instanceof Error ? err.message : err}`); + return { injectionText: '' }; + }); const completion = await llmProviderService.createChatCompletion({ messages: buildModelMessages({ previousMessages, userMessage, memories, routingMemoryContent: routedMemoryContent, + runtimeContextText: runtimeContext.injectionText ?? '', }), }); if (!completion?.ok) { diff --git a/docs/architecture/calendar-adapter-v0.2.md b/docs/architecture/calendar-adapter-v0.2.md new file mode 100644 index 0000000..05de7a0 --- /dev/null +++ b/docs/architecture/calendar-adapter-v0.2.md @@ -0,0 +1,81 @@ +# Calendar Source Adapter v0.2(接口冻结) + +| 字段 | 值 | +|------|-----| +| 状态 | **Draft(V0.2)** | +| 实现 | `temporal-recall-service/adapters/calendar.mjs`(V0.1 stub) | + +--- + +## 1. 职责 + +从 MeMind 日程系统(`scheduleService` / 未来 CalDAV)读取 **event_time 权威** 的 calendar events,归一化为 `TimelineItem`。 + +与 MeInput/Chat 的区别: + +| Source | event_time 来源 | +|--------|-----------------| +| Calendar | 结构化字段(权威) | +| MeInput/Chat | 文本抽取( hypothesis ) | + +--- + +## 2. Adapter 签名(Frozen) + +```typescript +interface CalendarSearchContext { + userId: string; + retrieval: ContextPlan['retrievals'][0]; + time: ContextPlan['time']; + temporalMode: ContextPlan['temporal_mode']; +} + +searchCalendar(ctx: CalendarSearchContext): Promise; +``` + +--- + +## 3. 数据来源(计划) + +| 优先级 | 来源 | 说明 | +|--------|------|------| +| P0 | `h5_schedules` / scheduleService API | MeMind 内置日程 | +| P1 | CalDAV 订阅 | 只读 sync | +| P2 | iOS EventKit export | 经 MeInput 伴侣 App | + +--- + +## 4. 归一化规则 + +```json +{ + "source": "calendar", + "type": "event", + "event_time": "", + "observed_time": "", + "title": "", + "status": "planned", + "confidence": 0.98, + "source_ref": "calendar:schedule:" +} +``` + +--- + +## 5. Context Planner 权重 + +当 query 含 `行程|会议|约会|日历|几点|安排` 时: + +```json +"sources": { "calendar": 0.85, ... } +``` + +V0.2 启用 retrievals 中 `source: "calendar"`(V0.1 在 planner 中跳过未实现源)。 + +--- + +## 6. V0.2 验收 + +1. 「我今天有什么安排?」→ Calendar items 排在 MeInput 前 +2. Calendar event + MeInput 同事件 → Dedupe merge +3. `PLANNED_IN` 模式仅按 `event_time` 过滤 diff --git a/docs/architecture/context-planner-v1.md b/docs/architecture/context-planner-v1.md new file mode 100644 index 0000000..30781a0 --- /dev/null +++ b/docs/architecture/context-planner-v1.md @@ -0,0 +1,151 @@ +# Context Planner API v1 + +| 字段 | 值 | +|------|-----| +| 状态 | **Frozen(V0.1)** | +| 建议 Base URL | MeMind Portal 同域 `/api/v1/context/*` | +| 输出契约 | `schemas/context-plan.schema.json` | + +--- + +## 1. 职责 + +将用户自然语言解析为 **ContextPlan AST**,决定: + +- 需要哪些上下文域(User Model / Temporal Recall / Memory V2) +- 多源检索权重与 expanded queries +- 时间范围与 Temporal Mode + +**不做**单选 intent 分类。 + +--- + +## 2. `POST /v1/context/plan` + +### 请求 + +```json +{ + "query": "我昨天有什么重要的事情安排吗?", + "user_id": "a70ff537-8908-486e-9b6c-042e07cc25db", + "now": "2026-09-03T22:00:00+08:00", + "session_id": "optional-session-uuid", + "locale": "zh-CN", + "planner_level": "auto" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `query` | 是 | 用户原句 | +| `user_id` | 是 | 当前用户 | +| `now` | 否 | 解析相对时间的锚点,默认服务端当前时间 | +| `session_id` | 否 | 用于 Chat 上下文 | +| `planner_level` | 否 | `auto` / `cheap_only` / `semantic` | + +### 响应 + +```json +{ + "plan": { + "query_type": "personal_temporal_recall", + "temporal_mode": "AMBIGUOUS", + "time": { + "mention_range": { + "start": "2026-09-02T00:00:00+08:00", + "end": "2026-09-03T00:00:00+08:00" + }, + "event_range": { + "start": "2026-09-02T00:00:00+08:00", + "end": "2026-09-03T00:00:00+08:00" + }, + "relative_label": "yesterday" + }, + "targets": [ + { "type": "commitment", "weight": 1.0 }, + { "type": "calendar_event", "weight": 0.85 }, + { "type": "todo", "weight": 0.9 } + ], + "sources": { + "calendar": 0.85, + "chat": 0.80, + "meinput": 0.75, + "memory_v2": 0.45 + }, + "retrievals": [ + { + "source": "meinput", + "query_type": "important_mentions", + "expanded_queries": ["重要", "安排", "会议", "明天", "截止"], + "weight": 0.75 + }, + { + "source": "chat", + "query_type": "commitments_and_decisions", + "expanded_queries": ["安排", "确认", "跟进", "记得"], + "weight": 0.80 + } + ], + "filters": { "importance_min": 0.6, "status": "any" }, + "output": { "group_by": "importance", "dedupe": true, "timeline": true, "wide_recall": true }, + "context_needs": { + "user_snapshot": "OPTIONAL", + "temporal_recall": "REQUIRED", + "memory_retrieval": "OPTIONAL" + }, + "planner_meta": { + "level": "cheap_router", + "rule_hits": ["time:yesterday", "keyword:重要", "keyword:安排"], + "confidence": 0.82 + } + } +} +``` + +### 错误 + +| HTTP | code | 说明 | +|------|------|------| +| 400 | `invalid_query` | 空 query | +| 401 | `unauthenticated` | 未登录 | +| 422 | `time_unparseable` | 时间词无法解析且无 fallback | + +--- + +## 3. 解析管线(实现约束) + +``` +1. deterministic time parser → mention_range / event_range / relative_label +2. keyword rule matcher → sources 权重 + rule_hits +3. target inference (rules) → targets[] +4. merge → ContextPlan +5. [v0.2] semantic planner patch → 仅 cheap_router confidence < 0.6 时 +``` + +V0.1 **默认 `cheap_only`**;`semantic` 返回 `501 not_implemented`。 + +--- + +## 4. 与 Task Intent Router 边界 + +| 组件 | 用途 | +|------|------| +| `chat-intent-router` | 聊天 → Agent 升级、tool 路由 | +| **Context Planner** | 回答需要哪些**检索上下文** | + +Context Planner **不决定**是否启动 Agent Run。 + +--- + +## 5. Runtime 集成 + +Agent 每轮开始前: + +``` +plan = POST /v1/context/plan { query: user_message } +if plan.context_needs.user_snapshot != SKIP → load snapshot +if plan.context_needs.temporal_recall != SKIP → POST /v1/temporal-recall/query { plan } +if plan.context_needs.memory_retrieval != SKIP → memory-v2 retrieve +``` + +V0.1 Runtime hook 可仅在 `temporal_recall = REQUIRED` 时启用。 diff --git a/docs/architecture/temporal-recall-v0.1.md b/docs/architecture/temporal-recall-v0.1.md new file mode 100644 index 0000000..11e728c --- /dev/null +++ b/docs/architecture/temporal-recall-v0.1.md @@ -0,0 +1,373 @@ +# RFC: Temporal Recall & Context Planner v0.1 + +| 字段 | 值 | +|------|-----| +| 状态 | **Frozen(V0.1)** | +| 日期 | 2026-09-03 | +| 关联 Schema | `schemas/timeline-item.schema.json`, `schemas/context-plan.schema.json` | +| 关联 API | `docs/api/temporal-recall-v1.md`, `docs/api/context-planner-v1.md` | +| 前置 RFC | `docs/rfc/user-model-v0.1.md` | + +--- + +## 1. 摘要 + +MeMind 需要一套独立于 User Model 与 Memory V2 的 **Temporal Recall** 能力,回答: + +> **某个时间范围内,发生了什么?** + +这与 User Model(「你是谁 / 你现在关注什么」)正交,是第四条一级能力域。 + +**V0.1 目标:** 跑通 `User Query → Context Planner → Multi-source Retrieval → Timeline → Rank → Answer`,数据源仅 **MeInput + Chat**;Calendar / Memory V2 作为可选低权重源或 v0.2 接入。 + +**核心架构转变(Frozen):** + +``` +Intent Classification(单选 intent) → 不再是 Temporal 问题的核心 +Context Planning(多源 Query Plan) → 新核心 +Retrieval Robustness(宽召回 + Rank) → 优于 Intent Precision +``` + +--- + +## 2. 四个能力域(Frozen) + +| 域 | 回答 | 典型问题 | +|----|------|----------| +| **User Model** | 我是谁、我现在关注什么 | 「帮我写代码时用什么风格?」 | +| **Memory V2** | 我过去明确发生过 / 决定过什么 | 「我们上次定的架构是什么?」 | +| **Temporal Recall** | 某个时间范围内发生了什么 | 「我这周有什么重要的事?」 | +| **Runtime Context** | 当前这一轮要用哪些上下文 | Session 内 tool / page / task 状态 | + +``` + User Query + │ + ▼ + Context Planner + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ + User Model Temporal Recall Memory V2 + 「你是谁」 「发生了什么」 「曾经记住什么」 + │ │ │ + └─────────────┼─────────────┘ + ▼ + Agent Runtime +``` + +### 2.1 组合策略(Frozen) + +| 问题类型 | User Model | Temporal Recall | Memory V2 | +|----------|------------|-----------------|-----------| +| 普通聊天 | REQUIRED | SKIP | SKIP | +| 时间范围问题 | OPTIONAL | REQUIRED | OPTIONAL | +| 历史决策 | OPTIONAL | OPTIONAL | REQUIRED | +| 复杂综合 | OPTIONAL | REQUIRED | REQUIRED | + +每项取值:`REQUIRED | OPTIONAL | SKIP`。 + +--- + +## 3. 架构铁律(Frozen) + +1. **Temporal 问题不做单选 intent 分类。** 输出 Query Plan,允许多源并行检索。 +2. **时间词用确定性 parser,不交给 LLM 猜。**(今天 / 昨天 / 这周 / 最近三天 …) +3. **强约束词给 source 加权,不硬路由。**(行程→Calendar 高权重;原话→MeInput 高权重) +4. **模糊语义才走 LLM semantic expansion。**(「重要的事」「需要注意的」) +5. **Timeline Item 必须区分 `event_time` 与 `observed_time`。** +6. **宽召回优于窄 intent。** 误解 20% 仍应能召回正确证据。 +7. **Temporal Recall 不写入 User Model Graph。** 它是检索视图,不是画像。 +8. **Temporal Recall 不默认写入 Memory V2。** 长期记忆仍走 UMS → Candidate → Memory 链。 +9. **Answer 必须基于 ranked timeline items,禁止 LLM 无证据自由发挥。** +10. **Dedupe 跨源合并同一事件**(Calendar + MeInput + Chat 三条 → 一条)。 + +--- + +## 4. 核心组件 + +### 4.1 Context Planner(Personal Query Planner) + +**职责:** 决定「这句话需要哪些上下文」,而非「这句话属于哪个 intent」。 + +**双通道解析(Frozen):** + +``` + User Query + │ + ┌─────────┴──────────┐ + ▼ ▼ + deterministic parser semantic parser + (rule / small model) (LLM, 仅复杂 query) + │ │ + time / keywords / targets / ambiguity / + explicit entities implied meaning + │ │ + └─────────┬──────────┘ + ▼ + Context Plan Merge + ▼ + ContextPlan AST +``` + +**Level 1 — Cheap Router(~80% 问题):** 无 LLM 或小模型;检测时间范围、明显 Calendar 词、明显原文请求、是否历史问题。 + +**Level 2 — Semantic Planner:** 仅复杂 query 启用 LLM(多时间引用、未完成承诺追踪、跨源条件过滤)。 + +### 4.2 Temporal Recall Service + +**职责:** 按 Context Plan 并行检索各 Source Adapter,归一化为 Timeline Item,Dedupe + Rank,返回 Recall Bundle。 + +``` + Context Plan + │ + ┌────────────┼─────────────┐ + ▼ ▼ ▼ + Calendar Chat MeInput + (v0.2+) (v0.1) (v0.1) + │ │ │ + └────────────┼─────────────┘ + ▼ + Timeline Normalizer + ▼ + Dedupe + ▼ + Recall Ranker + ▼ + Recall Bundle +``` + +### 4.3 Source Adapter 边界 + +每个 Source **只暴露 Timeline Item 或 Evidence Envelope**,Temporal Recall **不得** JOIN Source 内部表(与 UMS 铁律一致)。 + +| Source | V0.1 | Adapter 入口 | +|--------|------|--------------| +| MeInput | ✅ | `GET /v1/evidence/export`(expression_segment) | +| Chat | ✅ | Portal session messages / agent runs | +| Memory V2 | 低权重可选 | `memory-v2` time-bounded search | +| Calendar | ❌ v0.2 | 待定 | + +--- + +## 5. ContextPlan AST(Frozen) + +见 `schemas/context-plan.schema.json`。核心字段: + +```json +{ + "query_type": "personal_temporal_recall", + "time": { + "mention_range": { "start": "...", "end": "..." }, + "event_range": null + }, + "temporal_mode": "OCCURRED_IN | MENTIONED_IN | PLANNED_IN | CREATED_IN | DUE_IN | AMBIGUOUS", + "targets": [ + { "type": "calendar_event", "weight": 0.8 }, + { "type": "commitment", "weight": 1.0 }, + { "type": "todo", "weight": 0.9 } + ], + "sources": { + "calendar": 0.85, + "chat": 0.80, + "meinput": 0.75, + "memory_v2": 0.45 + }, + "retrievals": [ + { + "source": "meinput", + "query_type": "important_mentions", + "expanded_queries": ["会议", "安排", "明天", "截止"], + "weight": 0.75 + } + ], + "filters": { + "importance_min": 0.6, + "status": "unresolved" + }, + "output": { + "group_by": "importance", + "dedupe": true, + "timeline": true, + "wide_recall": true + }, + "context_needs": { + "user_snapshot": "OPTIONAL", + "temporal_recall": "REQUIRED", + "memory_retrieval": "OPTIONAL" + } +} +``` + +### 5.1 六个解析槽位 + +| 槽位 | 含义 | 示例 | +|------|------|------| +| `time_scope` | 绝对时间范围 | yesterday → `[start, end)` | +| `target` | 要找什么类型 | event, schedule, todo, commitment | +| `operation` | 检索后操作 | retrieve / retrieve_and_summarize | +| `importance` | 重要性过滤 | important / any | +| `sources` | 多源权重(非单选) | calendar:0.85, chat:0.80 | +| `temporal_semantics` | 时间语义 | occurred_or_planned / ambiguous | + +### 5.2 Temporal Mode(Frozen) + +| Mode | 语义 | 示例问题 | +|------|------|----------| +| `OCCURRED_IN` | 事件发生在该范围 | 「我昨天做了什么?」 | +| `MENTIONED_IN` | 在该范围内被提到 | 「我昨天说过要做什么?」 | +| `PLANNED_IN` | 计划在该范围发生 | 「我今天有什么安排?」 | +| `CREATED_IN` | 在该范围内创建/记录 | 「我上周定了哪些事?」 | +| `DUE_IN` | 截止 / 应完成在该范围 | 「这周五之前要交什么?」 | +| `AMBIGUOUS` | 宽召回两种语义 | 「我昨天有什么重要的事?」 | + +`AMBIGUOUS` 时 **禁止追问为默认行为**;并行查 occurred + mentioned,分组回答。 + +--- + +## 6. Timeline Item 统一模型(Frozen) + +见 `schemas/timeline-item.schema.json`。 + +```json +{ + "timeline_item_id": "uuid", + "user_id": "uuid", + "source": "calendar | chat | meinput | memory_v2", + "type": "event | todo | decision | mention | commitment", + "event_time": "2026-09-03T15:00:00+08:00", + "observed_time": "2026-09-02T20:30:00+08:00", + "title": "与张总签合同", + "content": "明天下午三点去签合同", + "importance": 0.82, + "confidence": 0.91, + "recall_score": 0.76, + "source_ref": "meinput:segment:...", + "participants": ["张总"], + "status": "planned | completed | mentioned | unknown", + "merged_from": ["calendar:...", "chat:...", "meinput:..."] +} +``` + +**关键区分:** + +- `event_time` — 事情何时发生 / 计划发生 +- `observed_time` — 何时被记录 / 输入 / 提到 + +例:9 月 2 日输入「明天下午三点签合同」→ `observed_time=9/2`, `event_time=9/3 15:00`。 + +--- + +## 7. Recall Score(Frozen) + +``` +recall_score = + source_quality +× temporal_match +× semantic_match +× importance +× extraction_confidence +``` + +| 分数区间 | 展示策略 | +|----------|----------| +| ≥ 0.75 | 主答案直接展示 | +| 0.50 ~ 0.75 | 次要信息 / 「可能相关」 | +| < 0.50 | 默认不展示 | + +--- + +## 8. Query Expansion(Frozen) + +**禁止**用用户原句单一 embedding 搜所有库。 + +Planner 为每个 source 生成 **expanded_queries**: + +| Source | 扩展策略 | +|--------|----------| +| Calendar | 时间 filter 为主,keyword 为辅 | +| Chat | 约 / 安排 / 明天 / 会议 / 确认 / 跟进 / 截止 | +| MeInput | 任务词 / 时间表达 / action verbs | +| Memory V2 | commitment / task / decision | + +--- + +## 9. Dedupe 规则(V0.1 简化) + +同一 `event_time` ±15min + 语义相似(title/content embedding 或 keyword overlap > 0.7)→ merge。 + +Merge 后保留最高 `source_quality`,`merged_from` 记录各源。 + +--- + +## 10. 与 User Model 的关系 + +| | User Model | Temporal Recall | +|--|------------|-----------------| +| 时间性 | 慢变 / 半静态 | 强时间索引 | +| 问题 | 「我是谁」 | 「那时发生了什么」 | +| 存储 | Graph + Snapshot | 检索视图(不持久化画像) | +| 写入 | Evidence → Signal → Candidate | 只读各 Source | +| Session 加载 | 默认 REQUIRED | 按 Planner 按需 | + +Planner 可将 User Snapshot 作为 **ranking prior**(例如已知 active_projects 加权相关 timeline items),但 Temporal Recall 不反向写 Graph。 + +--- + +## 11. V0.1 范围 + +### 做 + +- [ ] ContextPlan JSON Schema + Timeline Item Schema +- [ ] Deterministic time parser(zh-CN 相对时间) +- [ ] Rule-based source weight(强约束词表) +- [ ] Cheap Router(Level 1) +- [ ] MeInput Source Adapter(evidence export) +- [ ] Chat Source Adapter(session messages) +- [ ] Timeline Normalizer + Dedupe + Rank +- [ ] `POST /v1/temporal-recall/query` API +- [ ] Agent Runtime hook:`context_needs.temporal_recall = REQUIRED` 时注入 Recall Bundle + +### 不做(V0.1) + +- Calendar / Email / Browser / Git / Location +- LLM Semantic Planner(Level 2)— 仅预留接口 +- Memory V2 深度集成(可选低权重 stub) +- 用户追问 clarification UI(默认宽召回 + 分组) +- Timeline 持久化索引库(V0.1 实时检索;V0.2 考虑 `memind_timeline` 物化) + +--- + +## 12. 开发顺序(建议) + +1. Schema + API 契约冻结(本文档) +2. Deterministic time parser + rule keyword weights +3. MeInput adapter(复用 evidence export) +4. Chat adapter +5. Normalizer + Ranker + Dedupe +6. Cheap Router → ContextPlan +7. Temporal Recall API +8. Runtime Context Planner hook +9. v0.2:Calendar adapter + Semantic Planner + 物化 timeline index + +--- + +## 13. 命名约定(Frozen) + +| 避免 | 使用 | +|------|------| +| Intent Layer(用于 Temporal) | **Context Planner** | +| `intent: query_schedule` | `ContextPlan.sources` 多源权重 | +| 「猜测用户唯一意图」 | 「宽召回 + Rank」 | +| Personal Intent Classification | **Personal Temporal Recall** | + +Task Execution Intent(聊天→Agent 升级)仍走现有 `chat-intent-router`;**不与 Context Planner 混用**。 + +--- + +## 14. 验收标准(V0.1) + +1. 「我昨天有什么重要的事?」→ 返回 grouped timeline(occurred + mentioned),不全为空 +2. 「我这周输入过什么关于 MeInput 的?」→ MeInput source 高权重命中 +3. 同一事件在 Chat + MeInput 重复出现 → Dedupe 为一条 +4. `event_time` / `observed_time` 过滤行为符合 Temporal Mode +5. 无 ContextPlan 时 Agent 不调用 Temporal Recall(不误触发) diff --git a/docs/architecture/temporal-recall-v1.md b/docs/architecture/temporal-recall-v1.md new file mode 100644 index 0000000..81ca329 --- /dev/null +++ b/docs/architecture/temporal-recall-v1.md @@ -0,0 +1,190 @@ +# Temporal Recall API v1 + +| 字段 | 值 | +|------|-----| +| 状态 | **Frozen(V0.1)** | +| 建议 Base URL | MeMind Portal 同域 `/api/v1/temporal-recall/*` | +| 输入 | `ContextPlan` 或简化 query | +| 输出 | `TimelineItem[]` + 分组元数据 | + +--- + +## 1. 职责 + +按 Context Plan **并行检索**多源,归一化、Dedupe、Rank,返回 Personal Timeline Recall Bundle。 + +**回答:** 「某个时间范围内发生了什么?」 + +--- + +## 2. `POST /v1/temporal-recall/query` + +### 方式 A — 传入完整 Plan(推荐) + +```json +{ + "plan": { "...ContextPlan..." }, + "limit": 50 +} +``` + +### 方式 B — 快捷 query(内部先调 Planner) + +```json +{ + "query": "我这周有什么重要的事?", + "user_id": "a70ff537-8908-486e-9b6c-042e07cc25db", + "now": "2026-09-03T22:00:00+08:00", + "limit": 50 +} +``` + +### 响应 + +```json +{ + "query_type": "personal_temporal_recall", + "temporal_mode": "AMBIGUOUS", + "time_range": { + "start": "2026-09-01T00:00:00+08:00", + "end": "2026-09-08T00:00:00+08:00" + }, + "groups": [ + { + "label": "occurred_in_range", + "items": [ + { + "timeline_item_id": "...", + "source": "meinput", + "type": "mention", + "event_time": null, + "observed_time": "2026-09-02T14:30:00+08:00", + "title": "MeInput 验证上屏", + "content": "MeInput验证上屏", + "importance": 0.71, + "confidence": 0.95, + "recall_score": 0.78, + "source_ref": "meinput:segment:...", + "status": "mentioned" + } + ] + }, + { + "label": "mentioned_or_planned", + "items": [] + } + ], + "items": [], + "stats": { + "sources_queried": ["meinput", "chat"], + "raw_count": 42, + "deduped_count": 38, + "returned_count": 25, + "elapsed_ms": 320 + }, + "plan": { "...echo ContextPlan..." } +} +``` + +`groups` 在 `temporal_mode=AMBIGUOUS` 时区分「实际发生」与「提到/安排」;否则 `items` 为 flat ranked list。 + +--- + +## 3. `GET /v1/temporal-recall/info` + +```json +{ + "schema_version": 1, + "supported_sources": ["meinput", "chat"], + "planned_sources": ["calendar", "memory_v2", "email", "browser", "tasks"], + "temporal_modes": ["OCCURRED_IN", "MENTIONED_IN", "PLANNED_IN", "CREATED_IN", "DUE_IN", "AMBIGUOUS"], + "default_limit": 50, + "max_limit": 200 +} +``` + +--- + +## 4. Source Adapter 契约 + +每个 adapter 实现: + +```typescript +interface TemporalSourceAdapter { + source: 'meinput' | 'chat' | 'calendar' | 'memory_v2'; + search(ctx: { + userId: string; + retrieval: ContextPlan['retrievals'][0]; + time: ContextPlan['time']; + temporalMode: ContextPlan['temporal_mode']; + }): Promise; +} +``` + +### MeInput Adapter(V0.1) + +- 调用 `GET /v1/evidence/export`(MeInput Cloud) +- 过滤 `expression_segment`,按 `occurred_at` 映射 `observed_time` +- 规则抽取 `event_time`(「明天下午三点」→ 解析为绝对时间) +- `source_ref = meinput:segment:{evidence_id}` + +### Chat Adapter(V0.1) + +- 查 `h5_agent_runs` + session messages(用户可见范围) +- `observed_time = message.created_at` +- commitment/todo 规则抽取 + +--- + +## 5. Rank 公式 + +``` +recall_score = + source_quality(source) +× temporal_match(item, plan.time, plan.temporal_mode) +× semantic_match(item, expanded_queries) +× importance(item) +× confidence(item) +``` + +| 阈值 | 展示 | +|------|------| +| ≥ 0.75 | 主答案 | +| 0.50 ~ 0.75 | 次要 | +| < 0.50 | 丢弃 | + +--- + +## 6. Dedupe + +- 时间:`|event_time_a - event_time_b| < 15min` 或同日 + 同类 +- 语义:title/content keyword overlap > 0.7 或 embedding cosine > 0.85(v0.2) +- Merge → 保留最高 `source_quality`,填充 `merged_from` + +--- + +## 7. 认证 + +| 调用方 | 认证 | +|--------|------| +| Agent Runtime | 用户 sessionToken | +| 内部 Worker | `TEMPORAL_RECALL_TOKEN`(可选) | + +--- + +## 8. V0.1 不做 + +- Calendar / Email / Browser adapter +- 持久化 timeline 索引表 +- LLM 答案生成(只返回 structured timeline;Answer 层在 Agent) + +--- + +## 9. 错误码 + +| HTTP | code | 说明 | +|------|------|------| +| 400 | `invalid_plan` | Plan schema 校验失败 | +| 401 | `unauthenticated` | 未登录 | +| 503 | `source_unavailable` | MeInput export 不可用 | +| 504 | `recall_timeout` | 并行检索超时(默认 5s) | diff --git a/schemas/context-plan.schema.json b/schemas/context-plan.schema.json new file mode 100644 index 0000000..d02aa4a --- /dev/null +++ b/schemas/context-plan.schema.json @@ -0,0 +1,175 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://memind.local/schemas/context-plan.v1.schema.json", + "title": "ContextPlan", + "description": "Context Planner 输出 — 决定需要哪些上下文及多源检索计划", + "type": "object", + "required": ["query_type", "time", "temporal_mode", "targets", "sources", "retrievals", "context_needs"], + "additionalProperties": false, + "properties": { + "query_type": { + "type": "string", + "enum": [ + "personal_temporal_recall", + "memory_lookup", + "profile_only", + "general_chat", + "task_execution" + ] + }, + "time": { + "type": "object", + "required": ["mention_range"], + "additionalProperties": false, + "properties": { + "mention_range": { + "$ref": "#/$defs/timeRange", + "description": "按 observed_time / 提及时间过滤" + }, + "event_range": { + "oneOf": [{ "$ref": "#/$defs/timeRange" }, { "type": "null" }], + "description": "按 event_time / 计划发生时间过滤" + }, + "relative_label": { + "type": ["string", "null"], + "enum": [ + "today", + "yesterday", + "day_before_yesterday", + "this_week", + "last_week", + "this_month", + "last_month", + "recent_days", + null + ] + } + } + }, + "temporal_mode": { + "type": "string", + "enum": ["OCCURRED_IN", "MENTIONED_IN", "PLANNED_IN", "CREATED_IN", "DUE_IN", "AMBIGUOUS"] + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["type", "weight"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "calendar_event", + "event", + "schedule", + "todo", + "commitment", + "decision", + "mention", + "important_event" + ] + }, + "weight": { "type": "number", "minimum": 0, "maximum": 1 } + } + } + }, + "sources": { + "type": "object", + "additionalProperties": { "type": "number", "minimum": 0, "maximum": 1 }, + "propertyNames": { + "enum": ["calendar", "chat", "meinput", "memory_v2", "email", "browser", "tasks", "git"] + } + }, + "retrievals": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "query_type", "weight"], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "enum": ["calendar", "chat", "meinput", "memory_v2"] + }, + "query_type": { + "type": "string", + "description": "源内检索策略,如 important_mentions | commitments_and_decisions | events" + }, + "expanded_queries": { + "type": "array", + "items": { "type": "string", "maxLength": 128 } + }, + "weight": { "type": "number", "minimum": 0, "maximum": 1 }, + "skip_below": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.3, + "description": "低于此权重的 source 跳过检索" + } + } + } + }, + "filters": { + "type": "object", + "additionalProperties": false, + "properties": { + "importance_min": { "type": "number", "minimum": 0, "maximum": 1 }, + "status": { + "type": "string", + "enum": ["any", "unresolved", "planned", "completed"] + }, + "participants": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "output": { + "type": "object", + "additionalProperties": false, + "properties": { + "group_by": { + "type": "string", + "enum": ["importance", "time", "source", "type"] + }, + "dedupe": { "type": "boolean", "default": true }, + "timeline": { "type": "boolean", "default": true }, + "wide_recall": { "type": "boolean", "default": true }, + "max_items": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } + } + }, + "context_needs": { + "type": "object", + "required": ["user_snapshot", "temporal_recall", "memory_retrieval"], + "additionalProperties": false, + "properties": { + "user_snapshot": { "type": "string", "enum": ["REQUIRED", "OPTIONAL", "SKIP"] }, + "temporal_recall": { "type": "string", "enum": ["REQUIRED", "OPTIONAL", "SKIP"] }, + "memory_retrieval": { "type": "string", "enum": ["REQUIRED", "OPTIONAL", "SKIP"] } + } + }, + "planner_meta": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { "type": "string", "enum": ["cheap_router", "semantic_planner"] }, + "rule_hits": { "type": "array", "items": { "type": "string" } }, + "confidence": { "type": "number", "minimum": 0, "maximum": 1 } + } + } + }, + "$defs": { + "timeRange": { + "type": "object", + "required": ["start", "end"], + "additionalProperties": false, + "properties": { + "start": { "type": "string", "format": "date-time" }, + "end": { "type": "string", "format": "date-time" } + } + } + } +} diff --git a/schemas/evidence-envelope.schema.json b/schemas/evidence-envelope.schema.json new file mode 100644 index 0000000..e2ecb22 --- /dev/null +++ b/schemas/evidence-envelope.schema.json @@ -0,0 +1,146 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://memind.local/schemas/evidence-envelope.v1.schema.json", + "title": "EvidenceEnvelope", + "description": "通用 Evidence Contract v1。所有 Source(MeInput、Chat、Calendar、Browser、Git…)统一产出此结构;UMS 只消费 Envelope,不依赖各 Source 内部表结构。", + "type": "object", + "required": [ + "evidence_id", + "user_id", + "source_type", + "source_ref", + "occurred_at", + "evidence_type", + "payload", + "content_hash", + "schema_version" + ], + "properties": { + "evidence_id": { + "type": "string", + "format": "uuid", + "description": "Evidence 全局唯一 ID(由 Source 或 Export 层生成)" + }, + "user_id": { + "type": "string", + "format": "uuid", + "description": "MeMind h5_users.id" + }, + "source_type": { + "type": "string", + "enum": ["meinput", "chat", "calendar", "browser", "git", "mail", "filesystem", "voice", "location"], + "description": "Evidence Source 类型,与 MeMind Source 体系对齐" + }, + "source_ref": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Source 内稳定引用,如 segment_id / message_id / commit_sha" + }, + "occurred_at": { + "type": "string", + "format": "date-time", + "description": "Evidence 发生时间(用户行为时间,非入库时间)" + }, + "received_at": { + "type": "string", + "format": "date-time", + "description": "Source 侧接收/导出时间,可选" + }, + "evidence_type": { + "type": "string", + "enum": [ + "expression_segment", + "expression_commit", + "expression_session", + "chat_message", + "calendar_event", + "browser_visit", + "git_commit", + "aggregate_daily", + "timeline_report" + ], + "description": "Evidence 语义类型,决定 payload 子 schema" + }, + "payload": { + "type": "object", + "description": "类型化载荷,见 $defs" + }, + "content_hash": { + "type": "string", + "pattern": "^[a-f0-9]{64}$", + "description": "SHA-256(canonical_json(payload)+evidence_type+source_ref),用于幂等与去重" + }, + "schema_version": { + "type": "integer", + "const": 1 + }, + "privacy_level": { + "type": "string", + "enum": ["normal", "restricted", "secure_skip"], + "default": "normal", + "description": "secure_skip 的 Evidence 不应离开 Source;Export 层必须过滤" + }, + "locale": { + "type": "string", + "description": "如 zh-CN,可选" + } + }, + "additionalProperties": false, + "$defs": { + "ExpressionSegmentPayload": { + "type": "object", + "required": ["text", "start_at", "end_at", "event_ids"], + "properties": { + "text": { "type": "string", "minLength": 1 }, + "start_at": { "type": "string", "format": "date-time" }, + "end_at": { "type": "string", "format": "date-time" }, + "event_ids": { "type": "array", "items": { "type": "string", "format": "uuid" }, "minItems": 1 }, + "event_count": { "type": "integer", "minimum": 1 }, + "context": { + "type": "object", + "properties": { + "app": { "type": ["string", "null"] }, + "app_bundle_id": { "type": ["string", "null"] }, + "device_id": { "type": "string" }, + "scene": { "type": "string" }, + "input_method": { "type": ["string", "null"] }, + "language": { "type": ["string", "null"] } + } + } + }, + "additionalProperties": false + }, + "AggregateDailyPayload": { + "type": "object", + "required": ["day", "stats"], + "properties": { + "day": { "type": "string", "format": "date" }, + "stats": { + "type": "object", + "properties": { + "events": { "type": "integer", "minimum": 0 }, + "segments": { "type": "integer", "minimum": 0 }, + "chars": { "type": "integer", "minimum": 0 }, + "peak_hour": { "type": "integer", "minimum": 0, "maximum": 23 }, + "top_apps": { "type": "array", "items": { "type": "string" } }, + "top_terms": { "type": "array", "items": { "type": "string" } } + } + } + }, + "additionalProperties": false + }, + "TimelineReportPayload": { + "type": "object", + "required": ["report_type", "period_key", "edition", "status", "content"], + "properties": { + "report_type": { "type": "string", "enum": ["daily_close", "weekly_review"] }, + "period_key": { "type": "string" }, + "edition": { "type": "integer", "minimum": 1 }, + "status": { "type": "string", "enum": ["published", "revoked"] }, + "content": { "type": "object" } + }, + "additionalProperties": false + } + } +} diff --git a/schemas/memind_user_model-v0.sql b/schemas/memind_user_model-v0.sql new file mode 100644 index 0000000..bbc83a1 --- /dev/null +++ b/schemas/memind_user_model-v0.sql @@ -0,0 +1,187 @@ +-- memind_user_model v0 — User Model Service (UMS) +-- 库:memind_user_model(与 meinput、goose 并列,独立 RDS 库或 schema) +-- UMS 只 ingest Evidence Envelope v1,不读 meinput.mi_* 表。 + +-- ── L0: 原始 Evidence(immutable append)──────────────────────────────────── + +CREATE TABLE IF NOT EXISTS um_evidence ( + evidence_id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + source_type VARCHAR(32) NOT NULL, + source_ref VARCHAR(512) NOT NULL, + evidence_type VARCHAR(64) NOT NULL, + occurred_at DATETIME(3) NOT NULL, + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + content_hash CHAR(64) NOT NULL, + schema_version INT NOT NULL DEFAULT 1, + privacy_level VARCHAR(32) NOT NULL DEFAULT 'normal', + payload_json JSON NOT NULL, + UNIQUE KEY uq_um_evidence_content (user_id, content_hash), + KEY idx_um_evidence_user_time (user_id, occurred_at DESC), + KEY idx_um_evidence_source (user_id, source_type, occurred_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── L1: Signal(观察到了什么 — 确定性,可复算)──────────────────────────── + +CREATE TABLE IF NOT EXISTS um_signals ( + signal_id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + signal_type VARCHAR(64) NOT NULL, + -- 例:term_frequency | app_usage | entity_mention | rhythm_hour | segment_count + dimension_key VARCHAR(128) NOT NULL, + -- 例:term:MeInput | app:com.tencent.xin | hour:15 + window_start DATETIME(3) NOT NULL, + window_end DATETIME(3) NOT NULL, + value_json JSON NOT NULL, + -- 例:{"count":126,"chars":8400,"distinct_days":7} + evidence_ids JSON NOT NULL, + -- 指向 um_evidence.evidence_id 数组 + computed_at BIGINT NOT NULL, + content_hash CHAR(64) NOT NULL, + UNIQUE KEY uq_um_signal (user_id, signal_type, dimension_key, window_start, window_end, content_hash), + KEY idx_um_signals_user_window (user_id, window_end DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── L2: Candidate(系统推断了什么 — hypothesis)──────────────────────────── + +CREATE TABLE IF NOT EXISTS um_candidates ( + candidate_id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + candidate_type VARCHAR(64) NOT NULL, + -- project | interest | preference | relation | routine | focus + hypothesis_json JSON NOT NULL, + -- 例:{"type":"project","name":"MeInput","role":"active"} + status ENUM( + 'observed', + 'open', + 'accepted', + 'rejected', + 'conflicted', + 'revoked', + 'memory_eligible', + 'memory_promoted' + ) NOT NULL DEFAULT 'observed', + promotion_score DECIMAL(6,4) NOT NULL DEFAULT 0, + confidence DECIMAL(6,4) NOT NULL DEFAULT 0, + signal_ids JSON NOT NULL, + evidence_ids JSON NOT NULL, + first_seen_at DATETIME(3) NOT NULL, + last_seen_at DATETIME(3) NOT NULL, + version INT NOT NULL DEFAULT 1, + parent_candidate_id CHAR(36) NULL, + conflict_with_id CHAR(36) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_um_candidates_user_status (user_id, status, last_seen_at DESC), + KEY idx_um_candidates_user_type (user_id, candidate_type, promotion_score DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── L3: User Model Graph ─────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS um_entities ( + entity_id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + entity_type VARCHAR(32) NOT NULL, + -- person | project | topic | tool | preference_dim | role + canonical_name VARCHAR(256) NOT NULL, + aliases_json JSON NULL, + status ENUM('active', 'archived', 'revoked') NOT NULL DEFAULT 'active', + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_um_entity (user_id, entity_type, canonical_name(128)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS um_attributes ( + attribute_id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + entity_id CHAR(36) NULL, + attr_key VARCHAR(128) NOT NULL, + value_json JSON NOT NULL, + confidence DECIMAL(6,4) NOT NULL, + decay_halflife_days INT NOT NULL DEFAULT 90, + effective_weight DECIMAL(6,4) NOT NULL DEFAULT 1, + evidence_ids JSON NOT NULL, + candidate_id CHAR(36) NULL, + first_seen_at DATETIME(3) NOT NULL, + last_seen_at DATETIME(3) NOT NULL, + status ENUM('active', 'superseded', 'revoked') NOT NULL DEFAULT 'active', + version INT NOT NULL DEFAULT 1, + content_hash CHAR(64) NOT NULL, + KEY idx_um_attr_user_key (user_id, attr_key, status, effective_weight DESC), + CONSTRAINT fk_um_attr_entity FOREIGN KEY (entity_id) REFERENCES um_entities(entity_id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS um_relations ( + relation_id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + from_entity_id CHAR(36) NOT NULL, + rel_type VARCHAR(64) NOT NULL, + to_entity_id CHAR(36) NOT NULL, + confidence DECIMAL(6,4) NOT NULL, + evidence_ids JSON NOT NULL, + candidate_id CHAR(36) NULL, + first_seen_at DATETIME(3) NOT NULL, + last_seen_at DATETIME(3) NOT NULL, + status ENUM('active', 'superseded', 'revoked') NOT NULL DEFAULT 'active', + KEY idx_um_rel_user (user_id, from_entity_id, rel_type), + CONSTRAINT fk_um_rel_from FOREIGN KEY (from_entity_id) REFERENCES um_entities(entity_id) ON DELETE CASCADE, + CONSTRAINT fk_um_rel_to FOREIGN KEY (to_entity_id) REFERENCES um_entities(entity_id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── L4: Profile Projection(Graph 的结构化投影,非 narrative)────────────── + +CREATE TABLE IF NOT EXISTS um_profile_versions ( + profile_version INT NOT NULL, + user_id CHAR(36) NOT NULL, + structured_json JSON NOT NULL, + content_hash CHAR(64) NOT NULL, + parent_version INT NULL, + materialize_reason VARCHAR(64) NOT NULL, + -- daily_consolidation | candidate_accepted | threshold | manual + created_at BIGINT NOT NULL, + status ENUM('active', 'superseded', 'revoked') NOT NULL DEFAULT 'active', + PRIMARY KEY (user_id, profile_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── L5: Agent Snapshot(Profile Projection 的渲染视图,Session 加载)──────── + +CREATE TABLE IF NOT EXISTS um_profile_snapshots ( + snapshot_id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + projection VARCHAR(32) NOT NULL DEFAULT 'default', + -- default | coding | schedule + profile_version INT NOT NULL, + fast_revision INT NOT NULL DEFAULT 0, + snapshot_json JSON NOT NULL, + byte_size INT NOT NULL, + content_hash CHAR(64) NOT NULL, + created_at BIGINT NOT NULL, + expires_at BIGINT NULL, + status ENUM('active', 'superseded', 'revoked') NOT NULL DEFAULT 'active', + KEY idx_um_snapshot_user_proj (user_id, projection, status, created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── 辅助:ingest 游标(按 user + source_type)──────────────────────────────── + +CREATE TABLE IF NOT EXISTS um_ingest_cursors ( + user_id CHAR(36) NOT NULL, + source_type VARCHAR(32) NOT NULL, + cursor_value VARCHAR(256) NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (user_id, source_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── v0.1 预留:Memory lineage(暂不写入,schema 先冻结)──────────────────── + +CREATE TABLE IF NOT EXISTS um_memory_promotions ( + promotion_id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + candidate_id CHAR(36) NOT NULL, + attribute_id CHAR(36) NULL, + memind_candidate_id VARCHAR(64) NULL, + content_hash CHAR(64) NOT NULL, + promoted_at BIGINT NOT NULL, + revoked_at BIGINT NULL, + status ENUM('promoted', 'revoked') NOT NULL DEFAULT 'promoted', + KEY idx_um_promo_user (user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/schemas/timeline-item.schema.json b/schemas/timeline-item.schema.json new file mode 100644 index 0000000..58bea88 --- /dev/null +++ b/schemas/timeline-item.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://memind.local/schemas/timeline-item.v1.schema.json", + "title": "TimelineItem", + "description": "Temporal Recall 统一时间线条目 — 跨 Source 归一化模型", + "type": "object", + "required": [ + "timeline_item_id", + "user_id", + "source", + "type", + "observed_time", + "title", + "content", + "importance", + "confidence", + "source_ref", + "status" + ], + "additionalProperties": false, + "properties": { + "timeline_item_id": { + "type": "string", + "format": "uuid", + "description": "归一化后的 timeline 条目 ID" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "source": { + "type": "string", + "enum": ["calendar", "chat", "meinput", "memory_v2", "email", "browser", "tasks", "git", "location", "notes"] + }, + "type": { + "type": "string", + "enum": ["event", "todo", "decision", "mention", "commitment"] + }, + "event_time": { + "type": ["string", "null"], + "format": "date-time", + "description": "事件计划/实际发生时间;未知时为 null" + }, + "observed_time": { + "type": "string", + "format": "date-time", + "description": "首次被记录/输入/提到的时间" + }, + "title": { + "type": "string", + "maxLength": 512 + }, + "content": { + "type": "string", + "maxLength": 8192 + }, + "importance": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "抽取/归一化置信度" + }, + "recall_score": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "最终召回排序分" + }, + "source_ref": { + "type": "string", + "maxLength": 512, + "description": "源系统引用,如 meinput:segment:" + }, + "participants": { + "type": "array", + "items": { "type": "string", "maxLength": 128 }, + "default": [] + }, + "status": { + "type": "string", + "enum": ["planned", "completed", "mentioned", "unknown"] + }, + "merged_from": { + "type": "array", + "items": { "type": "string", "maxLength": 512 }, + "description": "Dedupe 合并前的 source_ref 列表" + }, + "app_bundle_id": { + "type": ["string", "null"], + "description": "MeInput 来源 App" + }, + "app_name": { + "type": ["string", "null"] + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/scripts/setup-ums-rds.mjs b/scripts/setup-ums-rds.mjs new file mode 100644 index 0000000..bb03d24 --- /dev/null +++ b/scripts/setup-ums-rds.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * Create RDS database memind_user_model (if missing) and run UMS schema migrate. + * + * Env (pick one): + * UMS_DATABASE_URL=mysql://user:pass@host:3306/memind_user_model + * DATABASE_URL / MEINPUT_DATABASE_URL (derives sibling DB on same host) + */ +import { spawnSync } from 'node:child_process'; +import mysql from 'mysql2/promise'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function deriveUmsUrl() { + if (process.env.UMS_DATABASE_URL) return process.env.UMS_DATABASE_URL; + const base = process.env.DATABASE_URL ?? process.env.MEINPUT_DATABASE_URL ?? ''; + if (!base) return null; + const u = new URL(base); + u.pathname = '/memind_user_model'; + return u.toString(); +} + +function adminUrl(dbUrl) { + const u = new URL(dbUrl); + u.pathname = '/'; + return u.toString(); +} + +async function main() { + const umsUrl = deriveUmsUrl(); + if (!umsUrl) { + console.error('Set UMS_DATABASE_URL or DATABASE_URL'); + process.exit(1); + } + + const admin = await mysql.createConnection({ uri: adminUrl(umsUrl) }); + try { + await admin.query( + 'CREATE DATABASE IF NOT EXISTS memind_user_model CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + ); + console.log('Database memind_user_model ready.'); + } finally { + await admin.end(); + } + + const child = spawnSync('node', ['user-model-service/migrate.mjs'], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, UMS_DATABASE_URL: umsUrl }, + }); + process.exit(child.status ?? 1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke-temporal-recall.mjs b/scripts/smoke-temporal-recall.mjs new file mode 100644 index 0000000..a20875c --- /dev/null +++ b/scripts/smoke-temporal-recall.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +/** + * Smoke: context plan → temporal recall query + * + * Env: MEMIND_BASE_URL (default http://127.0.0.1:8081) + * MEMIND_USERNAME / MEMIND_PASSWORD + */ +const MEMIND_PORTAL = process.env.MEMIND_BASE_URL ?? 'http://127.0.0.1:8081'; +const MEMIND_API = `${MEMIND_PORTAL.replace(/\/$/, '')}/api`; +const username = process.env.MEMIND_USERNAME ?? 'admin'; +const password = process.env.MEMIND_PASSWORD ?? '981122'; + +async function login() { + const res = await fetch(`${MEMIND_PORTAL}/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(`login: ${JSON.stringify(data)}`); + return { token: data.sessionToken, user: data.user }; +} + +async function main() { + const { token, user } = await login(); + const headers = { + 'content-type': 'application/json', + cookie: `tkmind_user_session=${token}`, + }; + + const query = process.argv[2] ?? '我昨天有什么重要的事?'; + const planRes = await fetch(`${MEMIND_API}/v1/context/plan`, { + method: 'POST', + headers, + body: JSON.stringify({ query, now: new Date().toISOString() }), + }); + const planBody = await planRes.json(); + if (!planRes.ok) throw new Error(`plan: ${JSON.stringify(planBody)}`); + console.log('plan:', { + query_type: planBody.plan.query_type, + temporal_mode: planBody.plan.temporal_mode, + sources: planBody.plan.sources, + retrievals: planBody.plan.retrievals?.map((r) => r.source), + }); + + const recallRes = await fetch(`${MEMIND_API}/v1/temporal-recall/query`, { + method: 'POST', + headers, + body: JSON.stringify({ plan: planBody.plan, limit: 10 }), + }); + const recallBody = await recallRes.json(); + if (!recallRes.ok) throw new Error(`recall: ${JSON.stringify(recallBody)}`); + console.log('recall stats:', recallBody.stats); + const sample = recallBody.items?.[0] ?? recallBody.groups?.[0]?.items?.[0]; + if (sample) { + console.log('sample:', sample.source, sample.title?.slice(0, 60), 'score=', sample.recall_score); + } else { + console.log('no items (user may have no data in range)'); + } + console.log(`smoke OK for user ${user.id}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs index 0a863f8..4d4ed9b 100644 --- a/server.mjs +++ b/server.mjs @@ -5,6 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createAuthManager } from './auth.mjs'; import { createDbPool, isDatabaseConfigured } from './db.mjs'; +import { createUmsPool, isUmsDatabaseConfigured } from './user-model-service/db.mjs'; import { attachMindSpaceImageGenerationRoutes } from './mindspace-image-generation-routes.mjs'; import { sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs'; import { createWikiAuth } from './wiki-auth.mjs'; @@ -70,6 +71,8 @@ import { createPortalAuthSessionHelpers } from './server/portal-auth-session-hel import { createPortalSessionCoordinator } from './server/portal-session-coordinator.mjs'; import { attachPortalSessionRoutes } from './server/portal-session-routes.mjs'; import { attachPortalUserMemoryRoutes } from './server/portal-user-memory-routes.mjs'; +import { attachPortalUserModelRoutes } from './server/portal-user-model-routes.mjs'; +import { attachPortalTemporalRecallRoutes } from './server/portal-temporal-recall-routes.mjs'; import { attachPortalGoalRunRoutes } from './server/portal-goal-run-routes.mjs'; import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; import { loadMindSpaceConfigCached } from './mindspace-config.mjs'; @@ -330,12 +333,24 @@ let scheduledTaskWorker = null; let llmProviderService = null; let wordFilterService = null; let authPool = null; +let umsPool = null; let pageDataService = null; let pageDataPublicService = null; let mindSpaceAnalyticsConfig = resolveMindSpaceAnalyticsConfig(); async function bootstrapUserAuth() { try { + if (isUmsDatabaseConfigured()) { + try { + umsPool = createUmsPool(); + console.log('[UMS] user model pool ready'); + } catch (err) { + console.warn( + '[UMS] pool init failed:', + err instanceof Error ? err.message : err, + ); + } + } if (!isDatabaseConfigured()) return false; const pool = createDbPool(); const domainServices = @@ -470,6 +485,7 @@ async function bootstrapUserAuth() { llmProviderService, userAuth, sessionAccess, + getUmsPool: () => umsPool, logger: console, }); memoryV2ConfigService = @@ -528,6 +544,7 @@ async function bootstrapUserAuth() { syncUserGeneratedPages, isSessionPageDeliveryActive, experienceService, + getUmsPool: () => umsPool, }); tkmindProxy = gatewayServices.tkmindProxy; toolGateway = gatewayServices.toolGateway; @@ -678,6 +695,7 @@ attachPortalAccountFeedbackRoutes({ getLegacyAuth: () => legacyAuth, getSubscriptionService: () => subscriptionService, getFeedbackService: () => feedbackService, + getUmsPool: () => umsPool, userToken, legacySessionToken, clearUserLoginCookies, @@ -753,6 +771,10 @@ api.use(createPortalApiAuthMiddleware({ isLegacyPageDataApiPath, isProductAnalyticsPublicPath: (requestPath, method) => method === 'GET' && requestPath === '/analytics/context', + isUmsIngestPublicPath: (requestPath, method) => + method === 'POST' && requestPath === '/v1/user-model/ingest', + isTemporalRecallInfoPublicPath: (requestPath, method) => + method === 'GET' && requestPath === '/v1/temporal-recall/info', accessPolicyMode: portalAccessPolicyMode, accessEnforcementConfig: portalAccessEnforcementConfig, accessShadowReporter: portalAccessShadowReporter, @@ -859,7 +881,7 @@ async function resolveUserMemoryItems(userId, { sessionId = null, limit = 200 } attachPortalUserMemoryRoutes(api, { getMemoryV2: () => memoryV2, getTkmindProxy: () => tkmindProxy, - getPool: () => pool, + getPool: () => authPool, ensureUserMemoryCapability, ownsAgentSession, loadUserVisibleConversation, @@ -867,6 +889,16 @@ attachPortalUserMemoryRoutes(api, { resolveUserMemoryItems, }); +attachPortalUserModelRoutes(api, { + getUmsPool: () => umsPool, + getUserAuth: () => userAuth, +}); + +attachPortalTemporalRecallRoutes(api, { + getPool: () => authPool, + getUserAuth: () => userAuth, +}); + attachPortalGoalRunRoutes(api, { getGoalRunService: () => goalRunService, getAgentRunGateway: () => agentRunGateway, diff --git a/server/portal-account-feedback-routes.mjs b/server/portal-account-feedback-routes.mjs index 7943e8b..c748533 100644 --- a/server/portal-account-feedback-routes.mjs +++ b/server/portal-account-feedback-routes.mjs @@ -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, }); }); diff --git a/server/portal-api-auth-middleware.mjs b/server/portal-api-auth-middleware.mjs index 2e08f45..9ee72e5 100644 --- a/server/portal-api-auth-middleware.mjs +++ b/server/portal-api-auth-middleware.mjs @@ -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(); } diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs index b17373a..14fc986 100644 --- a/server/portal-gateway-services-bootstrap.mjs +++ b/server/portal-gateway-services-bootstrap.mjs @@ -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, diff --git a/server/portal-memory-session-services-bootstrap.mjs b/server/portal-memory-session-services-bootstrap.mjs index 3a4a829..3de5700 100644 --- a/server/portal-memory-session-services-bootstrap.mjs +++ b/server/portal-memory-session-services-bootstrap.mjs @@ -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({ diff --git a/server/portal-temporal-recall-routes.mjs b/server/portal-temporal-recall-routes.mjs new file mode 100644 index 0000000..be09f24 --- /dev/null +++ b/server/portal-temporal-recall-routes.mjs @@ -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' }); + } + }); +} diff --git a/server/portal-user-model-routes.mjs b/server/portal-user-model-routes.mjs new file mode 100644 index 0000000..47a7f53 --- /dev/null +++ b/server/portal-user-model-routes.mjs @@ -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' }); + } + }); +} diff --git a/src/api/client.ts b/src/api/client.ts index abef7ff..3087942 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -27,6 +27,7 @@ import type { FeedbackSubmissionType, FeedbackBoardPage, PortalUser, + UserModelSnapshot, Session, SessionConversationPage, SessionListPage, @@ -362,6 +363,7 @@ export async function getMe(): Promise<{ capabilities?: CapabilityMap; grantedSkills?: string[]; unrestricted?: boolean; + userModelSnapshot?: UserModelSnapshot | null; }> { return portalFetch('/auth/me'); } diff --git a/src/types.ts b/src/types.ts index ae51348..f80ab96 100644 --- a/src/types.ts +++ b/src/types.ts @@ -325,6 +325,25 @@ export type PlanDefinition = { overageRate: number; }; +export type UserModelSnapshot = { + snapshot_id: string; + user_id: string; + projection: string; + profile_version: number; + fast_revision: number; + content_hash: string; + byte_size: number; + stale_after_sec: number; + loaded_at_hint?: string; + canonical_user_id?: string; + core?: { + active_projects?: Array<{ id?: string; name?: string; confidence?: number }>; + recent_focus?: Array<{ topic?: string; confidence?: number }>; + agent_hints?: string[]; + }; + meta?: Record; +}; + export type PortalUser = { id: string; username: string; diff --git a/temporal-recall-service/adapters/calendar.mjs b/temporal-recall-service/adapters/calendar.mjs new file mode 100644 index 0000000..6335d3d --- /dev/null +++ b/temporal-recall-service/adapters/calendar.mjs @@ -0,0 +1,87 @@ +import crypto from 'node:crypto'; +import { createScheduleService } from '../../schedule-service.mjs'; +import { extractEventTime, itemMatchesTimeWindow } from '../event-time-extract.mjs'; + +function envEnabled(name, fallback = true) { + const raw = String(process.env[name] ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +function newTimelineId(sourceRef) { + const hash = crypto.createHash('sha256').update(`timeline-v1|${sourceRef}`).digest('hex'); + return [ + hash.slice(0, 8), + hash.slice(8, 12), + `4${hash.slice(13, 16)}`, + hash.slice(16, 20), + hash.slice(20, 32), + ].join('-'); +} + +/** + * @param {ReturnType extends { listItems: infer _ } ? object : never>} item + */ +export function scheduleItemToTimelineItem(item, ctx) { + const eventMs = item.startAt ?? item.dueAt ?? null; + const eventIso = eventMs != null ? new Date(eventMs).toISOString() : null; + const observedIso = new Date(item.createdAt).toISOString(); + const sourceRef = `calendar:schedule:${item.id}`; + const text = [item.title, item.description].filter(Boolean).join(' — '); + const extracted = eventIso ? null : extractEventTime(text, observedIso); + + const timelineItem = { + timeline_item_id: newTimelineId(sourceRef), + user_id: item.userId, + source: 'calendar', + type: item.kind === 'event' ? 'event' : 'todo', + event_time: eventIso ?? extracted?.event_time ?? null, + observed_time: observedIso, + title: String(item.title ?? '').slice(0, 80), + content: String(text).slice(0, 8192), + importance: item.kind === 'event' ? 0.9 : 0.82, + confidence: 0.98, + source_ref: sourceRef, + participants: [], + status: 'planned', + metadata: { + location: item.location ?? null, + timezone: item.timezone ?? null, + schedule_kind: item.kind, + }, + }; + + if (ctx?.time && !itemMatchesTimeWindow(timelineItem, ctx.time, ctx.temporalMode)) { + return null; + } + return timelineItem; +} + +/** + * @param {{ pool?: import('mysql2/promise').Pool, userId: string, retrieval: object, time: object, temporalMode: string }} ctx + */ +export async function searchCalendar(ctx) { + if (!envEnabled('MEMIND_TEMPORAL_RECALL_CALENDAR_ENABLED', true)) return []; + if (!ctx.pool?.query || !ctx.userId) return []; + + const scheduleService = createScheduleService(ctx.pool); + const rangeStart = ctx.time?.event_range?.start ?? ctx.time?.mention_range?.start; + const rangeEnd = ctx.time?.event_range?.end ?? ctx.time?.mention_range?.end; + if (!rangeStart || !rangeEnd) return []; + + const from = new Date(rangeStart).getTime(); + const to = new Date(rangeEnd).getTime(); + if (Number.isNaN(from) || Number.isNaN(to)) return []; + + const items = await scheduleService.listItems({ + userId: ctx.userId, + from, + to, + status: 'active', + limit: 100, + }); + + return items + .map((item) => scheduleItemToTimelineItem(item, ctx)) + .filter(Boolean); +} diff --git a/temporal-recall-service/adapters/chat.mjs b/temporal-recall-service/adapters/chat.mjs new file mode 100644 index 0000000..618eed8 --- /dev/null +++ b/temporal-recall-service/adapters/chat.mjs @@ -0,0 +1,107 @@ +import crypto from 'node:crypto'; +import { + expandObservedFetchRange, + extractEventTime, + itemMatchesTimeWindow, +} from '../event-time-extract.mjs'; + +function newTimelineId(sourceRef) { + const hash = crypto.createHash('sha256').update(`timeline-v1|${sourceRef}`).digest('hex'); + return [ + hash.slice(0, 8), + hash.slice(8, 12), + `4${hash.slice(13, 16)}`, + hash.slice(16, 20), + hash.slice(20, 32), + ].join('-'); +} + +function parseUserMessage(row) { + try { + return typeof row.user_message_json === 'string' + ? JSON.parse(row.user_message_json) + : row.user_message_json; + } catch { + return null; + } +} + +function extractText(message) { + if (!message) return ''; + if (typeof message === 'string') return message.trim(); + if (typeof message.content === 'string') return message.content.trim(); + if (Array.isArray(message.content)) { + return message.content + .map((part) => (typeof part === 'string' ? part : part?.text ?? '')) + .join('') + .trim(); + } + if (typeof message.text === 'string') return message.text.trim(); + return ''; +} + +function scoreTextImportance(text, expandedQueries = []) { + let score = 0.5; + const t = String(text ?? ''); + if (/重要|紧急|安排|会议|待办|记得|跟进|截止|确认/.test(t)) score += 0.18; + for (const q of expandedQueries) { + if (q && t.includes(q)) score += 0.05; + } + return Math.min(0.95, score); +} + +function matchesExpanded(text, expandedQueries) { + if (!expandedQueries?.length) return true; + return expandedQueries.some((q) => q && text.includes(q)); +} + +/** + * @param {import('mysql2/promise').Pool} pool + * @param {{ userId: string, retrieval: object, time: object, sessionId?: string }} ctx + */ +export async function searchChat(pool, ctx) { + if (!pool?.query) return []; + + let sql = ` + SELECT id, user_message_json, created_at, agent_session_id + FROM h5_agent_runs + WHERE user_id = ? AND created_at >= ? AND created_at < ?`; + const fetchRange = expandObservedFetchRange(ctx.time, ctx.temporalMode); + const params = [ctx.userId, new Date(fetchRange.start).getTime(), new Date(fetchRange.end).getTime()]; + if (ctx.sessionId) { + sql += ' AND agent_session_id = ?'; + params.push(ctx.sessionId); + } + sql += ' ORDER BY created_at ASC LIMIT 300'; + + const [rows] = await pool.query(sql, params); + const items = []; + for (const row of rows) { + const message = parseUserMessage(row); + const text = extractText(message); + if (text.length < 2) continue; + if (!matchesExpanded(text, ctx.retrieval.expanded_queries) && text.length < 12) continue; + const observed = new Date(Number(row.created_at)).toISOString(); + const extracted = extractEventTime(text, observed); + const sourceRef = `chat:run:${row.id}`; + const item = { + timeline_item_id: newTimelineId(sourceRef), + user_id: ctx.userId, + source: 'chat', + type: /待办|记得|别忘了|跟进|截止|安排/.test(text) ? 'commitment' : 'mention', + event_time: extracted.event_time, + observed_time: observed, + title: text.slice(0, 80), + content: text.slice(0, 8192), + importance: scoreTextImportance(text, ctx.retrieval.expanded_queries), + confidence: extracted.event_time ? extracted.confidence : 0.88, + source_ref: sourceRef, + participants: [], + status: extracted.status === 'planned' ? 'planned' : 'mentioned', + metadata: { agent_session_id: row.agent_session_id }, + }; + if (!itemMatchesTimeWindow(item, ctx.time, ctx.temporalMode)) continue; + items.push(item); + } + return items; +} diff --git a/temporal-recall-service/adapters/meinput.mjs b/temporal-recall-service/adapters/meinput.mjs new file mode 100644 index 0000000..53db5b5 --- /dev/null +++ b/temporal-recall-service/adapters/meinput.mjs @@ -0,0 +1,154 @@ +import crypto from 'node:crypto'; +import { + expandObservedFetchRange, + extractEventTime, + itemMatchesTimeWindow, +} from '../event-time-extract.mjs'; +import mysql from 'mysql2/promise'; + +let meinputPool = null; + +function getMeinputPool() { + const url = process.env.MEINPUT_DATABASE_URL ?? ''; + if (!url) return null; + if (!meinputPool) { + meinputPool = mysql.createPool({ uri: url, waitForConnections: true, connectionLimit: 4 }); + } + return meinputPool; +} + +function newTimelineId(sourceRef) { + const hash = crypto.createHash('sha256').update(`timeline-v1|${sourceRef}`).digest('hex'); + return [ + hash.slice(0, 8), + hash.slice(8, 12), + `4${hash.slice(13, 16)}`, + hash.slice(16, 20), + hash.slice(20, 32), + ].join('-'); +} + +function scoreTextImportance(text, expandedQueries = []) { + let score = 0.45; + const t = String(text ?? ''); + if (t.length >= 20) score += 0.08; + if (/重要|紧急|必须|截止|会议|安排|明天|今天|项目/.test(t)) score += 0.15; + for (const q of expandedQueries) { + if (q && t.includes(q)) score += 0.05; + } + return Math.min(0.95, score); +} + +function envelopeToTimelineItem(envelope, expandedQueries, temporalMode, time) { + const payload = envelope.payload ?? {}; + const text = String(payload.text ?? '').trim(); + if (!text) return null; + const observed = envelope.occurred_at; + const extracted = extractEventTime(text, observed); + const appName = payload.context?.app ?? null; + const appBundle = payload.context?.app_bundle_id ?? null; + const sourceRef = `meinput:segment:${envelope.evidence_id}`; + const item = { + timeline_item_id: newTimelineId(sourceRef), + user_id: envelope.user_id, + source: 'meinput', + type: /待办|记得|别忘了|跟进|截止/.test(text) ? 'todo' : 'mention', + event_time: extracted.event_time, + observed_time: observed, + title: text.slice(0, 80), + content: text.slice(0, 8192), + importance: scoreTextImportance(text, expandedQueries), + confidence: extracted.event_time ? extracted.confidence : 0.92, + source_ref: sourceRef, + participants: [], + status: extracted.status === 'planned' ? 'planned' : 'mentioned', + app_name: appName, + app_bundle_id: appBundle, + }; + if (time && !itemMatchesTimeWindow(item, time, temporalMode)) return null; + return item; +} + +async function fetchViaHttp(ctx) { + const base = process.env.MEINPUT_BASE_URL ?? 'https://input.tkmind.cn'; + const username = process.env.MEINPUT_USERNAME ?? 'admin'; + const password = process.env.MEINPUT_PASSWORD ?? ''; + if (!password) return []; + + const loginRes = await fetch(`${base}/v1/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const login = await loginRes.json(); + if (!loginRes.ok || login.user_id !== ctx.userId) { + return []; + } + + const url = new URL(`${base}/v1/evidence/export`); + url.searchParams.set('limit', '200'); + url.searchParams.set('since', ctx.time.mention_range.start); + url.searchParams.set('until', ctx.time.mention_range.end); + const res = await fetch(url, { headers: { authorization: `Bearer ${login.access_token}` } }); + const data = await res.json(); + if (!res.ok) return []; + return (data.items ?? []) + .map((item) => + envelopeToTimelineItem( + item, + ctx.retrieval.expanded_queries, + ctx.temporalMode, + ctx.time, + ), + ) + .filter(Boolean); +} + +async function fetchViaDb(ctx) { + const pool = getMeinputPool(); + if (!pool) return null; + const fetchRange = expandObservedFetchRange(ctx.time, ctx.temporalMode); + const [rows] = await pool.query( + `SELECT event_id, text, app_name, app_bundle_id, created_at + FROM mi_input_events + WHERE user_id = ? AND privacy_level = 'normal' + AND created_at >= ? AND created_at < ? + ORDER BY created_at ASC + LIMIT 800`, + [ + ctx.userId, + fetchRange.start.slice(0, 23).replace('T', ' '), + fetchRange.end.slice(0, 23).replace('T', ' '), + ], + ); + return rows + .map((row) => { + const observed = + row.created_at instanceof Date ? row.created_at.toISOString() : new Date(row.created_at).toISOString(); + const envelope = { + evidence_id: row.event_id, + user_id: ctx.userId, + occurred_at: observed, + payload: { + text: row.text, + context: { app: row.app_name, app_bundle_id: row.app_bundle_id }, + }, + }; + return envelopeToTimelineItem( + envelope, + ctx.retrieval.expanded_queries, + ctx.temporalMode, + ctx.time, + ); + }) + .filter(Boolean); +} + +/** + * @param {{ userId: string, retrieval: object, time: object, temporalMode: string }} ctx + */ +export async function searchMeinput(ctx) { + const dbItems = await fetchViaDb(ctx); + if (dbItems !== null) return dbItems; + return fetchViaHttp(ctx); +} diff --git a/temporal-recall-service/calendar.test.mjs b/temporal-recall-service/calendar.test.mjs new file mode 100644 index 0000000..38cf5a2 --- /dev/null +++ b/temporal-recall-service/calendar.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { scheduleItemToTimelineItem } from './adapters/calendar.mjs'; + +test('scheduleItemToTimelineItem maps event with startAt', () => { + const ctx = { + temporalMode: 'PLANNED_IN', + time: { + mention_range: { + start: '2026-09-03T00:00:00+08:00', + end: '2026-09-04T00:00:00+08:00', + }, + event_range: { + start: '2026-09-03T00:00:00+08:00', + end: '2026-09-04T00:00:00+08:00', + }, + }, + }; + const item = scheduleItemToTimelineItem( + { + id: 'sched-1', + userId: 'user-1', + kind: 'event', + title: '与张总开会', + description: '项目方案确认', + startAt: new Date('2026-09-03T07:00:00.000Z').getTime(), + dueAt: null, + createdAt: new Date('2026-09-02T10:00:00.000Z').getTime(), + timezone: 'Asia/Shanghai', + location: null, + }, + ctx, + ); + assert.ok(item); + assert.equal(item.source, 'calendar'); + assert.equal(item.type, 'event'); + assert.equal(item.status, 'planned'); + assert.equal(item.confidence, 0.98); + assert.match(item.source_ref, /^calendar:schedule:/); +}); diff --git a/temporal-recall-service/context-planner.mjs b/temporal-recall-service/context-planner.mjs new file mode 100644 index 0000000..5e688d5 --- /dev/null +++ b/temporal-recall-service/context-planner.mjs @@ -0,0 +1,133 @@ +import { + analyzeKeywords, + expandQueries, + inferTargets, + inferTemporalMode, + isTemporalRecallQuery, +} from './keyword-rules.mjs'; +import { parseTimeScope } from './time-parser.mjs'; + +function envCalendarEnabled() { + const raw = String(process.env.MEMIND_TEMPORAL_RECALL_CALENDAR_ENABLED ?? '1').trim().toLowerCase(); + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +/** + * @param {{ query: string, user_id: string, now?: Date, planner_level?: string }} input + */ +export function buildContextPlan(input) { + const query = String(input.query ?? '').trim(); + const now = input.now instanceof Date ? input.now : new Date(input.now ?? Date.now()); + const planner_level = input.planner_level ?? 'auto'; + + if (!query) { + throw new Error('invalid_query'); + } + + if (planner_level === 'semantic') { + const err = new Error('semantic planner not implemented in v0.1'); + err.code = 'not_implemented'; + throw err; + } + + const temporal = isTemporalRecallQuery(query); + if (!temporal) { + return { + query_type: 'general_chat', + time: { + mention_range: parseTimeScope('这周', now).mention_range, + event_range: null, + relative_label: 'this_week', + }, + temporal_mode: 'AMBIGUOUS', + targets: [{ type: 'mention', weight: 0.5 }], + sources: { calendar: 0.2, chat: 0.4, meinput: 0.4, memory_v2: 0.3 }, + retrievals: [], + filters: { importance_min: 0.35, status: 'any' }, + output: { group_by: 'importance', dedupe: true, timeline: true, wide_recall: true, max_items: 50 }, + context_needs: { + user_snapshot: 'REQUIRED', + temporal_recall: 'SKIP', + memory_retrieval: 'SKIP', + }, + planner_meta: { level: 'cheap_router', rule_hits: ['query:general_chat'], confidence: 0.9 }, + }; + } + + const time = parseTimeScope(query, now); + const kw = analyzeKeywords(query); + const targets = inferTargets(query, kw); + const temporal_mode = inferTemporalMode(query); + const rule_hits = [...time.rule_hits, ...kw.rule_hits, `temporal_mode:${temporal_mode}`]; + + const sources = { ...kw.sources }; + if (temporal_mode === 'PLANNED_IN' || temporal_mode === 'DUE_IN') { + sources.calendar = Math.min(1, sources.calendar + 0.2); + } + if (temporal_mode === 'MENTIONED_IN' || temporal_mode === 'CREATED_IN') { + sources.meinput = Math.min(1, sources.meinput + 0.15); + sources.chat = Math.min(1, sources.chat + 0.15); + } + + const skip_below = 0.3; + const retrievals = []; + for (const [source, weight] of Object.entries(sources)) { + if (weight < skip_below) continue; + if (source === 'calendar' && !envCalendarEnabled()) continue; + retrievals.push({ + source, + query_type: + source === 'meinput' + ? 'important_mentions' + : source === 'chat' + ? 'commitments_and_decisions' + : source === 'calendar' + ? 'events' + : 'time_bounded', + expanded_queries: expandQueries(source, query, kw.commitmentKeywords), + weight: Number(weight.toFixed(2)), + skip_below, + }); + } + + retrievals.sort((a, b) => b.weight - a.weight); + + const memoryHint = /决定|定了|上次|记得|记住/.test(query); + const context_needs = { + user_snapshot: 'OPTIONAL', + temporal_recall: 'REQUIRED', + memory_retrieval: memoryHint ? 'OPTIONAL' : 'SKIP', + }; + + return { + query_type: 'personal_temporal_recall', + time: { + mention_range: time.mention_range, + event_range: time.event_range, + relative_label: time.relative_label, + }, + temporal_mode, + targets, + sources: Object.fromEntries( + Object.entries(sources).map(([k, v]) => [k, Number(v.toFixed(2))]), + ), + retrievals, + filters: { + importance_min: kw.importance_min, + status: /还没|未完成|没做|待/.test(query) ? 'unresolved' : 'any', + }, + output: { + group_by: temporal_mode === 'AMBIGUOUS' ? 'importance' : 'time', + dedupe: true, + timeline: true, + wide_recall: true, + max_items: 50, + }, + context_needs, + planner_meta: { + level: 'cheap_router', + rule_hits, + confidence: Math.min(0.95, 0.65 + retrievals.length * 0.08), + }, + }; +} diff --git a/temporal-recall-service/dedupe.mjs b/temporal-recall-service/dedupe.mjs new file mode 100644 index 0000000..0e4cf0a --- /dev/null +++ b/temporal-recall-service/dedupe.mjs @@ -0,0 +1,68 @@ +function normalizeTitle(text) { + return String(text ?? '') + .toLowerCase() + .replace(/\s+/g, '') + .slice(0, 64); +} + +function overlapScore(a, b) { + const ta = normalizeTitle(a); + const tb = normalizeTitle(b); + if (!ta || !tb) return 0; + if (ta === tb) return 1; + if (ta.includes(tb) || tb.includes(ta)) return 0.85; + const shorter = ta.length < tb.length ? ta : tb; + const longer = ta.length < tb.length ? tb : ta; + let common = 0; + for (let i = 0; i < shorter.length; i++) { + if (longer.includes(shorter[i])) common++; + } + return common / Math.max(longer.length, 1); +} + +function timeClose(a, b, windowMs = 15 * 60_000) { + const ma = a ? new Date(a).getTime() : null; + const mb = b ? new Date(b).getTime() : null; + if (ma === null || mb === null) return false; + return Math.abs(ma - mb) <= windowMs; +} + +/** + * @param {object[]} items + */ +export function dedupeTimelineItems(items) { + const kept = []; + for (const item of items) { + let merged = false; + for (let i = 0; i < kept.length; i++) { + const existing = kept[i]; + const titleSim = overlapScore(existing.title, item.title); + const sameDayObserved = + existing.observed_time?.slice(0, 10) === item.observed_time?.slice(0, 10); + const close = + timeClose(existing.event_time, item.event_time) || + (sameDayObserved && titleSim > 0.65); + if (titleSim >= 0.7 && close) { + const mergedFrom = [ + ...(existing.merged_from ?? [existing.source_ref]), + item.source_ref, + ]; + kept[i] = { + ...existing, + recall_score: Math.max(existing.recall_score ?? 0, item.recall_score ?? 0), + importance: Math.max(existing.importance ?? 0, item.importance ?? 0), + confidence: Math.max(existing.confidence ?? 0, item.confidence ?? 0), + merged_from: mergedFrom, + content: + (existing.content?.length ?? 0) >= (item.content?.length ?? 0) + ? existing.content + : item.content, + }; + merged = true; + break; + } + } + if (!merged) kept.push({ ...item }); + } + return kept; +} diff --git a/temporal-recall-service/event-time-extract.mjs b/temporal-recall-service/event-time-extract.mjs new file mode 100644 index 0000000..6541a6f --- /dev/null +++ b/temporal-recall-service/event-time-extract.mjs @@ -0,0 +1,273 @@ +const TZ_OFFSET_MIN = Number(process.env.TEMPORAL_RECALL_TZ_OFFSET_MIN ?? 480); + +/** @param {Date} d */ +function toLocalParts(d) { + const shifted = new Date(d.getTime() + TZ_OFFSET_MIN * 60_000); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth(), + day: shifted.getUTCDate(), + }; +} + +/** @param {{ year: number, month: number, day: number }} p @param {number} h @param {number} m */ +function partsToDate(p, h = 0, m = 0) { + const utcMs = + Date.UTC(p.year, p.month, p.day, h, m, 0, 0) - TZ_OFFSET_MIN * 60_000; + return new Date(utcMs); +} + +/** @param {Date} anchor @param {number} deltaDays */ +function addDays(anchor, deltaDays) { + const p = toLocalParts(anchor); + return partsToDate({ year: p.year, month: p.month, day: p.day + deltaDays }, 0, 0); +} + +const WEEKDAY_MAP = { + 一: 1, + 二: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 日: 7, + 天: 7, +}; + +function parseHour(text, hourRaw, minuteRaw) { + let hour = Number(hourRaw); + let minute = minuteRaw != null && minuteRaw !== '' ? Number(minuteRaw) : 0; + if (Number.isNaN(hour)) return null; + if (Number.isNaN(minute)) minute = 0; + + if (/下午|晚上|傍晚/.test(text) && hour >= 1 && hour <= 11) hour += 12; + if (/中午/.test(text) && hour >= 1 && hour <= 10) hour += 12; + if (/凌晨/.test(text) && hour === 12) hour = 0; + if (hour === 24) hour = 0; + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; + return { hour, minute }; +} + +function resolveDayOffset(text) { + if (/大后天/.test(text)) return 3; + if (/后天/.test(text)) return 2; + if (/明天|明日/.test(text)) return 1; + if (/今天|今日/.test(text)) return 0; + if (/昨天|昨日/.test(text)) return -1; + if (/前天/.test(text)) return -2; + return null; +} + +function resolveWeekday(text, anchor) { + const m = text.match(/(下?周|这个星期|这星期|本周)?([一二三四五六日天])/); + if (!m) return null; + const target = WEEKDAY_MAP[m[2]]; + if (!target) return null; + const p = toLocalParts(anchor); + const anchorDate = partsToDate(p, 0, 0); + const anchorDow = new Date(anchorDate.getTime() + TZ_OFFSET_MIN * 60_000).getUTCDay(); + const anchorMonBased = anchorDow === 0 ? 7 : anchorDow; + let delta = target - anchorMonBased; + if (delta <= 0) delta += 7; + if (/下周/.test(m[1] ?? '')) delta += 7; + return addDays(anchor, delta); +} + +function resolveMonthDay(text, anchor) { + const m = text.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*日/); + if (!m) return null; + const month = Number(m[1]) - 1; + const day = Number(m[2]); + const p = toLocalParts(anchor); + let year = p.year; + if (month < p.month || (month === p.month && day < p.day)) year += 1; + return partsToDate({ year, month, day }, 0, 0); +} + +const CN_DIGIT = { + 零: 0, + 〇: 0, + 一: 1, + 二: 2, + 两: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 七: 7, + 八: 8, + 九: 9, + 十: 10, +}; + +function parseChineseNumber(raw) { + const s = String(raw ?? '').trim(); + if (!s) return null; + if (/^\d+$/.test(s)) return Number(s); + if (s === '十') return 10; + if (s.startsWith('十')) { + const tail = CN_DIGIT[s[1]]; + return tail != null ? 10 + tail : null; + } + if (s.endsWith('十')) { + const head = CN_DIGIT[s[0]]; + return head != null ? head * 10 : null; + } + if (s.includes('十')) { + const [a, b] = s.split('十'); + const head = a ? CN_DIGIT[a] ?? null : 1; + const tail = b ? CN_DIGIT[b] ?? null : 0; + if (head == null || tail == null) return null; + return head * 10 + tail; + } + return CN_DIGIT[s] ?? null; +} + +function findTimeInText(text) { + const cnPattern = + /(上午|早上|中午|下午|晚上|傍晚|凌晨)?\s*([零〇一二两三四五六七八九十]{1,3})\s*(?:点|时)\s*(?:([零〇一二两三四五六七八九十]{1,3})\s*分?)?/; + const cnMatch = text.match(cnPattern); + if (cnMatch) { + const hour = parseChineseNumber(cnMatch[2]); + const minute = cnMatch[3] ? parseChineseNumber(cnMatch[3]) : 0; + if (hour != null) { + const parsed = parseHour(text, hour, minute ?? 0); + if (parsed) return parsed; + } + } + + const patterns = [ + /(上午|早上|中午|下午|晚上|傍晚|凌晨)?\s*(\d{1,2})\s*(?:[::点时])\s*(\d{1,2})?\s*(?:分)?/, + /(\d{1,2})\s*[::]\s*(\d{2})/, + ]; + for (const re of patterns) { + const m = text.match(re); + if (!m) continue; + const parsed = parseHour(text, m[2] ?? m[1], m[3] ?? m[2]); + if (parsed) return parsed; + } + return null; +} + +function hasFutureIntent(text) { + return /明天|明日|后天|大后天|下周|周一|周二|周三|周四|周五|周六|周日|星期|安排|预约|会议|截止|之前|前要/.test( + text, + ); +} + +/** + * 从中文文本抽取计划/事件发生时间(相对 observed_time 锚点)。 + * + * @param {string} text + * @param {string | Date} observedAt + * @returns {{ event_time: string | null, confidence: number, status: 'planned' | 'mentioned' | 'unknown' }} + */ +export function extractEventTime(text, observedAt) { + const raw = String(text ?? '').trim(); + if (!raw) { + return { event_time: null, confidence: 0, status: 'unknown' }; + } + + const anchor = observedAt instanceof Date ? observedAt : new Date(observedAt); + if (Number.isNaN(anchor.getTime())) { + return { event_time: null, confidence: 0, status: 'unknown' }; + } + + let baseDay = null; + let dayConfidence = 0; + + const dayOffset = resolveDayOffset(raw); + if (dayOffset != null) { + baseDay = addDays(anchor, dayOffset); + dayConfidence = 0.88; + } + + if (!baseDay) { + baseDay = resolveWeekday(raw, anchor); + if (baseDay) dayConfidence = 0.75; + } + + if (!baseDay) { + baseDay = resolveMonthDay(raw, anchor); + if (baseDay) dayConfidence = 0.8; + } + + const timePart = findTimeInText(raw); + if (baseDay && timePart) { + const p = toLocalParts(baseDay); + const dt = partsToDate(p, timePart.hour, timePart.minute); + return { + event_time: dt.toISOString(), + confidence: Math.min(0.95, dayConfidence + 0.1), + status: 'planned', + }; + } + + if (baseDay) { + const p = toLocalParts(baseDay); + return { + event_time: partsToDate(p, 9, 0).toISOString(), + confidence: dayConfidence * 0.85, + status: 'planned', + }; + } + + if (timePart && /今天|今日/.test(raw)) { + const p = toLocalParts(anchor); + return { + event_time: partsToDate(p, timePart.hour, timePart.minute).toISOString(), + confidence: 0.82, + status: 'planned', + }; + } + + if (hasFutureIntent(raw)) { + return { event_time: null, confidence: 0.35, status: 'mentioned' }; + } + + return { event_time: null, confidence: 0, status: 'unknown' }; +} + +/** + * 判断 timeline item 是否与查询时间窗相关(observed 或 event 命中)。 + */ +export function itemMatchesTimeWindow(item, time, temporalMode) { + const mentionStart = new Date(time?.mention_range?.start ?? 0).getTime(); + const mentionEnd = new Date(time?.mention_range?.end ?? 0).getTime(); + const eventStart = time?.event_range?.start + ? new Date(time.event_range.start).getTime() + : mentionStart; + const eventEnd = time?.event_range?.end + ? new Date(time.event_range.end).getTime() + : mentionEnd; + const observed = new Date(item.observed_time).getTime(); + const event = item.event_time ? new Date(item.event_time).getTime() : null; + + const inMention = observed >= mentionStart && observed < mentionEnd; + const inEvent = event != null && event >= eventStart && event < eventEnd; + + switch (temporalMode) { + case 'OCCURRED_IN': + return inEvent || (!event && inMention); + case 'MENTIONED_IN': + case 'CREATED_IN': + return inMention; + case 'PLANNED_IN': + case 'DUE_IN': + return inEvent || inMention; + default: + return inMention || inEvent; + } +} + +/** + * 为 DB 检索扩展 observed 窗口(捕获「昨天说明天」类输入)。 + */ +export function expandObservedFetchRange(time, temporalMode) { + const start = new Date(time?.mention_range?.start ?? Date.now()); + const end = new Date(time?.mention_range?.end ?? Date.now()); + if (['PLANNED_IN', 'OCCURRED_IN', 'AMBIGUOUS', 'DUE_IN'].includes(temporalMode ?? '')) { + start.setDate(start.getDate() - 14); + } + return { start: start.toISOString(), end: end.toISOString() }; +} diff --git a/temporal-recall-service/event-time-extract.test.mjs b/temporal-recall-service/event-time-extract.test.mjs new file mode 100644 index 0000000..10ec7c9 --- /dev/null +++ b/temporal-recall-service/event-time-extract.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + extractEventTime, + itemMatchesTimeWindow, +} from './event-time-extract.mjs'; + +test('extractEventTime parses 明天下午三点', () => { + const observed = new Date('2026-09-02T12:30:00+08:00'); + const result = extractEventTime('明天下午三点去签合同', observed); + assert.equal(result.status, 'planned'); + const hourLocal = new Date(result.event_time).getTime(); + const expected = new Date('2026-09-03T07:00:00.000Z').getTime(); + assert.equal(hourLocal, expected); +}); + +test('extractEventTime parses weekday', () => { + const observed = new Date('2026-09-03T10:00:00+08:00'); // Wed + const result = extractEventTime('下周五开会', observed); + assert.ok(result.event_time); + assert.equal(result.status, 'planned'); +}); + +test('itemMatchesTimeWindow separates mention vs event', () => { + const time = { + mention_range: { + start: '2026-09-02T00:00:00+08:00', + end: '2026-09-03T00:00:00+08:00', + }, + event_range: { + start: '2026-09-03T00:00:00+08:00', + end: '2026-09-04T00:00:00+08:00', + }, + }; + const item = { + observed_time: '2026-09-02T20:00:00+08:00', + event_time: '2026-09-03T15:00:00+08:00', + }; + assert.equal(itemMatchesTimeWindow(item, time, 'MENTIONED_IN'), true); + assert.equal(itemMatchesTimeWindow(item, time, 'PLANNED_IN'), true); +}); diff --git a/temporal-recall-service/keyword-rules.mjs b/temporal-recall-service/keyword-rules.mjs new file mode 100644 index 0000000..a63abb0 --- /dev/null +++ b/temporal-recall-service/keyword-rules.mjs @@ -0,0 +1,151 @@ +const CALENDAR_KEYWORDS = [ + '行程', '会议', '约会', '日历', '几点', '安排', '预约', '档期', '出发', '航班', '火车', +]; +const MEINPUT_KEYWORDS = [ + '输入', '打字', '说过', '原话', '上屏', '写过', '输入过', '发了', '微信', '消息', +]; +const CHAT_KEYWORDS = [ + '聊天', '对话', '讨论', '聊过', '问过', '你记得', '我们说过', '上次聊', 'agent', '助手', +]; +const MEMORY_KEYWORDS = [ + '决定', '定了', '之前定的', '记得', '记住', '上次', '当时', '承诺', '约定', +]; +const COMMITMENT_KEYWORDS = [ + '待办', '要做', '记得', '别忘了', '跟进', '确认', '截止', '交付', '完成', '处理', +]; +const IMPORTANCE_KEYWORDS = ['重要', '关键', '紧急', '必须', '大事', '要紧']; +const SCHEDULE_QUESTION = /有什么|哪些|什么事|干嘛|做了什么|发生/; +const TEMPORAL_QUESTION = /什么时候|何时|几点|哪天/; + +/** + * @param {string} query + */ +export function analyzeKeywords(query) { + const text = String(query ?? ''); + const rule_hits = []; + const sources = { + calendar: 0.35, + chat: 0.55, + meinput: 0.55, + memory_v2: 0.35, + }; + + for (const kw of CALENDAR_KEYWORDS) { + if (text.includes(kw)) { + sources.calendar = Math.min(1, sources.calendar + 0.15); + rule_hits.push(`keyword:calendar:${kw}`); + } + } + for (const kw of MEINPUT_KEYWORDS) { + if (text.includes(kw)) { + sources.meinput = Math.min(1, sources.meinput + 0.12); + rule_hits.push(`keyword:meinput:${kw}`); + } + } + for (const kw of CHAT_KEYWORDS) { + if (text.includes(kw)) { + sources.chat = Math.min(1, sources.chat + 0.12); + rule_hits.push(`keyword:chat:${kw}`); + } + } + for (const kw of MEMORY_KEYWORDS) { + if (text.includes(kw)) { + sources.memory_v2 = Math.min(1, sources.memory_v2 + 0.12); + rule_hits.push(`keyword:memory:${kw}`); + } + } + + if (SCHEDULE_QUESTION.test(text)) { + sources.calendar += 0.1; + sources.chat += 0.08; + sources.meinput += 0.08; + rule_hits.push('pattern:schedule_question'); + } + + const importance_min = IMPORTANCE_KEYWORDS.some((kw) => text.includes(kw)) ? 0.55 : 0.35; + if (importance_min > 0.35) rule_hits.push('filter:importance'); + + return { sources, rule_hits, importance_min, commitmentKeywords: COMMITMENT_KEYWORDS }; +} + +/** + * @param {string} query + * @param {{ sources: Record, importance_min: number, commitmentKeywords: string[] }} kw + */ +export function inferTargets(query, kw) { + const text = String(query ?? ''); + const targets = []; + + if (/行程|会议|约会|日历|安排/.test(text)) { + targets.push({ type: 'calendar_event', weight: 0.9 }, { type: 'schedule', weight: 0.85 }); + } + if (/待办|要做|完成|跟进|截止/.test(text)) { + targets.push({ type: 'todo', weight: 0.95 }); + } + if (/决定|定了|承诺|约定/.test(text)) { + targets.push({ type: 'commitment', weight: 0.95 }, { type: 'decision', weight: 0.85 }); + } + if (/重要|关键|紧急/.test(text)) { + targets.push({ type: 'important_event', weight: 0.9 }); + } + if (!targets.length || /什么事|做了什么|发生|输入|说过/.test(text)) { + targets.push({ type: 'mention', weight: 0.75 }, { type: 'event', weight: 0.7 }); + } + + const seen = new Set(); + return targets.filter((t) => { + if (seen.has(t.type)) return false; + seen.add(t.type); + return true; + }); +} + +/** + * @param {string} query + */ +export function inferTemporalMode(query) { + const text = String(query ?? ''); + if (/说过|提到|安排|定了|计划|要做|记得|别忘了/.test(text) && /昨天|前天|上周|这周/.test(text)) { + if (/做了什么|发生了什么|干嘛了/.test(text)) return 'AMBIGUOUS'; + if (/说过|提到|定了|安排/.test(text)) return 'MENTIONED_IN'; + } + if (/有什么安排|行程|会议|几点|今天|明天/.test(text)) return 'PLANNED_IN'; + if (/定了哪些|创建|记录/.test(text)) return 'CREATED_IN'; + if (/截止|due|要交|到期/.test(text)) return 'DUE_IN'; + if (/做了什么|发生了什么|干嘛|什么事/.test(text)) return 'OCCURRED_IN'; + if (/重要|什么事/.test(text)) return 'AMBIGUOUS'; + return 'AMBIGUOUS'; +} + +/** + * @param {string} query + */ +export function isTemporalRecallQuery(query) { + const text = String(query ?? '').trim(); + if (!text) return false; + if (TEMPORAL_QUESTION.test(text)) return true; + if (/今天|昨天|前天|明天|这周|上周|本月|最近|近期|这几天/.test(text)) return true; + if (/做了什么|发生了什么|什么事|有什么|哪些|安排|行程|输入过|说过/.test(text)) return true; + return false; +} + +export function expandQueries(source, query, commitmentKeywords) { + const base = []; + const text = String(query ?? ''); + if (source === 'meinput') { + base.push('会议', '安排', '明天', '今天', '项目', '确认', '跟进', '截止', '记得'); + } else if (source === 'chat') { + base.push('约', '安排', '明天', '会议', '确认', '跟进', '截止', '记得', '待办'); + } else if (source === 'memory_v2') { + base.push('commitment', 'decision', 'task', 'todo'); + } else if (source === 'calendar') { + base.push('会议', '预约', '行程'); + } + for (const kw of commitmentKeywords) { + if (text.includes(kw) && !base.includes(kw)) base.push(kw); + } + for (const kw of IMPORTANCE_KEYWORDS) { + if (text.includes(kw) && !base.includes(kw)) base.push(kw); + } + return base.slice(0, 12); +} diff --git a/temporal-recall-service/rank.mjs b/temporal-recall-service/rank.mjs new file mode 100644 index 0000000..05e7e72 --- /dev/null +++ b/temporal-recall-service/rank.mjs @@ -0,0 +1,91 @@ +const SOURCE_QUALITY = { + calendar: 0.95, + chat: 0.85, + meinput: 0.82, + memory_v2: 0.78, +}; + +function parseMs(value) { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? null : ms; +} + +function temporalMatch(item, plan) { + const mode = plan.temporal_mode ?? 'AMBIGUOUS'; + const mentionStart = parseMs(plan.time?.mention_range?.start); + const mentionEnd = parseMs(plan.time?.mention_range?.end); + const eventStart = parseMs(plan.time?.event_range?.start); + const eventEnd = parseMs(plan.time?.event_range?.end); + const observed = parseMs(item.observed_time); + const event = parseMs(item.event_time); + + const inMention = + observed !== null && mentionStart !== null && mentionEnd !== null + ? observed >= mentionStart && observed < mentionEnd + : 0.5; + const inEvent = + event !== null && eventStart !== null && eventEnd !== null + ? event >= eventStart && event < eventEnd + : inMention; + + switch (mode) { + case 'OCCURRED_IN': + return event !== null ? (inEvent ? 1 : 0.2) : inMention * 0.85; + case 'MENTIONED_IN': + case 'CREATED_IN': + return inMention ? 1 : 0.25; + case 'PLANNED_IN': + case 'DUE_IN': + return event !== null ? (inEvent ? 1 : 0.3) : inMention * 0.7; + default: + return Math.max(inMention, inEvent * 0.9); + } +} + +function semanticMatch(item, expandedQueries = []) { + if (!expandedQueries?.length) return 0.75; + const text = `${item.title ?? ''} ${item.content ?? ''}`; + let hits = 0; + for (const q of expandedQueries) { + if (q && text.includes(q)) hits += 1; + } + return Math.min(1, 0.45 + hits * 0.12); +} + +/** + * @param {object} item + * @param {object} plan + * @param {string[]} expandedQueries + */ +export function computeRecallScore(item, plan, expandedQueries = []) { + const source_quality = SOURCE_QUALITY[item.source] ?? 0.7; + const temporal_match = temporalMatch(item, plan); + const semantic_match = semanticMatch(item, expandedQueries); + const importance = Number(item.importance ?? 0.5); + const confidence = Number(item.confidence ?? 0.8); + const recall_score = + source_quality * temporal_match * semantic_match * importance * confidence; + return Number(Math.min(1, recall_score).toFixed(4)); +} + +/** + * @param {object[]} items + * @param {object} plan + */ +export function rankTimelineItems(items, plan) { + const retrievalQueries = Object.fromEntries( + (plan.retrievals ?? []).map((r) => [r.source, r.expanded_queries ?? []]), + ); + return items + .map((item) => ({ + ...item, + recall_score: computeRecallScore( + item, + plan, + retrievalQueries[item.source] ?? [], + ), + })) + .filter((item) => item.recall_score >= (plan.filters?.importance_min ?? 0.35) * 0.55) + .sort((a, b) => b.recall_score - a.recall_score); +} diff --git a/temporal-recall-service/recall.mjs b/temporal-recall-service/recall.mjs new file mode 100644 index 0000000..c55391d --- /dev/null +++ b/temporal-recall-service/recall.mjs @@ -0,0 +1,125 @@ +import { searchCalendar } from './adapters/calendar.mjs'; +import { searchChat } from './adapters/chat.mjs'; +import { searchMeinput } from './adapters/meinput.mjs'; +import { buildContextPlan } from './context-planner.mjs'; +import { dedupeTimelineItems } from './dedupe.mjs'; +import { rankTimelineItems } from './rank.mjs'; + +const SOURCE_HANDLERS = { + calendar: searchCalendar, + meinput: searchMeinput, + chat: searchChat, +}; + +/** + * @param {import('mysql2/promise').Pool | null} pool + * @param {{ plan: object, userId: string, sessionId?: string, limit?: number }} opts + */ +export async function executeTemporalRecall(pool, opts) { + const started = Date.now(); + const plan = opts.plan; + const userId = opts.userId; + const limit = Math.min(200, Math.max(1, Number(opts.limit ?? plan.output?.max_items ?? 50))); + const skipBelow = 0.3; + + const sourcesQueried = []; + const tasks = (plan.retrievals ?? []) + .filter((r) => (r.weight ?? 0) >= (r.skip_below ?? skipBelow)) + .map(async (retrieval) => { + const handler = SOURCE_HANDLERS[retrieval.source]; + if (!handler) return []; + sourcesQueried.push(retrieval.source); + const ctx = { + userId, + pool, + retrieval, + time: plan.time, + temporalMode: plan.temporal_mode, + sessionId: opts.sessionId, + }; + if (retrieval.source === 'chat') return searchChat(pool, ctx); + return handler(ctx); + }); + + const batches = await Promise.all(tasks); + const raw = batches.flat(); + const ranked = rankTimelineItems(raw, plan); + const deduped = plan.output?.dedupe !== false ? dedupeTimelineItems(ranked) : ranked; + const returned = deduped.slice(0, limit); + + let groups = null; + if (plan.temporal_mode === 'AMBIGUOUS' && plan.output?.timeline) { + const mentionStart = new Date(plan.time.mention_range.start).getTime(); + const mentionEnd = new Date(plan.time.mention_range.end).getTime(); + const occurred = []; + const mentioned = []; + for (const item of returned) { + const obs = new Date(item.observed_time).getTime(); + if (item.event_time) { + const ev = new Date(item.event_time).getTime(); + if (ev >= mentionStart && ev < mentionEnd) occurred.push(item); + else mentioned.push(item); + } else if (obs >= mentionStart && obs < mentionEnd) { + mentioned.push(item); + } else { + occurred.push(item); + } + } + groups = [ + { label: 'occurred_in_range', items: occurred }, + { label: 'mentioned_or_planned', items: mentioned }, + ]; + } + + return { + query_type: plan.query_type, + temporal_mode: plan.temporal_mode, + time_range: plan.time.mention_range, + groups, + items: groups ? [] : returned, + stats: { + sources_queried: sourcesQueried, + raw_count: raw.length, + deduped_count: deduped.length, + returned_count: returned.length, + elapsed_ms: Date.now() - started, + }, + plan, + }; +} + +/** + * @param {import('mysql2/promise').Pool | null} pool + * @param {{ query?: string, plan?: object, user_id: string, now?: Date, session_id?: string, limit?: number }} input + */ +export async function queryTemporalRecall(pool, input) { + const userId = input.user_id; + const plan = + input.plan ?? + buildContextPlan({ + query: input.query ?? '', + user_id: userId, + now: input.now, + }); + + if (plan.context_needs?.temporal_recall === 'SKIP') { + return { + query_type: plan.query_type, + temporal_mode: plan.temporal_mode, + time_range: plan.time?.mention_range ?? null, + groups: null, + items: [], + stats: { sources_queried: [], raw_count: 0, deduped_count: 0, returned_count: 0, elapsed_ms: 0 }, + plan, + }; + } + + return executeTemporalRecall(pool, { + plan, + userId, + sessionId: input.session_id, + limit: input.limit, + }); +} + +export { buildContextPlan }; diff --git a/temporal-recall-service/recall.test.mjs b/temporal-recall-service/recall.test.mjs new file mode 100644 index 0000000..ebd8580 --- /dev/null +++ b/temporal-recall-service/recall.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildContextPlan } from '../temporal-recall-service/context-planner.mjs'; +import { parseTimeScope } from '../temporal-recall-service/time-parser.mjs'; +import { dedupeTimelineItems } from '../temporal-recall-service/dedupe.mjs'; +import { formatTemporalRecallBlock } from '../temporal-recall-service/runtime-context.mjs'; + +test('parseTimeScope resolves yesterday', () => { + const now = new Date('2026-09-03T12:00:00+08:00'); + const scope = parseTimeScope('我昨天有什么重要的事', now); + assert.equal(scope.relative_label, 'yesterday'); + const startLocal = new Date(scope.mention_range.start); + assert.ok(startLocal.getTime() < now.getTime()); + assert.ok(new Date(scope.mention_range.end).getTime() <= now.getTime()); +}); + +test('buildContextPlan includes calendar for schedule questions', () => { + const plan = buildContextPlan({ + query: '我今天有什么行程安排?', + user_id: 'user-1', + now: new Date('2026-09-03T12:00:00+08:00'), + }); + assert.ok(plan.sources.calendar >= 0.5); + assert.ok(plan.retrievals.some((r) => r.source === 'calendar')); +}); + +test('buildContextPlan produces multi-source retrievals', () => { + const plan = buildContextPlan({ + query: '我昨天有什么重要的事情安排吗?', + user_id: 'user-1', + now: new Date('2026-09-03T12:00:00+08:00'), + }); + assert.equal(plan.query_type, 'personal_temporal_recall'); + assert.equal(plan.context_needs.temporal_recall, 'REQUIRED'); + assert.ok(plan.retrievals.length >= 2); + assert.ok(plan.sources.meinput > 0.5); + assert.ok(plan.sources.chat > 0.5); +}); + +test('dedupe merges similar items', () => { + const items = dedupeTimelineItems([ + { + timeline_item_id: 'a', + title: '明天下午三点签合同', + content: '明天下午三点去签合同', + observed_time: '2026-09-02T10:00:00+08:00', + event_time: null, + source_ref: 'meinput:1', + recall_score: 0.8, + importance: 0.8, + confidence: 0.9, + }, + { + timeline_item_id: 'b', + title: '明天下午三点去签合同', + content: '明天下午三点去跟张总签合同', + observed_time: '2026-09-02T10:05:00+08:00', + event_time: null, + source_ref: 'chat:1', + recall_score: 0.75, + importance: 0.75, + confidence: 0.85, + }, + ]); + assert.equal(items.length, 1); + assert.equal(items[0].merged_from?.length, 2); +}); + +test('formatTemporalRecallBlock renders grouped items', () => { + const block = formatTemporalRecallBlock({ + groups: [ + { + label: 'mentioned_or_planned', + items: [ + { + source: 'meinput', + observed_time: '2026-09-02T10:00:00+08:00', + title: '明天下午三点签合同', + recall_score: 0.81, + }, + ], + }, + ], + }); + assert.match(block, /时间范围回忆/); + assert.match(block, /meinput/); + assert.match(block, /签合同/); +}); diff --git a/temporal-recall-service/runtime-context.mjs b/temporal-recall-service/runtime-context.mjs new file mode 100644 index 0000000..1cf2902 --- /dev/null +++ b/temporal-recall-service/runtime-context.mjs @@ -0,0 +1,177 @@ +import { getActiveSnapshot } from '../user-model-service/snapshot.mjs'; +import { createUmsPool, isUmsDatabaseConfigured } from '../user-model-service/db.mjs'; +import { resolveCanonicalUserId } from '../user-model-service/canonical-user.mjs'; +import { buildContextPlan } from './context-planner.mjs'; +import { queryTemporalRecall } from './recall.mjs'; + +function envEnabled(name, fallback = false) { + const raw = String(process.env[name] ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +let lazyUmsPool = null; + +function resolveUmsPool(getUmsPool) { + if (typeof getUmsPool === 'function') { + const pool = getUmsPool(); + if (pool) return pool; + } + if (!isUmsDatabaseConfigured()) return null; + if (!lazyUmsPool) lazyUmsPool = createUmsPool(); + return lazyUmsPool; +} + +function extractQueryText(query) { + return String(query ?? '').trim(); +} + +function formatTimelineItem(item) { + const observed = item.observed_time ?? ''; + const event = item.event_time ?? ''; + const showEvent = event && event !== observed; + const when = showEvent ? event : observed; + const day = when ? when.slice(0, 16).replace('T', ' ') : ''; + const source = item.source ?? 'unknown'; + const title = String(item.title ?? item.content ?? '').trim().slice(0, 120); + const score = item.recall_score != null ? ` (${item.recall_score})` : ''; + const mentionNote = + showEvent && observed + ? ` [提及于 ${observed.slice(0, 16).replace('T', ' ')}]` + : ''; + return `- [${source}] ${day} ${title}${mentionNote}${score}`.trim(); +} + +/** + * @param {object} recallResult + */ +export function formatTemporalRecallBlock(recallResult) { + if (!recallResult) return ''; + const lines = ['【时间范围回忆】', '以下内容来自用户在该时间范围内的输入与对话线索,仅作事实参考;不要主动暴露数据来源。']; + + if (recallResult.groups?.length) { + for (const group of recallResult.groups) { + if (!group.items?.length) continue; + const label = + group.label === 'occurred_in_range' + ? '实际发生:' + : group.label === 'mentioned_or_planned' + ? '提到或安排:' + : `${group.label}:`; + lines.push('', label); + for (const item of group.items.slice(0, 12)) { + lines.push(formatTimelineItem(item)); + } + } + } else if (recallResult.items?.length) { + lines.push(''); + for (const item of recallResult.items.slice(0, 15)) { + lines.push(formatTimelineItem(item)); + } + } else { + return ''; + } + + return lines.join('\n').trim(); +} + +/** + * @param {object | null} snapshot + */ +export function formatUserSnapshotBlock(snapshot) { + if (!snapshot?.core) return ''; + const lines = ['【用户快照】', '以下为慢变用户画像摘要,用于理解关注点,不是执行指令。']; + const projects = snapshot.core.active_projects ?? []; + const focus = snapshot.core.recent_focus ?? []; + const hints = snapshot.core.agent_hints ?? []; + if (projects.length) { + lines.push(`近期项目:${projects.map((p) => p.name).filter(Boolean).slice(0, 5).join('、')}`); + } + if (focus.length) { + lines.push(`关注话题:${focus.map((f) => f.topic).filter(Boolean).slice(0, 5).join('、')}`); + } + for (const hint of hints.slice(0, 3)) { + if (hint) lines.push(String(hint)); + } + if (lines.length <= 2) return ''; + return lines.join('\n'); +} + +/** + * @param {{ + * pool?: import('mysql2/promise').Pool | null, + * getUmsPool?: () => import('mysql2/promise').Pool | null, + * userId: string, + * query: string, + * sessionId?: string | null, + * now?: Date, + * }} input + */ +export async function resolveRuntimeContext(input) { + if (!envEnabled('MEMIND_RUNTIME_CONTEXT_ENABLED', true)) { + return { enabled: false, plan: null, injectionEnabled: false, blocks: {} }; + } + + const query = extractQueryText(input.query); + if (!query || !input.userId) { + return { enabled: true, plan: null, injectionEnabled: false, blocks: {} }; + } + + const userId = resolveCanonicalUserId(input.userId); + + const plan = buildContextPlan({ + query, + user_id: userId, + now: input.now ?? new Date(), + }); + + const needs = plan.context_needs ?? {}; + const blocks = {}; + let temporalRecall = null; + let userSnapshot = null; + + if (needs.temporal_recall === 'REQUIRED' || needs.temporal_recall === 'OPTIONAL') { + try { + temporalRecall = await queryTemporalRecall(input.pool ?? null, { + plan, + user_id: userId, + session_id: input.sessionId ?? undefined, + limit: plan.output?.max_items ?? 20, + }); + const block = formatTemporalRecallBlock(temporalRecall); + if (block) blocks.temporal = block; + } catch (err) { + console.warn( + '[RuntimeContext] temporal recall skipped:', + err instanceof Error ? err.message : err, + ); + } + } + + if (needs.user_snapshot === 'REQUIRED' || needs.user_snapshot === 'OPTIONAL') { + const umsPool = resolveUmsPool(input.getUmsPool); + if (umsPool) { + try { + userSnapshot = await getActiveSnapshot(umsPool, userId, 'default'); + const block = formatUserSnapshotBlock(userSnapshot); + if (block) blocks.snapshot = block; + } catch (err) { + console.warn( + '[RuntimeContext] user snapshot skipped:', + err instanceof Error ? err.message : err, + ); + } + } + } + + const injectionEnabled = Boolean(blocks.temporal || blocks.snapshot); + return { + enabled: true, + plan, + temporalRecall, + userSnapshot, + injectionEnabled, + blocks, + injectionText: [blocks.snapshot, blocks.temporal].filter(Boolean).join('\n\n'), + }; +} diff --git a/temporal-recall-service/time-parser.mjs b/temporal-recall-service/time-parser.mjs new file mode 100644 index 0000000..b998f5d --- /dev/null +++ b/temporal-recall-service/time-parser.mjs @@ -0,0 +1,158 @@ +const TZ_OFFSET_MIN = Number(process.env.TEMPORAL_RECALL_TZ_OFFSET_MIN ?? 480); + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +/** @param {Date} d */ +function toLocalParts(d) { + const shifted = new Date(d.getTime() + TZ_OFFSET_MIN * 60_000); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth(), + day: shifted.getUTCDate(), + dow: shifted.getUTCDay(), + hour: shifted.getUTCHours(), + }; +} + +/** @param {{ year: number, month: number, day: number }} p @param {number} h @param {number} m */ +function partsToIso(p, h = 0, m = 0) { + const utcMs = + Date.UTC(p.year, p.month, p.day, h, m, 0, 0) - TZ_OFFSET_MIN * 60_000; + return new Date(utcMs).toISOString(); +} + +/** @param {Date} anchor */ +function startOfDay(anchor) { + const p = toLocalParts(anchor); + return new Date(partsToIso(p, 0, 0)); +} + +/** @param {Date} anchor */ +function endOfDay(anchor) { + const p = toLocalParts(anchor); + const start = Date.UTC(p.year, p.month, p.day + 1, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000; + return new Date(start); +} + +/** @param {Date} anchor @param {number} deltaDays */ +function addDays(anchor, deltaDays) { + const p = toLocalParts(anchor); + const ms = Date.UTC(p.year, p.month, p.day + deltaDays, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000; + return new Date(ms); +} + +/** @param {Date} anchor */ +function startOfWeek(anchor) { + const p = toLocalParts(anchor); + const mondayOffset = p.dow === 0 ? -6 : 1 - p.dow; + const ms = + Date.UTC(p.year, p.month, p.day + mondayOffset, 0, 0, 0, 0) - + TZ_OFFSET_MIN * 60_000; + return new Date(ms); +} + +/** @param {Date} anchor */ +function endOfWeek(anchor) { + const start = startOfWeek(anchor); + return addDays(start, 7); +} + +/** @param {Date} anchor */ +function startOfMonth(anchor) { + const p = toLocalParts(anchor); + return new Date(Date.UTC(p.year, p.month, 1, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000); +} + +/** @param {Date} anchor */ +function endOfMonth(anchor) { + const p = toLocalParts(anchor); + return new Date(Date.UTC(p.year, p.month + 1, 1, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000); +} + +/** + * @param {string} query + * @param {Date} [now] + * @returns {{ mention_range: { start: string, end: string }, event_range: { start: string, end: string } | null, relative_label: string | null, rule_hits: string[] }} + */ +export function parseTimeScope(query, now = new Date()) { + const text = String(query ?? ''); + const rule_hits = []; + let relative_label = null; + let rangeStart = null; + let rangeEnd = null; + + const patterns = [ + { re: /前天/, label: 'day_before_yesterday', apply: () => { + const d = addDays(now, -2); + return [startOfDay(d), endOfDay(d)]; + }}, + { re: /昨天|昨日/, label: 'yesterday', apply: () => { + const d = addDays(now, -1); + return [startOfDay(d), endOfDay(d)]; + }}, + { re: /今天|今日/, label: 'today', apply: () => [startOfDay(now), endOfDay(now)] }, + { re: /明天|明日/, label: 'tomorrow', apply: () => { + const d = addDays(now, 1); + return [startOfDay(d), endOfDay(d)]; + }}, + { re: /后天/, label: 'day_after_tomorrow', apply: () => { + const d = addDays(now, 2); + return [startOfDay(d), endOfDay(d)]; + }}, + { re: /上周|上星期|上个星期/, label: 'last_week', apply: () => { + const thisWeek = startOfWeek(now); + const lastStart = addDays(thisWeek, -7); + return [lastStart, thisWeek]; + }}, + { re: /这周|本周|这个星期|这星期/, label: 'this_week', apply: () => [startOfWeek(now), endOfWeek(now)] }, + { re: /上个月/, label: 'last_month', apply: () => { + const thisMonth = startOfMonth(now); + const p = toLocalParts(thisMonth); + const lastStart = new Date(Date.UTC(p.year, p.month - 1, 1, 0, 0, 0, 0) - TZ_OFFSET_MIN * 60_000); + return [lastStart, thisMonth]; + }}, + { re: /这个月|本月/, label: 'this_month', apply: () => [startOfMonth(now), endOfMonth(now)] }, + { re: /最近(\d+)天/, label: 'recent_days', apply: (m) => { + const days = Number(m[1]); + return [addDays(now, -days), endOfDay(now)]; + }}, + { re: /最近一周|最近1周/, label: 'recent_week', apply: () => [addDays(now, -7), endOfDay(now)] }, + ]; + + for (const { re, label, apply } of patterns) { + const m = text.match(re); + if (m) { + [rangeStart, rangeEnd] = apply(m); + relative_label = label; + rule_hits.push(`time:${label}`); + break; + } + } + + if (!rangeStart) { + if (/最近|近期|这几天/.test(text)) { + [rangeStart, rangeEnd] = [addDays(now, -7), endOfDay(now)]; + relative_label = 'recent_week'; + rule_hits.push('time:recent_fuzzy'); + } else { + [rangeStart, rangeEnd] = [addDays(now, -7), endOfDay(now)]; + relative_label = 'this_week'; + rule_hits.push('time:default_week'); + } + } + + const mention_range = { + start: rangeStart.toISOString(), + end: rangeEnd.toISOString(), + }; + + let event_range = null; + if (/安排|行程|会议|约会|几点|日历/.test(text)) { + event_range = { ...mention_range }; + rule_hits.push('time:event_range_linked'); + } + + return { mention_range, event_range, relative_label, rule_hits }; +} diff --git a/user-model-service/candidates.mjs b/user-model-service/candidates.mjs new file mode 100644 index 0000000..7bc40cd --- /dev/null +++ b/user-model-service/candidates.mjs @@ -0,0 +1,168 @@ +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); +} + +/** + * 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 < 5) continue; + if (term.length < 2) continue; + + const isProjectLike = /^[A-Z][a-zA-Z0-9]+$/.test(term) || term.includes('Input') || term.includes('Mind'); + 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 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, + ], + ); + } +} diff --git a/user-model-service/canonical-user.mjs b/user-model-service/canonical-user.mjs new file mode 100644 index 0000000..5ad4379 --- /dev/null +++ b/user-model-service/canonical-user.mjs @@ -0,0 +1,50 @@ +/** + * Resolve login user_id → canonical user_id for UMS / Temporal Recall / MeInput queries. + * + * Env MEMIND_CANONICAL_USER_MAP: + * from=to,from2=to2 + * + * Default: tang19821002 → 唐 (wx_ul610et8) + */ +const DEFAULT_MAP = new Map([ + [ + 'd0678bbc-2a50-4e08-8bf0-6b6c9301e2d6', + 'a70ff537-8908-486e-9b6c-042e07cc25db', + ], +]); + +let cachedMap = null; + +function parseCanonicalUserMap(raw) { + const map = new Map(DEFAULT_MAP); + const text = String(raw ?? '').trim(); + if (!text) return map; + for (const part of text.split(',')) { + const pair = part.trim(); + if (!pair) continue; + const eq = pair.indexOf('='); + if (eq <= 0) continue; + const from = pair.slice(0, eq).trim(); + const to = pair.slice(eq + 1).trim(); + if (from && to) map.set(from, to); + } + return map; +} + +function canonicalMap() { + if (!cachedMap) { + cachedMap = parseCanonicalUserMap(process.env.MEMIND_CANONICAL_USER_MAP); + } + return cachedMap; +} + +/** @param {string | null | undefined} userId */ +export function resolveCanonicalUserId(userId) { + const id = String(userId ?? '').trim(); + if (!id) return id; + return canonicalMap().get(id) ?? id; +} + +export function resetCanonicalUserMapCache() { + cachedMap = null; +} diff --git a/user-model-service/canonical-user.test.mjs b/user-model-service/canonical-user.test.mjs new file mode 100644 index 0000000..f8a6d76 --- /dev/null +++ b/user-model-service/canonical-user.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + resolveCanonicalUserId, + resetCanonicalUserMapCache, +} from './canonical-user.mjs'; + +test('resolveCanonicalUserId maps tang19821002 to wx 唐 by default', () => { + resetCanonicalUserMapCache(); + assert.equal( + resolveCanonicalUserId('d0678bbc-2a50-4e08-8bf0-6b6c9301e2d6'), + 'a70ff537-8908-486e-9b6c-042e07cc25db', + ); + assert.equal( + resolveCanonicalUserId('a70ff537-8908-486e-9b6c-042e07cc25db'), + 'a70ff537-8908-486e-9b6c-042e07cc25db', + ); +}); + +test('resolveCanonicalUserId respects MEMIND_CANONICAL_USER_MAP env', () => { + process.env.MEMIND_CANONICAL_USER_MAP = 'user-a=user-b'; + resetCanonicalUserMapCache(); + assert.equal(resolveCanonicalUserId('user-a'), 'user-b'); + delete process.env.MEMIND_CANONICAL_USER_MAP; + resetCanonicalUserMapCache(); +}); diff --git a/user-model-service/db.mjs b/user-model-service/db.mjs new file mode 100644 index 0000000..bda7a26 --- /dev/null +++ b/user-model-service/db.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function isUmsDatabaseConfigured() { + return Boolean( + process.env.UMS_DATABASE_URL || + (process.env.UMS_MYSQL_HOST && process.env.UMS_MYSQL_DATABASE), + ); +} + +function poolOptions() { + return { + waitForConnections: true, + connectionLimit: Math.max(1, Number(process.env.UMS_MYSQL_POOL_SIZE ?? 10)), + queueLimit: 0, + timezone: 'Z', + }; +} + +export function createUmsPool() { + if (!isUmsDatabaseConfigured()) { + throw new Error('UMS MySQL 未配置,请设置 UMS_DATABASE_URL 或 UMS_MYSQL_*'); + } + if (process.env.UMS_DATABASE_URL) { + return mysql.createPool({ uri: process.env.UMS_DATABASE_URL, ...poolOptions() }); + } + return mysql.createPool({ + host: process.env.UMS_MYSQL_HOST ?? 'localhost', + port: Number(process.env.UMS_MYSQL_PORT ?? 3306), + user: process.env.UMS_MYSQL_USER ?? 'boot', + password: process.env.UMS_MYSQL_PASSWORD ?? '', + database: process.env.UMS_MYSQL_DATABASE ?? 'memind_user_model', + ...poolOptions(), + }); +} + +export async function runUmsSchema(pool) { + const schemaPath = path.join(__dirname, '..', 'schemas', 'memind_user_model-v0.sql'); + const sql = fs.readFileSync(schemaPath, 'utf8'); + const statements = sql + .split(';') + .map((statement) => + statement + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n') + .trim(), + ) + .filter(Boolean); + for (const statement of statements) { + await pool.query(statement); + } +} diff --git a/user-model-service/ingest.mjs b/user-model-service/ingest.mjs new file mode 100644 index 0000000..755a66e --- /dev/null +++ b/user-model-service/ingest.mjs @@ -0,0 +1,104 @@ +import crypto from 'node:crypto'; + +function newId() { + return crypto.randomUUID(); +} + +function nowMs() { + return Date.now(); +} + +function validateEnvelope(item) { + const required = [ + 'evidence_id', + 'user_id', + 'source_type', + 'source_ref', + 'occurred_at', + 'evidence_type', + 'payload', + 'content_hash', + 'schema_version', + ]; + for (const key of required) { + if (item[key] === undefined || item[key] === null || item[key] === '') { + return { ok: false, reason: `missing ${key}` }; + } + } + if (item.schema_version !== 1) return { ok: false, reason: 'unsupported schema_version' }; + if (item.privacy_level === 'secure_skip') return { ok: false, reason: 'secure_skip' }; + return { ok: true }; +} + +/** + * @param {import('mysql2/promise').Pool} pool + * @param {{ items: object[], dry_run?: boolean }} input + */ +export async function ingestEvidenceBatch(pool, { items, dry_run = false }) { + const accepted = []; + const duplicates = []; + const rejected = []; + + for (const item of items ?? []) { + const check = validateEnvelope(item); + if (!check.ok) { + rejected.push({ evidence_id: item.evidence_id ?? null, reason: check.reason }); + continue; + } + + const [existing] = await pool.query( + `SELECT evidence_id, content_hash FROM um_evidence WHERE user_id = ? AND content_hash = ? LIMIT 1`, + [item.user_id, item.content_hash], + ); + if (existing[0]) { + duplicates.push(existing[0].evidence_id); + continue; + } + + const [byId] = await pool.query(`SELECT content_hash FROM um_evidence WHERE evidence_id = ? LIMIT 1`, [ + item.evidence_id, + ]); + if (byId[0] && byId[0].content_hash !== item.content_hash) { + rejected.push({ evidence_id: item.evidence_id, reason: 'evidence_conflict' }); + continue; + } + + if (dry_run) { + accepted.push(item.evidence_id); + continue; + } + + await pool.query( + `INSERT INTO um_evidence + (evidence_id, user_id, source_type, source_ref, evidence_type, occurred_at, received_at, + content_hash, schema_version, privacy_level, payload_json) + VALUES (?, ?, ?, ?, ?, ?, NOW(3), ?, ?, ?, ?)`, + [ + item.evidence_id, + item.user_id, + item.source_type, + item.source_ref, + item.evidence_type, + item.occurred_at.replace('T', ' ').replace('Z', '').slice(0, 23), + item.content_hash, + item.schema_version, + item.privacy_level ?? 'normal', + JSON.stringify(item.payload), + ], + ); + accepted.push(item.evidence_id); + } + + return { accepted, duplicates, rejected }; +} + +export async function updateIngestCursor(pool, userId, sourceType, cursorValue) { + await pool.query( + `INSERT INTO um_ingest_cursors (user_id, source_type, cursor_value, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE cursor_value = VALUES(cursor_value), updated_at = VALUES(updated_at)`, + [userId, sourceType, cursorValue, nowMs()], + ); +} + +export { validateEnvelope }; diff --git a/user-model-service/migrate.mjs b/user-model-service/migrate.mjs new file mode 100644 index 0000000..6b089b9 --- /dev/null +++ b/user-model-service/migrate.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { createUmsPool, isUmsDatabaseConfigured, runUmsSchema } from './db.mjs'; + +async function main() { + if (!isUmsDatabaseConfigured()) { + console.error('UMS 未配置。请设置 UMS_DATABASE_URL 或 UMS_MYSQL_*'); + process.exit(1); + } + const pool = createUmsPool(); + try { + await runUmsSchema(pool); + console.log('memind_user_model schema v0 migrated successfully.'); + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/user-model-service/service.mjs b/user-model-service/service.mjs new file mode 100644 index 0000000..5290842 --- /dev/null +++ b/user-model-service/service.mjs @@ -0,0 +1,66 @@ +import { mergeCandidatesFromSignals } from './candidates.mjs'; +import { ingestEvidenceBatch, updateIngestCursor } from './ingest.mjs'; +import { extractSignalsFromEnvelope, upsertSignals } from './signals.mjs'; +import { materializeProfileAndSnapshot } from './snapshot.mjs'; + +/** + * @param {import('mysql2/promise').Pool} pool + * @param {{ items: object[], source_type?: string, dry_run?: boolean }} input + */ +export async function processIngestBatch(pool, input) { + const ingestResult = await ingestEvidenceBatch(pool, input); + if (input.dry_run) { + return { ...ingestResult, signals_computed: 0, candidates_touched: 0, snapshot: null }; + } + + const userIds = new Set(); + for (const id of ingestResult.accepted) { + const [rows] = await pool.query(`SELECT user_id, payload_json, evidence_type, evidence_id, occurred_at, source_type, source_ref, content_hash, schema_version, privacy_level FROM um_evidence WHERE evidence_id = ?`, [id]); + const row = rows[0]; + if (!row) continue; + userIds.add(row.user_id); + const envelope = { + evidence_id: row.evidence_id, + user_id: row.user_id, + source_type: row.source_type, + source_ref: row.source_ref, + occurred_at: row.occurred_at instanceof Date ? row.occurred_at.toISOString() : row.occurred_at, + evidence_type: row.evidence_type, + payload: typeof row.payload_json === 'string' ? JSON.parse(row.payload_json) : row.payload_json, + content_hash: row.content_hash, + schema_version: row.schema_version, + privacy_level: row.privacy_level, + }; + const drafts = extractSignalsFromEnvelope(envelope); + await upsertSignals(pool, row.user_id, drafts); + } + + let signalsComputed = 0; + let candidatesTouched = 0; + let snapshot = null; + + for (const userId of userIds) { + signalsComputed += 1; + candidatesTouched += await mergeCandidatesFromSignals(pool, userId); + snapshot = await materializeProfileAndSnapshot(pool, userId, { reason: 'ingest_batch' }); + } + + const sourceType = input.source_type ?? input.items?.[0]?.source_type ?? 'meinput'; + const lastItem = input.items?.[input.items.length - 1]; + if (lastItem?.occurred_at && userIds.size === 1) { + await updateIngestCursor(pool, [...userIds][0], sourceType, lastItem.occurred_at); + } + + return { + ...ingestResult, + signals_computed: signalsComputed, + candidates_touched: candidatesTouched, + snapshot: snapshot + ? { + profile_version_bumped: true, + fast_revision_bumped: true, + ...snapshot, + } + : { profile_version_bumped: false, fast_revision_bumped: false }, + }; +} diff --git a/user-model-service/session-snapshot.mjs b/user-model-service/session-snapshot.mjs new file mode 100644 index 0000000..b6771bf --- /dev/null +++ b/user-model-service/session-snapshot.mjs @@ -0,0 +1,30 @@ +import { resolveCanonicalUserId } from './canonical-user.mjs'; +import { getActiveSnapshot } from './snapshot.mjs'; + +/** + * Session bootstrap payload for /auth/me (RFC: 5~10KB snapshot at session start). + * + * @param {import('mysql2/promise').Pool | null | undefined} pool + * @param {string} userId + * @param {string} [projection] + */ +export async function loadSessionUserModelSnapshot(pool, userId, projection = 'default') { + if (!pool?.query || !userId) return null; + const canonicalUserId = resolveCanonicalUserId(userId); + const snap = await getActiveSnapshot(pool, canonicalUserId, projection); + if (!snap) return null; + return { + 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, + loaded_at_hint: 'session_start', + core: snap.core, + meta: snap.meta, + ...(canonicalUserId !== userId ? { canonical_user_id: canonicalUserId } : {}), + }; +} diff --git a/user-model-service/signals.mjs b/user-model-service/signals.mjs new file mode 100644 index 0000000..b76658e --- /dev/null +++ b/user-model-service/signals.mjs @@ -0,0 +1,154 @@ +import crypto from 'node:crypto'; + +const STOPWORDS = new Set([ + '的', '了', '在', '是', '我', '你', '他', '她', '它', '我们', '你们', '他们', + '这', '那', '有', '和', '与', '或', '就', '也', '都', '还', '要', '会', '能', + '一个', '什么', '怎么', '可以', '没有', '不是', '如果', '因为', '所以', '但是', + '然后', '已经', '还是', '自己', '现在', '今天', '明天', '这个', '那个', '一下', + 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'have', 'are', 'was', 'not', +]); + +function newId() { + return crypto.randomUUID(); +} + +function nowMs() { + return Date.now(); +} + +function extractTerms(text) { + const terms = []; + const cjk = String(text).match(/[\u4e00-\u9fff]{2,12}/g) ?? []; + terms.push(...cjk); + const en = String(text).match(/[a-zA-Z][a-zA-Z0-9]{2,}/g) ?? []; + terms.push(...en.map((w) => w.toLowerCase())); + return terms.filter((t) => !STOPWORDS.has(t)); +} + +function dayStartUtc(date) { + const d = new Date(date); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); +} + +function dayEndUtc(date) { + const start = dayStartUtc(date); + return new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1); +} + +function hashSignal(userId, signalType, dimensionKey, windowStart, windowEnd, valueJson) { + const raw = `${userId}|${signalType}|${dimensionKey}|${windowStart}|${windowEnd}|${JSON.stringify(valueJson)}`; + return crypto.createHash('sha256').update(raw).digest('hex'); +} + +/** + * @param {object} envelope Evidence Envelope v1 + * @returns {Array} signal drafts + */ +export function extractSignalsFromEnvelope(envelope) { + if (envelope.evidence_type !== 'expression_segment') return []; + const payload = envelope.payload ?? {}; + const text = payload.text ?? ''; + const occurredAt = envelope.occurred_at; + const windowStart = dayStartUtc(occurredAt).toISOString().slice(0, 23).replace('T', ' '); + const windowEnd = dayEndUtc(occurredAt).toISOString().slice(0, 23).replace('T', ' '); + const signals = []; + + for (const term of extractTerms(text)) { + const key = /^[a-z]/.test(term) ? `term:${term}` : `term:${term}`; + signals.push({ + signal_type: 'term_frequency', + dimension_key: key, + window_start: windowStart, + window_end: windowEnd, + value_json: { count: 1, chars: text.length }, + evidence_ids: [envelope.evidence_id], + }); + } + + const appId = payload.context?.app_bundle_id; + if (appId) { + signals.push({ + signal_type: 'app_usage', + dimension_key: `app:${appId}`, + window_start: windowStart, + window_end: windowEnd, + value_json: { count: 1, app_name: payload.context?.app ?? null }, + evidence_ids: [envelope.evidence_id], + }); + } + + const hour = new Date(occurredAt).getUTCHours(); + signals.push({ + signal_type: 'segment_count', + dimension_key: `window:daily:${windowStart.slice(0, 10)}`, + window_start: windowStart, + window_end: windowEnd, + value_json: { segments: 1, hour }, + evidence_ids: [envelope.evidence_id], + }); + + return signals; +} + +export async function upsertSignals(pool, userId, signalDrafts) { + let touched = 0; + const ts = nowMs(); + for (const draft of signalDrafts) { + const contentHash = hashSignal( + userId, + draft.signal_type, + draft.dimension_key, + draft.window_start, + draft.window_end, + draft.value_json, + ); + const [existing] = await pool.query( + `SELECT signal_id, value_json, evidence_ids FROM um_signals + WHERE user_id = ? AND signal_type = ? AND dimension_key = ? + AND window_start = ? AND window_end = ? + LIMIT 1`, + [userId, draft.signal_type, draft.dimension_key, draft.window_start, draft.window_end], + ); + if (existing[0]) { + const prev = existing[0]; + const prevValue = typeof prev.value_json === 'string' ? JSON.parse(prev.value_json) : prev.value_json; + const prevEvidence = + typeof prev.evidence_ids === 'string' ? JSON.parse(prev.evidence_ids) : prev.evidence_ids; + const mergedEvidence = [...new Set([...(prevEvidence ?? []), ...draft.evidence_ids])]; + const mergedValue = { + ...prevValue, + count: Number(prevValue.count ?? 0) + Number(draft.value_json.count ?? 1), + segments: Number(prevValue.segments ?? 0) + Number(draft.value_json.segments ?? 0), + chars: Number(prevValue.chars ?? 0) + Number(draft.value_json.chars ?? 0), + }; + await pool.query( + `UPDATE um_signals SET value_json = ?, evidence_ids = ?, computed_at = ?, content_hash = ? + WHERE signal_id = ?`, + [JSON.stringify(mergedValue), JSON.stringify(mergedEvidence), ts, contentHash, prev.signal_id], + ); + } else { + await pool.query( + `INSERT INTO um_signals + (signal_id, user_id, signal_type, dimension_key, window_start, window_end, + value_json, evidence_ids, computed_at, content_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newId(), + userId, + draft.signal_type, + draft.dimension_key, + draft.window_start, + draft.window_end, + JSON.stringify(draft.value_json), + JSON.stringify(draft.evidence_ids), + ts, + contentHash, + ], + ); + } + touched += 1; + } + return touched; +} + +export { extractTerms, STOPWORDS }; diff --git a/user-model-service/snapshot.mjs b/user-model-service/snapshot.mjs new file mode 100644 index 0000000..65a853f --- /dev/null +++ b/user-model-service/snapshot.mjs @@ -0,0 +1,205 @@ +import crypto from 'node:crypto'; + +function newId() { + return crypto.randomUUID(); +} + +function nowMs() { + return Date.now(); +} + +function hashJson(obj) { + return crypto.createHash('sha256').update(JSON.stringify(obj)).digest('hex'); +} + +/** + * @param {import('mysql2/promise').Pool} pool + * @param {string} userId + * @param {object} options + */ +export async function materializeProfileAndSnapshot(pool, userId, options = {}) { + const projection = options.projection ?? 'default'; + const reason = options.reason ?? 'candidate_accepted'; + const ts = nowMs(); + + const [projects] = await pool.query( + `SELECT e.canonical_name, a.confidence, a.effective_weight, a.last_seen_at + FROM um_entities e + JOIN um_attributes a ON a.entity_id = e.entity_id AND a.status = 'active' + WHERE e.user_id = ? AND e.entity_type = 'project' AND e.status = 'active' + ORDER BY a.effective_weight DESC + LIMIT 8`, + [userId], + ); + + const [focusCandidates] = await pool.query( + `SELECT hypothesis_json, confidence, promotion_score, last_seen_at + FROM um_candidates + WHERE user_id = ? AND candidate_type = 'focus' AND status IN ('open', 'accepted') + ORDER BY promotion_score DESC + LIMIT 10`, + [userId], + ); + + const [versionRows] = await pool.query( + `SELECT COALESCE(MAX(profile_version), 0) AS v FROM um_profile_versions WHERE user_id = ?`, + [userId], + ); + const profileVersion = Number(versionRows[0]?.v ?? 0) + 1; + + const activeProjects = projects.map((row) => ({ + id: `project_${String(row.canonical_name).toLowerCase().replace(/[^a-z0-9]+/g, '_')}`, + name: row.canonical_name, + 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 { + topic: h.topic ?? h.name ?? 'unknown', + weight: Number(row.promotion_score), + ttl_days: 14, + }; + }); + + const structured = { + identity: [], + active_projects: activeProjects, + recent_focus: recentFocus, + technical_preferences: [], + working_style: [], + }; + + const structuredHash = hashJson(structured); + await pool.query( + `UPDATE um_profile_versions SET status = 'superseded' WHERE user_id = ? AND status = 'active'`, + [userId], + ); + await pool.query( + `INSERT INTO um_profile_versions + (profile_version, user_id, structured_json, content_hash, parent_version, materialize_reason, created_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active')`, + [ + profileVersion, + userId, + JSON.stringify(structured), + structuredHash, + profileVersion > 1 ? profileVersion - 1 : null, + reason, + ts, + ], + ); + + const [fastRows] = await pool.query( + `SELECT COALESCE(MAX(fast_revision), 0) AS r FROM um_profile_snapshots WHERE user_id = ? AND projection = ?`, + [userId, projection], + ); + const fastRevision = Number(fastRows[0]?.r ?? 0) + 1; + + const agentHints = []; + if (activeProjects[0]) agentHints.push(`近期重点:${activeProjects[0].name}`); + if (recentFocus[0]) agentHints.push(`关注话题:${recentFocus[0].topic}`); + + const snapshotCore = { + identity: structured.identity, + active_projects: activeProjects, + recent_focus: recentFocus, + technical_preferences: structured.technical_preferences, + working_style: structured.working_style, + agent_hints: agentHints, + }; + + const snapshotBody = { + profile_version: profileVersion, + fast_revision: fastRevision, + projection, + core: snapshotCore, + meta: { + graph_entity_count: activeProjects.length, + open_candidates: recentFocus.length, + }, + }; + + const snapshotJson = { + ...snapshotBody, + stale_after_sec: 3600, + }; + const snapshotStr = JSON.stringify(snapshotCore); + const byteSize = Buffer.byteLength(snapshotStr, 'utf8'); + const contentHash = hashJson(snapshotBody); + + await pool.query( + `UPDATE um_profile_snapshots SET status = 'superseded' WHERE user_id = ? AND projection = ? AND status = 'active'`, + [userId, projection], + ); + + const snapshotId = newId(); + await pool.query( + `INSERT INTO um_profile_snapshots + (snapshot_id, user_id, projection, profile_version, fast_revision, snapshot_json, byte_size, + content_hash, created_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`, + [ + snapshotId, + userId, + projection, + profileVersion, + fastRevision, + JSON.stringify(snapshotJson), + byteSize, + contentHash, + ts, + ], + ); + + return { + profile_version: profileVersion, + fast_revision: fastRevision, + snapshot_id: snapshotId, + content_hash: contentHash, + byte_size: byteSize, + }; +} + +export async function getActiveSnapshot(pool, userId, projection = 'default') { + const [rows] = await pool.query( + `SELECT snapshot_id, user_id, projection, profile_version, fast_revision, snapshot_json, + byte_size, content_hash, created_at + FROM um_profile_snapshots + WHERE user_id = ? AND projection = ? AND status = 'active' + ORDER BY created_at DESC + LIMIT 1`, + [userId, projection], + ); + const row = rows[0]; + if (!row) return null; + const snapshotJson = + typeof row.snapshot_json === 'string' ? JSON.parse(row.snapshot_json) : row.snapshot_json; + return { + snapshot_id: row.snapshot_id, + user_id: row.user_id, + projection: row.projection, + profile_version: row.profile_version, + fast_revision: row.fast_revision, + content_hash: row.content_hash, + byte_size: row.byte_size, + stale_after_sec: snapshotJson.stale_after_sec ?? 3600, + core: snapshotJson.core ?? snapshotJson, + meta: snapshotJson.meta ?? {}, + created_at: row.created_at, + }; +} + +export async function getSnapshotInfo(pool, userId, projection = 'default') { + const snap = await getActiveSnapshot(pool, userId, projection); + if (!snap) return null; + return { + profile_version: snap.profile_version, + fast_revision: snap.fast_revision, + content_hash: snap.content_hash, + projection: snap.projection, + snapshot_id: snap.snapshot_id, + }; +}