# 第二步执行计划：读取与注入层升级

## Context

第一步（写入决策层）已完成，写侧已有"召回 → 比对 → ADD/UPDATE/DELETE/NONE"能力。
但读侧仍是"查到就拼"，存在四个问题：

1. RAG 和 legacy **互斥**，无法做到"MySQL 强约束 + RAG 语义补充"双路合并
2. 去重仅 `seen[line]` 字符串级，无 `memory_id` / `content_hash` 级去重
3. 所有类型平铺、limit=5 一刀切，constraint 可被 fact 挤掉
4. `memory_context` 虽已写入 `PinnedBlocks`，但 Execute 阶段走自定义 `msg0~msg3` 骨架，当前并未消费这块内容

---

## 当前数据流（legacy）

```
用户发消息
  │
  ▼
agent_newagent.go:114  injectMemoryContext()
  │  调用 MemoryReader.Retrieve()
  │  入参: userID, chatID, query=userMessage, limit=5
  ▼
ReadService.Retrieve()                          ← read_service.go:51
  │  门控: 用户设置检查
  │  分支: RAG成功→走RAG / 否则→走legacy
  │  两路互斥，只走一条
  ▼
  ├── retrieveByRAG()                            ← read_service.go:132
  │     ragRuntime.RetrieveMemory() → []RetrieveHit
  │     转为 []ItemDTO, 用户设置过滤, 截断到 limit
  │
  └── retrieveByLegacy()                         ← read_service.go:84
        itemRepo.FindByQuery(limit*3) → []MemoryItem
        用户设置过滤 → scoreRetrievedItem排序 → 截断到 limit
        toItemDTOs() 转换, TouchLastAccessAt
  │
  ▼ 返回 []ItemDTO（最多5条，无类型预算，无服务级去重）
  │
renderMemoryPinnedContent()                     ← agent_memory.go:105
  │  遍历 items, 对每条生成 "[类型] 内容"
  │  seen[line] 字符串级弱去重
  ▼
拼接为一段纯文本 → ConversationContext.UpsertPinnedBlock(key="memory_context")
  │
  ├── base.go:55  renderPinnedBlocks()
  │     把所有 pinned blocks 拼成 system message
  │     Chat / Plan / Deliver / 走通用 buildStageMessages 的节点可自动消费
  │
  └── execute_context.go:52  buildExecuteStageMessages()
        Execute 走自定义 msg0~msg3 骨架
        当前未渲染 memory_context，等价于 Execute 看不到这段记忆
```

---

## 目标数据流（hybrid）

