Version: 0.9.14.dev.260410
后端:
1. LLM 客户端从 newAgent/llm 提升为 infra/llm 基础设施层
- 删除 backend/newAgent/llm/(ark.go / ark_adapter.go / client.go / json.go)
- 等价迁移至 backend/infra/llm/,所有 newAgent node 与 service 统一改引用 infrallm
- 消除 newAgent 对模型客户端的私有依赖,为 memory / websearch 等多模块复用铺路
2. RAG 基础设施完成可运行态接入(factory / runtime / observer / service 四层成型)
- 新建 backend/infra/rag/factory.go / runtime.go / observe.go / observer.go /
service.go:工厂创建、运行时生命周期、轻量观测接口、检索服务门面
- 更新 infra/rag/config/config.go:补齐 Milvus / Embed / Reranker 全部配置项与默认值
- 更新 infra/rag/embed/eino_embedder.go:增强 Eino embedding 适配,支持 BaseURL / APIKey 环境变量 / 超时 /
维度等参数
- 更新 infra/rag/store/milvus_store.go:完整实现 Milvus 向量存储(建集合 / 建 Index / Upsert / Search /
Delete),支持 COSINE / L2 / IP 度量
- 更新 infra/rag/core/pipeline.go:适配 Runtime 接口,Pipeline 由 factory 注入而非手动拼装
- 更新 infra/rag/corpus/memory_corpus.go / vector_store.go:对接 Memory 模块数据源与 Store 接口扩展
3. Memory 模块从 Day1 骨架升级为 Day2 完整可运行态
- 新建 memory/module.go:统一门面 Module,对外封装 EnqueueExtract / ReadService / ManageService / WithTx /
StartWorker,启动层只依赖这一个入口
- 新建 memory/orchestrator/llm_write_orchestrator.go:LLM 驱动的记忆抽取编排器,替代原 mock 抽取
- 新建 memory/service/read_service.go:按用户开关过滤 + 轻量重排 + 访问时间刷新的读取链路
- 新建 memory/service/manage_service.go:记忆管理面能力(列出 / 软删除 / 开关读写),删除同步写审计日志
- 新建 memory/service/common.go:服务层公共工具
- 新建 memory/worker/loop.go:后台轮询循环 RunPollingLoop,定时抢占 pending 任务并推进
- 新建 memory/utils/audit.go / settings.go:审计日志构造、用户设置过滤等纯函数
- 更新 memory/model/item.go / job.go / settings.go / config.go / status.go:补齐 DTO 字段与状态常量
- 更新 memory/repo/item_repo.go / job_repo.go / audit_repo.go / settings_repo.go:补齐 CRUD 与查询能力
- 更新 memory/worker/runner.go:Runner 对接 Module 与 LLM 抽取器,任务状态机完整化
- 更新 memory/README.md:同步模块现状说明
4. newAgent 接入 Memory 读取注入与工具注册依赖预埋
- 新建 service/agentsvc/agent_memory.go:定义 MemoryReader 接口 + injectMemoryContext,在 graph
执行前统一补充记忆上下文
- 更新 service/agentsvc/agent.go:新增 memoryReader 字段与 SetMemoryReader 方法
- 更新 service/agentsvc/agent_newagent.go:调用 injectMemoryContext 注入 pinned block,检索失败仅降级不阻断主链路
- 更新 newAgent/tools/registry.go:新增 DefaultRegistryDeps(含 RAGRuntime),工具注册表支持依赖注入
5. 启动流程与事件处理器接线更新
- 更新 cmd/start.go:初始化 RAG Runtime → Memory Module → 注册事件处理器 → 启动 Worker 后台轮询
- 更新 service/events/memory_extract_requested.go:改用 memory.Module.WithTx(tx) 统一门面,事件处理器不再直接依赖
repo/service 内部包
6. 缓存插件与配置同步
- 更新 middleware/cache_deleter.go:静默忽略 MemoryJob / MemoryItem / MemoryAuditLog / MemoryUserSetting
等新模型,避免日志刷屏;清理冗余注释
- 更新 config.example.yaml:补齐 rag / memory / websearch 配置段及默认值
- 更新 go.mod / go.sum:新增 eino-ext/openai / json-patch / go-openai 依赖
前端:无 仓库:无
This commit is contained in:
299
backend/memory/orchestrator/llm_write_orchestrator.go
Normal file
299
backend/memory/orchestrator/llm_write_orchestrator.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
infrallm "github.com/LoveLosita/smartflow/backend/infra/llm"
|
||||
memorymodel "github.com/LoveLosita/smartflow/backend/memory/model"
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMemoryExtractMaxTokens = 1200
|
||||
defaultMemoryExtractMaxFacts = 5
|
||||
)
|
||||
|
||||
// LLMWriteOrchestrator 负责把单条对话消息转成可入库的记忆候选。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责调用 LLM 做抽取、把输出标准化成 memory_facts;
|
||||
// 2. 不负责落库,不负责任务状态机推进;
|
||||
// 3. 当 LLM 不可用或输出异常时,回退到保守的本地抽取,保证链路不完全断。
|
||||
type LLMWriteOrchestrator struct {
|
||||
client *infrallm.Client
|
||||
cfg memorymodel.Config
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewLLMWriteOrchestrator 构造 LLM 版记忆写入编排器。
|
||||
func NewLLMWriteOrchestrator(client *infrallm.Client, cfg memorymodel.Config) *LLMWriteOrchestrator {
|
||||
return &LLMWriteOrchestrator{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
logger: log.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractFacts 从单条消息中抽取可入库事实。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. 成功时返回标准化后的候选事实;
|
||||
// 2. 即使 LLM 失败,也尽量返回保守的 fallback 结果,避免 worker 空转报错;
|
||||
// 3. 只有输入本身为空时才返回空结果。
|
||||
func (o *LLMWriteOrchestrator) ExtractFacts(ctx context.Context, payload memorymodel.ExtractJobPayload) ([]memorymodel.NormalizedFact, error) {
|
||||
sourceText := strings.TrimSpace(payload.SourceText)
|
||||
if sourceText == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if o == nil || o.client == nil {
|
||||
return fallbackNormalizedFacts(payload), nil
|
||||
}
|
||||
|
||||
messages := infrallm.BuildSystemUserMessages(
|
||||
buildMemoryExtractSystemPrompt(o.cfg.ExtractPrompt),
|
||||
nil,
|
||||
buildMemoryExtractUserPrompt(payload),
|
||||
)
|
||||
|
||||
resp, rawResult, err := infrallm.GenerateJSON[memoryExtractResponse](
|
||||
ctx,
|
||||
o.client,
|
||||
messages,
|
||||
infrallm.GenerateOptions{
|
||||
Temperature: clampTemperature(o.cfg.LLMTemperature),
|
||||
MaxTokens: defaultMemoryExtractMaxTokens,
|
||||
Thinking: infrallm.ThinkingModeDisabled,
|
||||
Metadata: map[string]any{
|
||||
"stage": "memory_extract",
|
||||
"user_id": payload.UserID,
|
||||
"conversation_id": payload.ConversationID,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
if o.logger != nil {
|
||||
o.logger.Printf("[WARN] memory extract llm failed user_id=%d conversation_id=%s err=%v raw=%s",
|
||||
payload.UserID, payload.ConversationID, err, truncateForLog(rawResult))
|
||||
}
|
||||
return fallbackNormalizedFacts(payload), nil
|
||||
}
|
||||
|
||||
facts := convertExtractResponse(resp)
|
||||
normalized := memoryutils.NormalizeFacts(facts)
|
||||
if len(normalized) == 0 {
|
||||
return fallbackNormalizedFacts(payload), nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
type memoryExtractResponse struct {
|
||||
Facts []memoryExtractFact `json:"facts"`
|
||||
}
|
||||
|
||||
type memoryExtractFact struct {
|
||||
MemoryType string `json:"memory_type"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Importance float64 `json:"importance"`
|
||||
SensitivityLevel int `json:"sensitivity_level"`
|
||||
IsExplicit bool `json:"is_explicit"`
|
||||
}
|
||||
|
||||
type memoryExtractPromptInput 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 string `json:"occurred_at"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
func buildMemoryExtractSystemPrompt(override string) string {
|
||||
override = strings.TrimSpace(override)
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
|
||||
return strings.TrimSpace(`你是一个“记忆抽取器”。
|
||||
你的任务是从单条用户消息中抽取值得长期记住的事实、偏好、约束、待办线索。
|
||||
请只输出 JSON 对象,不要输出解释、不要输出 markdown。
|
||||
|
||||
输出格式:
|
||||
{
|
||||
"facts": [
|
||||
{
|
||||
"memory_type": "preference|constraint|fact|todo_hint",
|
||||
"title": "短标题",
|
||||
"content": "完整事实内容",
|
||||
"confidence": 0.0,
|
||||
"importance": 0.0,
|
||||
"sensitivity_level": 0,
|
||||
"is_explicit": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
1. 最多输出 5 条事实。
|
||||
2. 只保留稳定、未来可能复用的信息,闲聊、寒暄、一次性噪声不要记。
|
||||
3. 用户明确说“记住”或“以后提醒我”时,is_explicit 设为 true。
|
||||
4. confidence 表示这条事实是否真的值得记,取 0 到 1。
|
||||
5. importance 表示对后续提醒/陪伴的价值,取 0 到 1。
|
||||
6. sensitivity_level 取 0 到 2,数字越大越敏感。
|
||||
7. 不确定就少记,不要编造。`)
|
||||
}
|
||||
|
||||
func buildMemoryExtractUserPrompt(payload memorymodel.ExtractJobPayload) string {
|
||||
request := memoryExtractPromptInput{
|
||||
UserID: payload.UserID,
|
||||
ConversationID: payload.ConversationID,
|
||||
AssistantID: payload.AssistantID,
|
||||
RunID: payload.RunID,
|
||||
SourceMessageID: payload.SourceMessageID,
|
||||
SourceRole: payload.SourceRole,
|
||||
SourceText: payload.SourceText,
|
||||
OccurredAt: payload.OccurredAt.Format("2006-01-02 15:04:05"),
|
||||
TraceID: payload.TraceID,
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(request, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("请从这条消息中抽取可长期记住的信息:%s", payload.SourceText)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("请从下面这条用户消息中抽取可长期记住的信息,最多 %d 条。\n输入:\n%s",
|
||||
defaultMemoryExtractMaxFacts, string(raw))
|
||||
}
|
||||
|
||||
func convertExtractResponse(resp *memoryExtractResponse) []memorymodel.FactCandidate {
|
||||
if resp == nil || len(resp.Facts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]memorymodel.FactCandidate, 0, len(resp.Facts))
|
||||
for _, fact := range resp.Facts {
|
||||
memoryType := memorymodel.NormalizeMemoryType(fact.MemoryType)
|
||||
if memoryType == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(fact.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
confidence := clamp01(fact.Confidence)
|
||||
if confidence == 0 {
|
||||
confidence = 0.6
|
||||
}
|
||||
|
||||
importance := clamp01(fact.Importance)
|
||||
if importance == 0 {
|
||||
importance = defaultImportanceByType(memoryType)
|
||||
}
|
||||
|
||||
result = append(result, memorymodel.FactCandidate{
|
||||
MemoryType: memoryType,
|
||||
Title: strings.TrimSpace(fact.Title),
|
||||
Content: content,
|
||||
Confidence: confidence,
|
||||
Importance: importance,
|
||||
SensitivityLevel: clampInt(fact.SensitivityLevel, 0, 2),
|
||||
IsExplicit: fact.IsExplicit,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fallbackNormalizedFacts(payload memorymodel.ExtractJobPayload) []memorymodel.NormalizedFact {
|
||||
sourceText := strings.TrimSpace(payload.SourceText)
|
||||
if sourceText == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return memoryutils.NormalizeFacts([]memorymodel.FactCandidate{
|
||||
{
|
||||
MemoryType: memorymodel.MemoryTypeFact,
|
||||
Title: buildFallbackTitle(sourceText),
|
||||
Content: sourceText,
|
||||
Confidence: 0.55,
|
||||
Importance: defaultImportanceByType(memorymodel.MemoryTypeFact),
|
||||
SensitivityLevel: 0,
|
||||
IsExplicit: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func buildFallbackTitle(sourceText string) string {
|
||||
runes := []rune(strings.TrimSpace(sourceText))
|
||||
if len(runes) == 0 {
|
||||
return "用户提到"
|
||||
}
|
||||
if len(runes) > 24 {
|
||||
runes = runes[:24]
|
||||
}
|
||||
return "用户提到:" + string(runes)
|
||||
}
|
||||
|
||||
func clampTemperature(v float64) float64 {
|
||||
if v <= 0 {
|
||||
return 0.1
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clampInt(v, minValue, maxValue int) int {
|
||||
if v < minValue {
|
||||
return minValue
|
||||
}
|
||||
if v > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func defaultImportanceByType(memoryType string) float64 {
|
||||
switch memoryType {
|
||||
case memorymodel.MemoryTypePreference:
|
||||
return 0.85
|
||||
case memorymodel.MemoryTypeConstraint:
|
||||
return 0.95
|
||||
case memorymodel.MemoryTypeTodoHint:
|
||||
return 0.8
|
||||
default:
|
||||
return 0.6
|
||||
}
|
||||
}
|
||||
|
||||
func truncateForLog(raw *infrallm.TextResult) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(raw.Text)
|
||||
if len(text) <= 200 {
|
||||
return text
|
||||
}
|
||||
return text[:200] + "..."
|
||||
}
|
||||
@@ -8,36 +8,33 @@ import (
|
||||
memoryutils "github.com/LoveLosita/smartflow/backend/memory/utils"
|
||||
)
|
||||
|
||||
// WriteOrchestrator 是写入链路编排器(Day1 首版)。
|
||||
// WriteOrchestrator 是 Day1 的本地回退版本。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. Day1 只做 mock 抽取 + 标准化,不接 LLM 决策;
|
||||
// 2. Day2/Day3 再引入冲突消解、重排与向量召回。
|
||||
// 1. 只做最保守的“从 source_text 直接生成一条候选事实”;
|
||||
// 2. 不依赖 LLM,便于在模型不可用时保底;
|
||||
// 3. 后续会逐步被 LLM 版编排器取代,但不会直接删掉,方便回退。
|
||||
type WriteOrchestrator struct{}
|
||||
|
||||
func NewWriteOrchestrator() *WriteOrchestrator {
|
||||
return &WriteOrchestrator{}
|
||||
}
|
||||
|
||||
// ExtractFacts 执行“候选事实抽取 -> 标准化”链路。
|
||||
//
|
||||
// Day1 策略:
|
||||
// 1. 先用 source_text 直接构造候选事实,确保链路可跑通;
|
||||
// 2. 后续再替换成 LLM 抽取与结构化决策。
|
||||
// ExtractFacts 执行最小回退链路。
|
||||
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,
|
||||
},
|
||||
}
|
||||
candidates := []memorymodel.FactCandidate{{
|
||||
MemoryType: memorymodel.MemoryTypeFact,
|
||||
Title: "用户提到",
|
||||
Content: sourceText,
|
||||
Confidence: 0.6,
|
||||
Importance: 0.6,
|
||||
SensitivityLevel: 0,
|
||||
IsExplicit: false,
|
||||
}}
|
||||
return memoryutils.NormalizeFacts(candidates), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user