Version: 0.9.13.dev.260410
后端: 1. Memory Day1 链路打通(chat_history -> outbox -> memory_jobs) - 更新 service/events/chat_history_persist.go:聊天消息落库同事务追加 memory.extract.requested 事件(仅 user 消息,失败回滚后由 outbox 重试) - 新建 service/events/memory_extract_requested.go:消费 memory.extract.requested 并幂等入队 memory_jobs,补齐 payload 校验、文本截断与 idempotency key - 更新 cmd/start.go:注册 RegisterMemoryExtractRequestedHandler 2. Memory 模块骨架落地(先跑通状态机,再接入真实抽取) - 新建 memory/model、repo、service、orchestrator、worker、utils 目录与 Day1 mock 抽取执行链 - 新建 model/memory.go:补齐 memory_items / memory_jobs / memory_audit_logs / memory_user_settings 与事件 payload 模型 - 更新 inits/mysql.go:接入 4 张 memory 相关表 AutoMigrate 3. RAG 复用基础设施预埋(依赖可替换) - 新建 infra/rag:core pipeline + chunk/embed/retrieve/rerank/store/corpus/config 分层实现 - 默认接入 MockEmbedder + InMemoryStore,预留 Milvus / Eino 适配实现 - 新增 infra/rag/RAG复用接口实施计划.md 4. 本地依赖与交接文档同步 - 更新 docker-compose.yml:新增 etcd / minio / milvus / attu 服务与数据卷 - 删除 newAgent/HANDOFF_工具研究与运行态重置.md、newAgent/阶段3_上下文瘦身设计.md - 新增 newAgent/HANDOFF_WebSearch两阶段实施计划.md、memory/HANDOFF-RAG复用后续实施计划.md、memory/README.md 前端:无 仓库:无
This commit is contained in:
165
backend/memory/HANDOFF-RAG复用后续实施计划.md
Normal file
165
backend/memory/HANDOFF-RAG复用后续实施计划.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# HANDOFF:RAG 复用后续实施计划(Memory 目录留档)
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
1. 给下一位开发者一个可直接执行的后续实施蓝图。
|
||||
2. 明确“已完成/未完成/切流点/回滚点”,避免重复摸索。
|
||||
3. 保证 Memory 与 WebSearch 共用一套 RAG Core,减少重复实现。
|
||||
|
||||
## 2. 当前状态(截至本次交接)
|
||||
|
||||
### 2.1 已完成
|
||||
|
||||
1. `backend/infra/rag` 共享骨架已建好,包含:
|
||||
- `core`:统一类型、接口、pipeline、错误码。
|
||||
- `chunk/embed/retrieve/rerank/store`:默认可运行实现(mock/in-memory/noop)。
|
||||
- `corpus`:`MemoryCorpus`、`WebCorpus` 适配器。
|
||||
- `config`:`rag.*` 配置读取。
|
||||
2. Memory Day1 写入链路已打通(`memory.extract.requested` 事件、`memory_jobs` 入库、worker 状态推进)。
|
||||
3. Milvus 相关 `docker-compose` 服务定义已补齐(本机因网络问题尚未拉起验证)。
|
||||
|
||||
### 2.2 未完成
|
||||
|
||||
1. RAG Core 尚未接入真实业务调用点(当前仍是并行骨架状态)。
|
||||
2. Milvus 真正读写实现未落地(`MilvusStore` 为占位)。
|
||||
3. Eino Embedding/Reranker 未落地(当前占位实现)。
|
||||
4. Memory 读取注入链路(Day2)尚未切到 RAG Core。
|
||||
5. WebSearch 尚未切到 `WebCorpus + RAG Core`。
|
||||
|
||||
## 3. 后续实施总原则
|
||||
|
||||
1. 并行迁移:新旧逻辑并存,先灰度后切流,最后删除旧实现。
|
||||
2. 单轮只做一个能力域:先 Memory 读链路,再 WebSearch。
|
||||
3. 保留可回滚开关:任何切流都必须有一键回退路径。
|
||||
4. 失败可降级:Rerank/Vector 异常不影响主链路响应。
|
||||
|
||||
## 4. 建议执行顺序(4 轮)
|
||||
|
||||
## Round 2:Memory 读链路接入 RAG Core(优先)
|
||||
|
||||
### 目标
|
||||
|
||||
1. Memory 检索由 `infra/rag` 承载,保留旧检索兜底。
|
||||
2. 注入效果不劣于当前逻辑,且可观测。
|
||||
|
||||
### 实施项
|
||||
|
||||
1. 在 Memory ReadService 中接入 `core.Pipeline.Retrieve`。
|
||||
2. 构造 `MemoryRetrieveInput`,强制过滤:
|
||||
- `user_id + assistant_id + conversation_id`
|
||||
3. 配置开关:
|
||||
- `memory.rag.enabled`(默认 `false`,灰度开启)
|
||||
4. 降级策略:
|
||||
- RAG 失败 -> 回退旧检索链路;
|
||||
- Rerank 失败 -> 使用原排序(pipeline 已支持 fallback 标记)。
|
||||
5. 指标补齐:
|
||||
- `memory_rag_hit_count`
|
||||
- `memory_rag_fallback_rate`
|
||||
- `memory_rag_latency_ms`
|
||||
|
||||
### 验收
|
||||
|
||||
1. 开关关闭时行为与当前一致。
|
||||
2. 开关开启时可稳定召回,失败能回退。
|
||||
3. 日志可追踪“为什么没注入某条记忆”。
|
||||
|
||||
## Round 3:WebSearch 接入 WebCorpus + RAG Core
|
||||
|
||||
### 目标
|
||||
|
||||
1. WebSearch 复用同一检索流程,不再各写一套召回逻辑。
|
||||
2. 严格限制跨 query/session 串召回。
|
||||
|
||||
### 实施项
|
||||
|
||||
1. 把抓取结果映射为 `WebIngestItem` 入 RAG。
|
||||
2. 检索时必须带:
|
||||
- `query_id` 或 `session_id`
|
||||
3. 配置开关:
|
||||
- `websearch.rag.enabled`(默认 `false`)
|
||||
4. 保留原 websearch 结果路径,RAG 仅先做“补充召回层”。
|
||||
|
||||
### 验收
|
||||
|
||||
1. 开启后答案质量不下降,且无跨 query 污染。
|
||||
2. 关闭后立即回到旧逻辑。
|
||||
|
||||
## Round 4:Milvus + Eino 真实实现替换
|
||||
|
||||
### 目标
|
||||
|
||||
1. 将 `InMemoryStore` 替换为 `MilvusStore`(生产可用)。
|
||||
2. 将 `MockEmbedder/NoopReranker` 替换为 Eino 实现。
|
||||
|
||||
### 实施项
|
||||
|
||||
1. `store/milvus_store.go`:
|
||||
- 实现 `Upsert/Search/Delete/Get`
|
||||
- 建 collection 与 metadata filter 映射
|
||||
2. `embed/eino_embedder.go`:
|
||||
- 完成 embedding 调用与超时控制
|
||||
3. `rerank/eino_reranker.go`:
|
||||
- 完成重排调用与错误降级
|
||||
4. 配置补齐:
|
||||
- `rag.store=milvus`
|
||||
- `rag.embed.provider=eino`
|
||||
- `rag.reranker.provider=eino`
|
||||
|
||||
### 验收
|
||||
|
||||
1. Milvus 可稳定写入/检索。
|
||||
2. 模型服务波动时主链路可降级。
|
||||
3. 指标完整(命中、延迟、fallback、错误码)。
|
||||
|
||||
## Round 5:统一切流与旧逻辑收敛
|
||||
|
||||
### 目标
|
||||
|
||||
1. Memory + WebSearch 默认走 RAG Core。
|
||||
2. 删除重复旧实现,避免双轨长期维护成本。
|
||||
|
||||
### 实施项
|
||||
|
||||
1. 提升开关默认值为 `true`。
|
||||
2. 观察窗口(建议 3~7 天)稳定后删除旧分支代码。
|
||||
3. 更新文档:
|
||||
- `backend/memory/记忆模块实施计划.md`
|
||||
- `backend/agent/通用能力接入文档.md`(若新增/替换通用能力,必须同步)
|
||||
|
||||
### 验收
|
||||
|
||||
1. 代码无重复检索实现。
|
||||
2. 回滚开关仍可用(应急时关闭 RAG)。
|
||||
3. 线上指标稳定且可追踪。
|
||||
|
||||
## 5. 开关与回滚建议
|
||||
|
||||
1. 建议开关:
|
||||
- `memory.rag.enabled`
|
||||
- `websearch.rag.enabled`
|
||||
- `rag.reranker.enabled`
|
||||
2. 回滚策略:
|
||||
- 先关 corpus 级开关(memory/websearch)。
|
||||
- 再关 reranker。
|
||||
- 极端情况降级到旧检索链路。
|
||||
|
||||
## 6. 关键风险与应对
|
||||
|
||||
1. 风险:切流后召回漂移。
|
||||
应对:双写日志对比(旧链路 vs 新链路 TopK)。
|
||||
2. 风险:Milvus 延迟波动。
|
||||
应对:检索超时 + fallback + 限流。
|
||||
3. 风险:跨会话串数据。
|
||||
应对:过滤维度强校验,不满足则拒绝检索。
|
||||
|
||||
## 7. 接手即办清单(最小行动)
|
||||
|
||||
1. 先做 Round 2(Memory 读链路接 RAG),不要同时改 WebSearch。
|
||||
2. 提交前必须跑:
|
||||
- `go test ./...`
|
||||
3. 每次本地 `go test` 后清理项目根目录 `.gocache`。
|
||||
4. 完成一轮后在本文件追加:
|
||||
- 已落地清单
|
||||
- 待办差距
|
||||
- 下一轮入口
|
||||
|
||||
28
backend/memory/README.md
Normal file
28
backend/memory/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Memory 模块(Day1 骨架)
|
||||
|
||||
## 本轮目标
|
||||
|
||||
1. 打通 `memory.extract.requested` 事件发布与消费。
|
||||
2. 消费后把任务可靠写入 `memory_jobs`(支持幂等)。
|
||||
3. 提供 `worker.RunOnce()`,可手工推进 `pending -> processing -> success/failed`。
|
||||
|
||||
## 本轮边界(刻意不做)
|
||||
|
||||
1. 不接真实 LLM 抽取与冲突决策。
|
||||
2. 不接 Milvus 向量召回。
|
||||
3. 不做读取注入链路(Day2 再接)。
|
||||
|
||||
## 目录说明
|
||||
|
||||
- `model/`:记忆领域 DTO、状态机、配置对象。
|
||||
- `repo/`:`memory_*` 表访问。
|
||||
- `service/`:任务入队门面与配置加载。
|
||||
- `orchestrator/`:写入链路编排(Day1 为 mock 抽取)。
|
||||
- `worker/`:任务执行器(支持手工触发单次运行)。
|
||||
- `utils/`:`ExtractJSON`、`NormalizeFacts` 等工具函数。
|
||||
|
||||
## 手工验证建议
|
||||
|
||||
1. 发起一轮聊天后,检查 outbox 是否存在 `memory.extract.requested`。
|
||||
2. 等待消费后,检查 `memory_jobs` 是否新增 `pending` 记录。
|
||||
3. 手工调用 `worker.RunOnce()`,确认任务推进到 `success/failed`。
|
||||
16
backend/memory/model/audit.go
Normal file
16
backend/memory/model/audit.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AuditLogDTO 是审计日志领域对象。
|
||||
type AuditLogDTO struct {
|
||||
ID int64
|
||||
MemoryID int64
|
||||
UserID int
|
||||
Operation string
|
||||
OperatorType string
|
||||
Reason string
|
||||
BeforeJSON string
|
||||
AfterJSON string
|
||||
CreatedAt *time.Time
|
||||
}
|
||||
25
backend/memory/model/config.go
Normal file
25
backend/memory/model/config.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Config 是记忆模块配置对象(Day1 首版)。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载模块运行参数,不承载业务状态;
|
||||
// 2. 允许启动期统一注入,避免业务层直接依赖配置中心。
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
|
||||
ExtractPrompt string
|
||||
DecisionPrompt string
|
||||
|
||||
Threshold float64
|
||||
EnableReranker bool
|
||||
|
||||
LLMTemperature float64
|
||||
LLMTopP float64
|
||||
|
||||
JobMaxRetry int
|
||||
WorkerPollEvery time.Duration
|
||||
WorkerClaimBatch int
|
||||
}
|
||||
27
backend/memory/model/item.go
Normal file
27
backend/memory/model/item.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ItemDTO 是记忆条目对外读写 DTO。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 面向 memory 模块内部服务层使用;
|
||||
// 2. 不直接绑定 GORM 标签,避免传输结构与存储结构强耦合。
|
||||
type ItemDTO struct {
|
||||
ID int64
|
||||
UserID int
|
||||
ConversationID string
|
||||
AssistantID string
|
||||
RunID string
|
||||
MemoryType string
|
||||
Title string
|
||||
Content string
|
||||
Confidence float64
|
||||
Importance float64
|
||||
SensitivityLevel int
|
||||
IsExplicit bool
|
||||
Status string
|
||||
TTLAt *time.Time
|
||||
CreatedAt *time.Time
|
||||
UpdatedAt *time.Time
|
||||
}
|
||||
41
backend/memory/model/job.go
Normal file
41
backend/memory/model/job.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ExtractJobPayload 是 memory_jobs.payload_json 的领域视图。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述抽取任务执行所需字段;
|
||||
// 2. 与数据库模型解耦,避免后续表结构调整污染 worker 逻辑。
|
||||
type ExtractJobPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
AssistantID string `json:"assistant_id,omitempty"`
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
SourceMessageID int64 `json:"source_message_id,omitempty"`
|
||||
SourceRole string `json:"source_role"`
|
||||
SourceText string `json:"source_text"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
|
||||
// FactCandidate 表示抽取阶段得到的候选事实。
|
||||
type FactCandidate struct {
|
||||
MemoryType string
|
||||
Title string
|
||||
Content string
|
||||
Confidence float64
|
||||
IsExplicit bool
|
||||
}
|
||||
|
||||
// NormalizedFact 表示通过标准化后的可入库事实。
|
||||
type NormalizedFact struct {
|
||||
MemoryType string
|
||||
Title string
|
||||
Content string
|
||||
NormalizedContent string
|
||||
ContentHash string
|
||||
Confidence float64
|
||||
IsExplicit bool
|
||||
}
|
||||
12
backend/memory/model/settings.go
Normal file
12
backend/memory/model/settings.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserSettingDTO 是用户记忆开关领域对象。
|
||||
type UserSettingDTO struct {
|
||||
UserID int
|
||||
MemoryEnabled bool
|
||||
ImplicitMemoryEnabled bool
|
||||
SensitiveMemoryEnabled bool
|
||||
UpdatedAt *time.Time
|
||||
}
|
||||
59
backend/memory/model/status.go
Normal file
59
backend/memory/model/status.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
// MemoryTypePreference 表示用户偏好类记忆。
|
||||
MemoryTypePreference = "preference"
|
||||
// MemoryTypeConstraint 表示硬约束类记忆。
|
||||
MemoryTypeConstraint = "constraint"
|
||||
// MemoryTypeFact 表示一般事实类记忆。
|
||||
MemoryTypeFact = "fact"
|
||||
// MemoryTypeTodoHint 表示近期待办线索类记忆。
|
||||
MemoryTypeTodoHint = "todo_hint"
|
||||
)
|
||||
|
||||
const (
|
||||
// DecisionActionAdd 表示新增记忆。
|
||||
DecisionActionAdd = "ADD"
|
||||
// DecisionActionUpdate 表示更新记忆。
|
||||
DecisionActionUpdate = "UPDATE"
|
||||
// DecisionActionDelete 表示删除记忆。
|
||||
DecisionActionDelete = "DELETE"
|
||||
// DecisionActionNone 表示不做写入动作。
|
||||
DecisionActionNone = "NONE"
|
||||
)
|
||||
|
||||
var validMemoryTypes = map[string]struct{}{
|
||||
MemoryTypePreference: {},
|
||||
MemoryTypeConstraint: {},
|
||||
MemoryTypeFact: {},
|
||||
MemoryTypeTodoHint: {},
|
||||
}
|
||||
|
||||
var validDecisionActions = map[string]struct{}{
|
||||
DecisionActionAdd: {},
|
||||
DecisionActionUpdate: {},
|
||||
DecisionActionDelete: {},
|
||||
DecisionActionNone: {},
|
||||
}
|
||||
|
||||
// NormalizeMemoryType 统一记忆类型字符串。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做字符串标准化,不做业务兜底;
|
||||
// 2. 若调用方传入非法类型,返回空字符串供上游决定丢弃或降级。
|
||||
func NormalizeMemoryType(memoryType string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(memoryType))
|
||||
if _, ok := validMemoryTypes[normalized]; !ok {
|
||||
return ""
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// IsValidDecisionAction 校验决策动作是否合法。
|
||||
func IsValidDecisionAction(action string) bool {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(action))
|
||||
_, ok := validDecisionActions[normalized]
|
||||
return ok
|
||||
}
|
||||
43
backend/memory/orchestrator/write_orchestrator.go
Normal file
43
backend/memory/orchestrator/write_orchestrator.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
|
||||
)
|
||||
|
||||
// WriteOrchestrator 是写入链路编排器(Day1 首版)。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Day1 只做 mock 抽取 + 标准化,不接 LLM 决策;
|
||||
// 2. Day2/Day3 再引入冲突消解、重排与向量召回。
|
||||
type WriteOrchestrator struct{}
|
||||
|
||||
func NewWriteOrchestrator() *WriteOrchestrator {
|
||||
return &WriteOrchestrator{}
|
||||
}
|
||||
|
||||
// ExtractFacts 执行“候选事实抽取 -> 标准化”链路。
|
||||
//
|
||||
// Day1 策略:
|
||||
// 1. 先用 source_text 直接构造候选事实,确保链路可跑通;
|
||||
// 2. 后续再替换成 LLM 抽取与结构化决策。
|
||||
func (o *WriteOrchestrator) ExtractFacts(_ context.Context, payload memorymodel.ExtractJobPayload) ([]memorymodel.NormalizedFact, error) {
|
||||
sourceText := strings.TrimSpace(payload.SourceText)
|
||||
if sourceText == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
candidates := []memorymodel.FactCandidate{
|
||||
{
|
||||
MemoryType: memorymodel.MemoryTypeFact,
|
||||
Title: "用户近期提及",
|
||||
Content: sourceText,
|
||||
Confidence: 0.6,
|
||||
IsExplicit: false,
|
||||
},
|
||||
}
|
||||
return memoryutils.NormalizeFacts(candidates), nil
|
||||
}
|
||||
25
backend/memory/repo/audit_repo.go
Normal file
25
backend/memory/repo/audit_repo.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuditRepo 封装 memory_audit_logs 的数据访问。
|
||||
type AuditRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAuditRepo(db *gorm.DB) *AuditRepo {
|
||||
return &AuditRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *AuditRepo) Create(ctx context.Context, log model.MemoryAuditLog) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("memory audit repo is nil")
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(&log).Error
|
||||
}
|
||||
33
backend/memory/repo/item_repo.go
Normal file
33
backend/memory/repo/item_repo.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ItemRepo 封装 memory_items 的数据访问(Day1 先占位)。
|
||||
type ItemRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewItemRepo(db *gorm.DB) *ItemRepo {
|
||||
return &ItemRepo{db: db}
|
||||
}
|
||||
|
||||
// UpsertItems 预留给 Day2/Day3 的写入链路。
|
||||
//
|
||||
// Day1 约束:
|
||||
// 1. 先完成任务入队与状态机闭环;
|
||||
// 2. 不在本阶段引入复杂冲突消解与向量写入。
|
||||
func (r *ItemRepo) UpsertItems(ctx context.Context, items []model.MemoryItem) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("memory item repo is nil")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(&items).Error
|
||||
}
|
||||
221
backend/memory/repo/job_repo.go
Normal file
221
backend/memory/repo/job_repo.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// JobRepo 封装 memory_jobs 的数据访问。
|
||||
type JobRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewJobRepo(db *gorm.DB) *JobRepo {
|
||||
return &JobRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *JobRepo) WithTx(tx *gorm.DB) *JobRepo {
|
||||
return &JobRepo{db: tx}
|
||||
}
|
||||
|
||||
// CreatePendingExtractJob 创建“待抽取”任务(幂等写入)。
|
||||
//
|
||||
// 失败语义:
|
||||
// 1. 参数非法直接返回 error,由上游决定 dead 或重试;
|
||||
// 2. 同幂等键重复写入采用 DoNothing,保证无副作用。
|
||||
func (r *JobRepo) CreatePendingExtractJob(
|
||||
ctx context.Context,
|
||||
payload memorymodel.ExtractJobPayload,
|
||||
sourceEventID string,
|
||||
) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("memory job repo is nil")
|
||||
}
|
||||
if payload.UserID <= 0 {
|
||||
return errors.New("invalid user_id")
|
||||
}
|
||||
if payload.IdempotencyKey == "" {
|
||||
return errors.New("idempotency_key is empty")
|
||||
}
|
||||
|
||||
rawPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
job := model.MemoryJob{
|
||||
UserID: payload.UserID,
|
||||
ConversationID: strPtrOrNil(payload.ConversationID),
|
||||
SourceMessageID: int64PtrOrNil(payload.SourceMessageID),
|
||||
SourceEventID: strPtrOrNil(sourceEventID),
|
||||
JobType: model.MemoryJobTypeExtract,
|
||||
IdempotencyKey: payload.IdempotencyKey,
|
||||
PayloadJSON: string(rawPayload),
|
||||
Status: model.MemoryJobStatusPending,
|
||||
RetryCount: 0,
|
||||
MaxRetry: 6,
|
||||
NextRetryAt: &now,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "idempotency_key"}},
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(&job).Error
|
||||
}
|
||||
|
||||
// ClaimNextRunnableExtractJob 抢占一个可执行的 extract 任务。
|
||||
//
|
||||
// 抢占规则:
|
||||
// 1. 只从 pending/failed 中挑 next_retry_at 已到期任务;
|
||||
// 2. 用行锁避免多个 worker 抢到同一条任务;
|
||||
// 3. 抢占成功后立即置为 processing,防止重复执行。
|
||||
func (r *JobRepo) ClaimNextRunnableExtractJob(ctx context.Context, now time.Time) (*model.MemoryJob, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("memory job repo is nil")
|
||||
}
|
||||
|
||||
var claimed *model.MemoryJob
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var job model.MemoryJob
|
||||
queryErr := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("job_type = ?", model.MemoryJobTypeExtract).
|
||||
Where("status IN ?", []string{model.MemoryJobStatusPending, model.MemoryJobStatusFailed}).
|
||||
Where("(next_retry_at IS NULL OR next_retry_at <= ?)", now).
|
||||
Order("id ASC").
|
||||
First(&job).Error
|
||||
if queryErr != nil {
|
||||
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return queryErr
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"status": model.MemoryJobStatusProcessing,
|
||||
"updated_at": now,
|
||||
"last_error": nil,
|
||||
}
|
||||
if updateErr := tx.Model(&model.MemoryJob{}).Where("id = ?", job.ID).Updates(updates).Error; updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
|
||||
job.Status = model.MemoryJobStatusProcessing
|
||||
job.UpdatedAt = &now
|
||||
claimed = &job
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
// MarkSuccess 把任务推进为 success 最终态。
|
||||
func (r *JobRepo) MarkSuccess(ctx context.Context, jobID int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("memory job repo is nil")
|
||||
}
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"status": model.MemoryJobStatusSuccess,
|
||||
"last_error": nil,
|
||||
"next_retry_at": nil,
|
||||
"updated_at": now,
|
||||
}
|
||||
return r.db.WithContext(ctx).Model(&model.MemoryJob{}).Where("id = ?", jobID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// MarkFailed 按重试策略推进任务到 failed/dead。
|
||||
//
|
||||
// 规则:
|
||||
// 1. retry_count +1 后若超上限,直接 dead;
|
||||
// 2. 未超上限则写 failed 并设置 next_retry_at。
|
||||
func (r *JobRepo) MarkFailed(ctx context.Context, jobID int64, reason string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("memory job repo is nil")
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var job model.MemoryJob
|
||||
queryErr := tx.
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", jobID).
|
||||
First(&job).Error
|
||||
if queryErr != nil {
|
||||
return queryErr
|
||||
}
|
||||
if job.Status == model.MemoryJobStatusSuccess || job.Status == model.MemoryJobStatusDead {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxRetry := job.MaxRetry
|
||||
if maxRetry <= 0 {
|
||||
maxRetry = 6
|
||||
}
|
||||
nextRetryCount := job.RetryCount + 1
|
||||
now := time.Now()
|
||||
status := model.MemoryJobStatusFailed
|
||||
var nextRetryAt *time.Time
|
||||
if nextRetryCount >= maxRetry {
|
||||
status = model.MemoryJobStatusDead
|
||||
nextRetryAt = nil
|
||||
} else {
|
||||
t := now.Add(calcRetryBackoff(nextRetryCount))
|
||||
nextRetryAt = &t
|
||||
}
|
||||
|
||||
lastErr := truncateError(reason)
|
||||
updates := map[string]any{
|
||||
"status": status,
|
||||
"retry_count": nextRetryCount,
|
||||
"last_error": &lastErr,
|
||||
"next_retry_at": nextRetryAt,
|
||||
"updated_at": now,
|
||||
}
|
||||
return tx.Model(&model.MemoryJob{}).Where("id = ?", jobID).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
|
||||
func calcRetryBackoff(retryCount int) time.Duration {
|
||||
if retryCount <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
if retryCount > 6 {
|
||||
retryCount = 6
|
||||
}
|
||||
return time.Second * time.Duration(1<<(retryCount-1))
|
||||
}
|
||||
|
||||
func truncateError(reason string) string {
|
||||
if len(reason) <= 2000 {
|
||||
return reason
|
||||
}
|
||||
return reason[:2000]
|
||||
}
|
||||
|
||||
func strPtrOrNil(v string) *string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
value := v
|
||||
return &value
|
||||
}
|
||||
|
||||
func int64PtrOrNil(v int64) *int64 {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
value := v
|
||||
return &value
|
||||
}
|
||||
34
backend/memory/repo/settings_repo.go
Normal file
34
backend/memory/repo/settings_repo.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SettingsRepo 封装 memory_user_settings 的读写。
|
||||
type SettingsRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSettingsRepo(db *gorm.DB) *SettingsRepo {
|
||||
return &SettingsRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *SettingsRepo) Upsert(ctx context.Context, setting model.MemoryUserSetting) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("memory settings repo is nil")
|
||||
}
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"memory_enabled",
|
||||
"implicit_memory_enabled",
|
||||
"sensitive_memory_enabled",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(&setting).Error
|
||||
}
|
||||
49
backend/memory/service/config_loader.go
Normal file
49
backend/memory/service/config_loader.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// LoadConfigFromViper 读取记忆模块配置并做默认值兜底。
|
||||
//
|
||||
// 默认策略:
|
||||
// 1. temperature/top_p 使用低随机参数,提升可复现性;
|
||||
// 2. Day1 先提供参数位,不强制所有参数立即生效;
|
||||
// 3. 轮询与重试参数给出保守默认值,避免对主链路造成压力。
|
||||
func LoadConfigFromViper() memorymodel.Config {
|
||||
cfg := memorymodel.Config{
|
||||
Enabled: viper.GetBool("memory.enabled"),
|
||||
ExtractPrompt: viper.GetString("memory.prompt.extract"),
|
||||
DecisionPrompt: viper.GetString("memory.prompt.decision"),
|
||||
Threshold: viper.GetFloat64("memory.threshold"),
|
||||
EnableReranker: viper.GetBool("memory.enableReranker"),
|
||||
LLMTemperature: viper.GetFloat64("memory.llm.temperature"),
|
||||
LLMTopP: viper.GetFloat64("memory.llm.topP"),
|
||||
JobMaxRetry: viper.GetInt("memory.job.maxRetry"),
|
||||
WorkerPollEvery: viper.GetDuration("memory.worker.pollEvery"),
|
||||
WorkerClaimBatch: viper.GetInt("memory.worker.claimBatch"),
|
||||
}
|
||||
|
||||
if cfg.Threshold <= 0 {
|
||||
cfg.Threshold = 0.55
|
||||
}
|
||||
if cfg.LLMTemperature <= 0 {
|
||||
cfg.LLMTemperature = 0.1
|
||||
}
|
||||
if cfg.LLMTopP <= 0 {
|
||||
cfg.LLMTopP = 0.2
|
||||
}
|
||||
if cfg.JobMaxRetry <= 0 {
|
||||
cfg.JobMaxRetry = 6
|
||||
}
|
||||
if cfg.WorkerPollEvery <= 0 {
|
||||
cfg.WorkerPollEvery = 2 * time.Second
|
||||
}
|
||||
if cfg.WorkerClaimBatch <= 0 {
|
||||
cfg.WorkerClaimBatch = 1
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
33
backend/memory/service/enqueue_service.go
Normal file
33
backend/memory/service/enqueue_service.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
|
||||
)
|
||||
|
||||
// EnqueueService 是 Day1 的“任务入队门面”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把抽取请求入 memory_jobs;
|
||||
// 2. 不负责执行抽取、不负责写 memory_items。
|
||||
type EnqueueService struct {
|
||||
jobRepo *memoryrepo.JobRepo
|
||||
}
|
||||
|
||||
func NewEnqueueService(jobRepo *memoryrepo.JobRepo) *EnqueueService {
|
||||
return &EnqueueService{jobRepo: jobRepo}
|
||||
}
|
||||
|
||||
func (s *EnqueueService) EnqueueExtractJob(
|
||||
ctx context.Context,
|
||||
payload memorymodel.ExtractJobPayload,
|
||||
sourceEventID string,
|
||||
) error {
|
||||
if s == nil || s.jobRepo == nil {
|
||||
return errors.New("memory enqueue service is nil")
|
||||
}
|
||||
return s.jobRepo.CreatePendingExtractJob(ctx, payload, sourceEventID)
|
||||
}
|
||||
104
backend/memory/utils/extract_json.go
Normal file
104
backend/memory/utils/extract_json.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*([\\[{].*[\\]}])\\s*```")
|
||||
|
||||
// ExtractJSON 从模型输出中提取 JSON 文本(兼容代码块包裹)。
|
||||
//
|
||||
// 步骤:
|
||||
// 1. 先判断整段文本是否本身就是合法 JSON;
|
||||
// 2. 再尝试匹配 ```json ... ``` 代码块;
|
||||
// 3. 最后做一次“首个 JSON 对象/数组”扫描提取。
|
||||
func ExtractJSON(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", errors.New("empty model output")
|
||||
}
|
||||
|
||||
// 1. 直接 JSON 命中时,避免做额外启发式扫描。
|
||||
if json.Valid([]byte(trimmed)) {
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
// 2. 兼容 markdown 代码块包裹 JSON。
|
||||
matches := fencedJSONPattern.FindStringSubmatch(trimmed)
|
||||
if len(matches) > 1 {
|
||||
candidate := strings.TrimSpace(matches[1])
|
||||
if json.Valid([]byte(candidate)) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 兜底扫描首个完整 JSON 片段,尽量提升容错能力。
|
||||
if candidate, ok := findFirstJSONSegment(trimmed); ok {
|
||||
return candidate, nil
|
||||
}
|
||||
return "", errors.New("json not found in model output")
|
||||
}
|
||||
|
||||
func findFirstJSONSegment(raw string) (string, bool) {
|
||||
start := -1
|
||||
var open, close rune
|
||||
for i, ch := range raw {
|
||||
if ch == '{' {
|
||||
start = i
|
||||
open = '{'
|
||||
close = '}'
|
||||
break
|
||||
}
|
||||
if ch == '[' {
|
||||
start = i
|
||||
open = '['
|
||||
close = ']'
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
depth := 0
|
||||
inString := false
|
||||
escaped := false
|
||||
for i, ch := range raw[start:] {
|
||||
if inString {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if ch == open {
|
||||
depth++
|
||||
continue
|
||||
}
|
||||
if ch == close {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
candidate := strings.TrimSpace(raw[start : start+i+1])
|
||||
if json.Valid([]byte(candidate)) {
|
||||
return candidate, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
102
backend/memory/utils/normalize_facts.go
Normal file
102
backend/memory/utils/normalize_facts.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTitleLength = 64
|
||||
maxContentLength = 1000
|
||||
)
|
||||
|
||||
// NormalizeFacts 对候选事实做标准化与过滤。
|
||||
//
|
||||
// 步骤:
|
||||
// 1. 标准化 memory_type 与文本字段,丢弃空值和非法类型;
|
||||
// 2. 对超长内容截断,避免脏数据污染后续链路;
|
||||
// 3. 基于“类型+标准化内容”做去重,避免同一轮重复写入。
|
||||
func NormalizeFacts(candidates []memorymodel.FactCandidate) []memorymodel.NormalizedFact {
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]memorymodel.NormalizedFact, 0, len(candidates))
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
memoryType := memorymodel.NormalizeMemoryType(candidate.MemoryType)
|
||||
if memoryType == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
content := normalizeWhitespace(candidate.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
content = truncateByRune(content, maxContentLength)
|
||||
|
||||
title := normalizeWhitespace(candidate.Title)
|
||||
if title == "" {
|
||||
title = truncateByRune(content, maxTitleLength)
|
||||
}
|
||||
title = truncateByRune(title, maxTitleLength)
|
||||
|
||||
confidence := clamp01(candidate.Confidence)
|
||||
if confidence == 0 {
|
||||
confidence = 0.6
|
||||
}
|
||||
|
||||
normalizedContent := strings.ToLower(content)
|
||||
contentHash := hashContent(memoryType, normalizedContent)
|
||||
dedupKey := fmt.Sprintf("%s:%s", memoryType, contentHash)
|
||||
if _, exists := seen[dedupKey]; exists {
|
||||
continue
|
||||
}
|
||||
seen[dedupKey] = struct{}{}
|
||||
|
||||
result = append(result, memorymodel.NormalizedFact{
|
||||
MemoryType: memoryType,
|
||||
Title: title,
|
||||
Content: content,
|
||||
NormalizedContent: normalizedContent,
|
||||
ContentHash: contentHash,
|
||||
Confidence: confidence,
|
||||
IsExplicit: candidate.IsExplicit,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeWhitespace(raw string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
|
||||
}
|
||||
|
||||
func truncateByRune(raw string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(raw)
|
||||
if len(runes) <= max {
|
||||
return raw
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func hashContent(memoryType, normalizedContent string) string {
|
||||
sum := sha256.Sum256([]byte(memoryType + "::" + normalizedContent))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
22
backend/memory/worker/mock_extractor.go
Normal file
22
backend/memory/worker/mock_extractor.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryorchestrator "github.com/LoveLosita/smartflow/backend/memory/orchestrator"
|
||||
)
|
||||
|
||||
// Extractor 是 worker 抽取依赖接口。
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. Day1 先接 mock 编排器跑通状态机;
|
||||
// 2. Day2/Day3 可无缝替换为真实 LLM 抽取实现。
|
||||
type Extractor interface {
|
||||
ExtractFacts(ctx context.Context, payload memorymodel.ExtractJobPayload) ([]memorymodel.NormalizedFact, error)
|
||||
}
|
||||
|
||||
// NewMockExtractor 返回 Day1 默认 mock 抽取器。
|
||||
func NewMockExtractor() Extractor {
|
||||
return memoryorchestrator.NewWriteOrchestrator()
|
||||
}
|
||||
95
backend/memory/worker/runner.go
Normal file
95
backend/memory/worker/runner.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryrepo "github.com/LoveLosita/smartflow/backend/memory/repo"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
)
|
||||
|
||||
// RunOnceResult 描述一次手工触发执行结果。
|
||||
type RunOnceResult struct {
|
||||
Claimed bool
|
||||
JobID int64
|
||||
Status string
|
||||
Facts int
|
||||
}
|
||||
|
||||
// Runner 是 Day1 首版任务执行器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责推进 memory_jobs 状态机;
|
||||
// 2. Day1 不做 memory_items 真正落库,仅做 mock 抽取与状态推进。
|
||||
type Runner struct {
|
||||
jobRepo *memoryrepo.JobRepo
|
||||
extractor Extractor
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewRunner(jobRepo *memoryrepo.JobRepo, extractor Extractor) *Runner {
|
||||
return &Runner{
|
||||
jobRepo: jobRepo,
|
||||
extractor: extractor,
|
||||
logger: log.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnce 手工执行一次任务抢占与处理。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. Claimed=false 表示当前无可执行任务;
|
||||
// 2. Claimed=true 且 Status=success/failed/dead 表示状态已推进完成。
|
||||
func (r *Runner) RunOnce(ctx context.Context) (*RunOnceResult, error) {
|
||||
if r == nil || r.jobRepo == nil || r.extractor == nil {
|
||||
return nil, errors.New("memory worker runner is not initialized")
|
||||
}
|
||||
|
||||
// 1. 抢占一条可执行任务,避免并发 worker 重复处理同一记录。
|
||||
job, err := r.jobRepo.ClaimNextRunnableExtractJob(ctx, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if job == nil {
|
||||
return &RunOnceResult{Claimed: false}, nil
|
||||
}
|
||||
|
||||
result := &RunOnceResult{
|
||||
Claimed: true,
|
||||
JobID: job.ID,
|
||||
Status: model.MemoryJobStatusProcessing,
|
||||
Facts: 0,
|
||||
}
|
||||
|
||||
// 2. 解析 payload_json。解析失败属于数据质量问题,走失败重试并打日志。
|
||||
var payload memorymodel.ExtractJobPayload
|
||||
if err = json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil {
|
||||
failReason := fmt.Sprintf("解析任务载荷失败: %v", err)
|
||||
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
|
||||
result.Status = model.MemoryJobStatusFailed
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 3. 调用抽取器执行 mock 抽取。Day1 先保证“能推进状态”,不引入重计算。
|
||||
facts, extractErr := r.extractor.ExtractFacts(ctx, payload)
|
||||
if extractErr != nil {
|
||||
failReason := fmt.Sprintf("抽取执行失败: %v", extractErr)
|
||||
_ = r.jobRepo.MarkFailed(ctx, job.ID, failReason)
|
||||
result.Status = model.MemoryJobStatusFailed
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 4. 抽取成功后把任务置为 success。
|
||||
if err = r.jobRepo.MarkSuccess(ctx, job.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Status = model.MemoryJobStatusSuccess
|
||||
result.Facts = len(facts)
|
||||
r.logger.Printf("memory worker run once success: job_id=%d extracted_facts=%d", job.ID, len(facts))
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user