```
用户发消息
  │
  ▼
agent_newagent.go:114  injectMemoryContext()     ← 不改触发点，改内部链路
  │  调用 MemoryReader.Retrieve()
  ▼
ReadService.Retrieve()                            ← read_service.go
  │  门控: 用户设置检查（不变）
  │  分支: cfg.ReadMode == "hybrid" → 走新链路
  │        否则 → 走旧链路（完全不变）
  ▼ ══════════════════════════════════════════════
 HybridRetrieve()                                 ← 新文件 retrieve_merge.go
 ║                                                ← 整个混合链路收口在一个函数里
 ║  ┌─────────────────────────────────────────┐
 ║  │ 第一路：结构化强约束召回                    │
 ║  │                                         │
 ║  │ ItemRepo.FindPinnedByUser()             │ ← 新方法 item_repo.go
 ║  │   → constraint: status=active, 全取     │
 ║  │   → preference: confidence>=0.8,        │
 ║  │     按 importance 降序取 limit 条        │
 ║  │ 合并 → []MemoryItem → toItemDTOs()       │
 ║  │ 结果 A                                   │
 ║  └─────────────────────────────────────────┘
 ║                    ↓
 ║  ┌─────────────────────────────────────────┐
 ║  │ 第二路：语义候选召回                       │
 ║  │                                         │
 ║  │ RAG 可用?                                │
 ║  │   是 → ragRuntime.RetrieveMemory()       │ ← 复用现有 RAG 链路
 ║  │        → []RetrieveHit                   │
 ║  │        → buildMemoryDTOFromRetrieveHit() │ ← 复用 read_service.go 已有函数
 ║  │        → 用户设置过滤                     │
 ║  │   否 → itemRepo.FindByQuery()            │ ← 复用现有 FindByQuery
 ║  │        → toItemDTOs()                    │
 ║  │        → 用户设置过滤                     │
 ║  │ 结果 B                                   │
 ║  └─────────────────────────────────────────┘
 ║                    ↓
 ║          合并 A + B → []ItemDTO
 ║                    ↓
 ║  ┌─────────────────────────────────────────┐
 ║  │ 三级去重                                  │
 ║  │                                         │
 ║  │ 1. dedupByID    — 按 memory_id 去重     │ ← 同 ID 只保留一条
 ║  │                 后出现的覆盖先出现的      │
 ║  │ 2. dedupByHash  — 按 content_hash 去重  │ ← 复用 HashContent 算法
 ║  │                 hash 为空的跳过          │   (normalize_facts.go)
 ║  │                 保留 importance 更高的    │
 ║  │ 3. dedupByText  — 按渲染文本兜底去重     │ ← hash 缺失/空值兜底
 ║  │                 用 localizeMemoryType +  │
 ║  │                 Content 生成 key         │
 ║  └─────────────────────────────────────────┘
 ║                    ↓
 ║  ┌─────────────────────────────────────────┐
 ║  │ 排序                                      │
 ║  │                                         │
 ║  │ RankItems()                              │ ← 新文件 retrieve_rank.go
 ║  │   类型优先级权重叠加原加权分:             │
 ║  │   constraint +0.15                      │
 ║  │   preference +0.10                      │
 ║  │   todo_hint  +0.05                      │
 ║  │   fact       +0                         │
 ║  │   + 原 0.35*importance + 0.3*confidence │
 ║  │   + 0.2*recency + 0.1*explicit          │
 ║  │   + 0.08*同会话加分                     │
 ║  │   同分按 ID 降序                         │
 ║  └─────────────────────────────────────────┘
 ║                    ↓
 ║  ┌─────────────────────────────────────────┐
 ║  │ 类型预算裁剪                              │
 ║  │                                         │
 ║  │ applyTypeBudget()                        │
 ║  │   constraint:  最多 ConstraintLimit 条   │ ← 默认 5
 ║  │   preference:  最多 PreferenceLimit 条   │ ← 默认 5
 ║  │   todo_hint:   最多 TodoHintLimit 条     │ ← 默认 3
 ║  │   fact:        最多 FactLimit 条         │ ← 默认 5
 ║  │   类型内部保持 RankItems 排序结果        │
 ║  │   总计最多 18 条（仍受 Execute 上下文预算约束）│
 ║  └─────────────────────────────────────────┘
 ║                    ↓
 ║           返回 []ItemDTO（去重、排序、预算裁剪后的最终结果）
 ══════════════════════════════════════════════
  │
  ▼ 返回到 injectMemoryContext()
  │
  │ cfg.InjectRenderMode == "typed_v2" ?
  │
  ├── typed_v2 → RenderTypedMemoryContent()    ← 新文件 agent_memory_render.go
  │     按类型分组渲染:
  │     ┌──────────────────────────────────┐
  │     │ 以下是与当前对话相关的用户记忆，     │
  │     │ 仅在确实有帮助时参考，不要机械复述。 │
  │     │                                  │
  │     │ 【必守约束】                       │
  │     │ - 用户点外卖不要香菜。              │
  │     │                                  │
  │     │ 【用户偏好】                       │
  │     │ - 用户偏爱黑咖啡。                 │
  │     │                                  │
  │     │ 【当前话题相关事实】                │
  │     │ - 用户最近在准备周四的程序设计作业。  │
  │     │                                  │
  │     │ 【近期待办】                       │
  │     │ - 周五前交英语作文。               │
  │     └──────────────────────────────────┘
  │     规则: 空段不输出, 段内 "- " 前缀
  │
  └── flat → RenderFlatMemoryContent()         ← 新文件 agent_memory_render.go
        从 agent_memory.go 迁入现有 renderMemoryPinnedContent 逻辑，不变
  │
  ▼ 拼接为纯文本
  │
ConversationContext.UpsertPinnedBlock(key="memory_context")
  │
  ├── 通用阶段 → base.go:55  renderPinnedBlocks()        ← 不改
  │     把所有 pinned blocks 拼成 system message
  │     Chat / Plan / Deliver / 走通用组装的节点自动消费 memory_context
  │
  └── Execute 阶段 → buildExecuteMessage3()              ← 修改 execute_context.go
        renderExecuteMemoryContext(ctx)                  ← 新文件 execute_pinned.go
          → 只白名单读取 key="memory_context"
          → 以“相关记忆”补充段拼入 msg3
          → 不复用通用 renderPinnedBlocks，避免 execution_context/current_step 等块重复注入
```

---

## 每个阶段对应的代码改动

### 阶段 0：前置准备（配置 + DTO 补齐）

改造开始前，先让配置和 DTO 能支撑后续链路。

**改动 1：Config 新增读侧配置字段**
- 文件：`backend/memory/model/config.go`
- 新增 6 个字段：`ReadMode` / `ReadConstraintLimit` / `ReadPreferenceLimit` / `ReadFactLimit` / `ReadTodoHintLimit` / `InjectRenderMode`

**改动 2：ConfigLoader 读取 + 默认值**
- 文件：`backend/memory/service/config_loader.go`
- 读取上述 6 个 viper key，默认值：ReadMode="legacy", ConstraintLimit=5, PreferenceLimit=5, FactLimit=5, TodoHintLimit=3, RenderMode="flat"

**改动 3：ItemDTO 补齐 ContentHash**
- 文件：`backend/memory/model/item.go` — ItemDTO 新增 `ContentHash string`
- 文件：`backend/memory/service/common.go` — `toItemDTO` 补映射 `ContentHash: strValue(item.ContentHash)`
- 原因：去重阶段需要 content_hash，当前 ItemDTO 没有这个字段

### 阶段 1：第一路 — 结构化强约束召回

**改动 4：ItemRepo 新增 FindPinnedByUser**
- 文件：`backend/memory/repo/item_repo.go`
- 两次查询合并：
  - 查 1：`memory_type=constraint AND status=active AND user_id=? AND (未过期)`
  - 查 2：`memory_type=preference AND confidence>=0.8 AND status=active AND user_id=? AND (未过期)` 按 importance DESC LIMIT preferenceLimit
- 合并返回，约束在前偏好在后
- 复用已有的 `applyScopedEquality` 模式构建 WHERE

### 阶段 2：第二路 — 语义候选召回

**无新文件**。直接在 HybridRetrieve 内部实现：
- RAG 可用：调 `ragRuntime.RetrieveMemory()` → 复用 `buildMemoryDTOFromRetrieveHit()` 转 DTO
- RAG 不可用：调 `itemRepo.FindByQuery()` → 复用 `toItemDTOs()` 转 DTO
- 两路复用现有函数，不重写

### 阶段 3：三级去重

**新增文件：`backend/memory/service/retrieve_merge.go`**

三个纯函数，输入 `[]ItemDTO` 输出 `[]ItemDTO`：

1. `dedupByID` — map[int64]ItemDTO，后出现的覆盖先出现的
2. `dedupByHash` — map[string]ItemDTO，保留 importance 更高的；hash 为空跳过
3. `dedupByText` — map[string]ItemDTO，用 `localizeMemoryType + Content` 生成 key

复用：`HashContent` 算法（来自 `normalize_facts.go`，已导出）

### 阶段 4：排序

**新增文件：`backend/memory/service/retrieve_rank.go`**

- `RankItems(items, now, conversationID)` — 在原 `scoreRetrievedItem` 基础上叠加类型优先级权重
- 原 `scoreRetrievedItem` 保留给 legacy 路径，不删除

### 阶段 5：类型预算裁剪

**同文件：`backend/memory/service/retrieve_merge.go`**

- `applyTypeBudget(items, cfg)` — 按 4 个类型 limit 截断，类型内部保持排序结果

### 阶段 6：ReadService 接入

**改动 5：ReadService.Retrieve 新增 hybrid 分支**
- 文件：`backend/memory/service/read_service.go`
- 改动极小：在现有 Retrieve 方法中，门控通过后、limit 计算后，加一个 `if cfg.ReadMode == "hybrid"` 分支调 HybridRetrieve
- 旧路径（RAG 优先 → legacy 兜底）完全不动

### 阶段 7：渲染

**新增文件：`backend/service/agentsvc/agent_memory_render.go`**

- `RenderTypedMemoryContent(items)` — 按类型分组渲染，空段不输出
- `RenderFlatMemoryContent(items)` — 迁入现有 `renderMemoryPinnedContent` 逻辑
- 产物仍统一收口为 `ConversationContext.PinnedBlock(key="memory_context")`，后续 Execute 只消费这块内容，不再重复维护第二套 memory 渲染逻辑

### 阶段 8：Execute 记忆消费补齐

**新增文件：`backend/newAgent/prompt/execute_pinned.go`**
- 新增 `renderExecuteMemoryContext(ctx)`：只白名单读取 `memory_context` 这一个 pinned block
- 输出定位：作为 Execute `msg3` 的补充段，不进入 `msg1/msg2`，避免污染历史归档与 ReAct 窗口
- 设计约束：**不**直接复用通用 `renderPinnedBlocks()`，避免 `execution_context` / `current_step` / `rough_build_done` 等 Execute 自有 pinned block 重复注入

**改动 6：`execute_context.go` 接入 memory_context**
- 文件：`backend/newAgent/prompt/execute_context.go`
- 在 `buildExecuteMessage3()` 中拼接 `renderExecuteMemoryContext(ctx)` 的结果
- 空记忆不输出；只追加“相关记忆”段，不改动 `msg0/msg1/msg2` 既有职责

### 阶段 9：注入入口切换

**改动 7：agent_memory.go 接入 renderMode**
- 文件：`backend/service/agentsvc/agent.go` — AgentService 新增 `memoryCfg memorymodel.Config` 字段
- 文件：`backend/service/agentsvc/agent_memory.go` — `SetMemoryReader` 签名增加 cfg 参数；`injectMemoryContext` 根据 cfg.InjectRenderMode 选渲染函数

**改动 8：启动层传参**
- 文件：`backend/cmd/start.go` — `SetMemoryReader(memoryModule)` → `SetMemoryReader(memoryModule, memoryCfg)`
- memoryCfg 在同函数第 78 行已定义，无需额外引入

---

## 文件变更汇总

| 文件 | 操作 | 对应阶段 |
|---|---|---|
| `backend/memory/model/config.go` | 修改 | 阶段 0 |
| `backend/memory/service/config_loader.go` | 修改 | 阶段 0 |
| `backend/memory/model/item.go` | 修改 | 阶段 0 |
| `backend/memory/service/common.go` | 修改 | 阶段 0 |
| `backend/memory/repo/item_repo.go` | 修改 | 阶段 1 |
| `backend/memory/service/retrieve_merge.go` | **新增** | 阶段 3 + 5 |
| `backend/memory/service/retrieve_rank.go` | **新增** | 阶段 4 |
| `backend/memory/service/read_service.go` | 修改 | 阶段 6 |
| `backend/service/agentsvc/agent_memory_render.go` | **新增** | 阶段 7 |
| `backend/newAgent/prompt/execute_pinned.go` | **新增** | 阶段 8 |
| `backend/newAgent/prompt/execute_context.go` | 修改 | 阶段 8 |
| `backend/service/agentsvc/agent.go` | 修改 | 阶段 9 |
| `backend/service/agentsvc/agent_memory.go` | 修改 | 阶段 9 |
| `backend/cmd/start.go` | 修改 | 阶段 9 |

---

## 实施顺序（严格依赖链）

```
阶段 0（前置）:  config.go → config_loader.go → item.go + common.go
    ↓
阶段 1（Repo）:  item_repo.go (FindPinnedByUser)
    ↓
阶段 3+4（去重+排序）: retrieve_merge.go（去重函数）+ retrieve_rank.go（可并行）
    ↓
阶段 5（预算）:  retrieve_merge.go（HybridRetrieve 入口 + applyTypeBudget）
    ↓                   ↑ 合并阶段 1~5 为完整 HybridRetrieve 函数
阶段 6（接入）:  read_service.go（hybrid 分支）
    ↓
阶段 7（渲染）:  agent_memory_render.go（可和阶段 6 并行）
    ↓
阶段 8（Execute 消费）: execute_pinned.go + execute_context.go
    ↓
阶段 9（集成）:  agent.go + agent_memory.go + start.go
```

---

## 回滚策略

全部配置开关回滚，不改代码：

| 配置 | 回滚值 | 效果 |
|---|---|---|
| `memory.read.mode` | `legacy` | 读侧回到当前行为 |
| `memory.inject.renderMode` | `flat` | 注入渲染回到当前行为 |

---

## 验证方式

1. **默认启动不变**：不配置任何新参数，系统行为与当前完全一致
2. **hybrid 双路召回**：设 `memory.read.mode=hybrid`，日志确认两路召回 + 合并 + 去重生效
3. **constraint 优先**：写入 5 条 fact + 2 条 constraint，确认 constraint 不被挤出
4. **去重生效**：同一用户多条同义记忆，注入只保留一条
5. **RAG 降级**：关 Milvus，hybrid 模式仍通过 MySQL fallback 正常工作
6. **typed_v2 渲染**：设 `memory.inject.renderMode=typed_v2`，pinned block 按段输出
7. **Execute 可见记忆**：进入 Execute 节点时，送入 LLM 的 `msg3` 含“相关记忆”段，且内容来自 `memory_context`
8. **Execute 无重复注入**：`execution_context` / `current_step` 等 Execute 自有 pinned block 不因 memory 接入被重复渲染
9. **单元测试**：对去重/预算/排序/渲染 / Execute 记忆桥接编写测试，跑完删除

---

## 本轮明确不做

1. 不把 memory 改造成工具调用
2. 不改 newAgent 的图路由结构
3. 不把 WebSearch 并进统一召回
4. 不清理历史重复脏数据
5. 不动写入决策层代码
6. 不让 Execute 无差别复用通用 `renderPinnedBlocks()`，避免把全部 pinned block 一股脑塞进 `msg3`
