From 5acddb9ecb61c1168e800fa9b41aaa7e68f7ede3 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:54:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E7=BB=8F=E9=AA=8C=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=20-=20=E6=8C=89=E4=BB=BB=E5=8A=A1=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=BA=A6=E6=8E=92=E5=BA=8F=E5=8F=AC=E5=9B=9E=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E6=B8=85=E7=90=86=E5=BE=85=E6=89=B9=E5=87=86?= =?UTF-8?q?=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 召回按主题、情境和中文双字命中加权;30天未处理的待批准记忆会在记录和压缩调度时过期,成功计数写入运行态以免事件窗口截断后误拦。 --- backend/internal/app/cloud_agent_lessons.go | 104 +++++++++++++---- .../internal/app/cloud_agent_lessons_test.go | 107 ++++++++++++++++++ .../app/cloud_agent_memory_compact.go | 5 +- backend/internal/app/cloud_agent_runtime.go | 8 +- .../internal/app/cloud_agent_runtime_state.go | 3 + .../internal/app/cloud_agent_runtime_tools.go | 8 ++ backend/internal/repository/agent_lesson.go | 22 ++++ 7 files changed, 229 insertions(+), 28 deletions(-) diff --git a/backend/internal/app/cloud_agent_lessons.go b/backend/internal/app/cloud_agent_lessons.go index 5216cb1c3..ebb5d3c74 100644 --- a/backend/internal/app/cloud_agent_lessons.go +++ b/backend/internal/app/cloud_agent_lessons.go @@ -48,6 +48,7 @@ const ( cloudAgentLessonSearchTokenMax = 8 cloudAgentRememberLessonMaxPerRun = 3 cloudAgentRememberLessonPendingPerUser = 50 + cloudAgentPendingLessonTTL = 30 * 24 * time.Hour cloudAgentRememberLessonApprovedMax = 300 cloudAgentMemoryImportMax = 200 ) @@ -121,12 +122,16 @@ func cloudAgentRememberLesson(repo *repository.Repository, userID string, state if cloudAgentRememberLessonCount(state) >= cloudAgentRememberLessonMaxPerRun { return nil, BadAuthRequest(fmt.Sprintf("本轮最多记录 %d 条经验,请合并成更通用的一条", cloudAgentRememberLessonMaxPerRun)) } + now := time.Now() + if _, err := repo.ExpirePendingAgentLessons(userID, now.Add(-cloudAgentPendingLessonTTL), now, cloudAgentRememberLessonPendingPerUser); err != nil { + return nil, err + } pending, err := repo.CountAgentLessonsByAuthor(userID, model.AgentLessonStatusPending) if err != nil { return nil, err } if pending >= cloudAgentRememberLessonPendingPerUser { - return nil, BadAuthRequest("待你批准的记忆已经比较多,请先到「设置 → Agent 记忆」处理后再记新的") + return nil, BadAuthRequest(fmt.Sprintf("当前有 %d 条待批准记忆,已达到上限 %d;请先到「设置 → Agent 记忆」批准或删除后再记新的", pending, cloudAgentRememberLessonPendingPerUser)) } var args struct { Topic string `json:"topic"` @@ -170,7 +175,6 @@ func cloudAgentRememberLesson(repo *repository.Repository, userID string, state return nil, err } } - now := time.Now() entry := &model.AgentLesson{ ID: newID(), Topic: topic, Situation: situation, Lesson: lesson, Source: source, Category: cloudAgentNormalizeLessonCategory(args.Category), @@ -214,13 +218,23 @@ func cloudAgentLessonEligibleSuccesses(state *cloudAgentRuntime) int { continue } name, _ := event.Payload["toolName"].(string) - switch name { - case "", "skills_load", "remember_lesson", "recall_lessons", "agent_profile_read", "plan_update", "ask_user": - continue + if cloudAgentLessonToolEligible(name) { + count++ } - count++ } - return count + if count > state.LessonEligibleToolSuccesses { + return count + } + return state.LessonEligibleToolSuccesses +} + +func cloudAgentLessonToolEligible(name string) bool { + switch name { + case "", "skills_load", "remember_lesson", "recall_lessons", "agent_profile_read", "plan_update", "ask_user": + return false + default: + return true + } } func cloudAgentRememberLessonCount(state *cloudAgentRuntime) int { @@ -236,7 +250,10 @@ func cloudAgentRememberLessonCount(state *cloudAgentRuntime) int { count++ } } - return count + if count > state.RememberLessonSuccesses { + return count + } + return state.RememberLessonSuccesses } func cloudAgentLessonFingerprint(situation, lesson, stepsJSON string) string { @@ -397,20 +414,34 @@ func (s *Service) cloudAgentLessonsBlock(userID, taskText string) string { } func cloudAgentPickLessonIndex(lessons []model.AgentLesson, taskText string, limit int) ([]model.AgentLesson, int) { - matched := make([]model.AgentLesson, 0) - rest := make([]model.AgentLesson, 0, len(lessons)) - for _, lesson := range lessons { - if cloudAgentLessonMatchesTask(lesson, taskText) { - matched = append(matched, lesson) - } else { - rest = append(rest, lesson) - } + type rankedLesson struct { + lesson model.AgentLesson + score int + } + ranked := make([]rankedLesson, 0, len(lessons)) + for _, lesson := range lessons { + ranked = append(ranked, rankedLesson{lesson: lesson, score: cloudAgentLessonTaskScore(lesson, taskText)}) + } + sort.SliceStable(ranked, func(i, j int) bool { + if ranked[i].score != ranked[j].score { + return ranked[i].score > ranked[j].score + } + if ranked[i].lesson.Hits != ranked[j].lesson.Hits { + return ranked[i].lesson.Hits > ranked[j].lesson.Hits + } + return ranked[i].lesson.UpdatedAt.After(ranked[j].lesson.UpdatedAt) + }) + index := make([]model.AgentLesson, 0, len(ranked)) + matchedN := 0 + for _, item := range ranked { + if item.score > 0 { + matchedN++ + } + index = append(index, item.lesson) } - index := append(matched, rest...) if limit > 0 && len(index) > limit { index = index[:limit] } - matchedN := len(matched) if matchedN > len(index) { matchedN = len(index) } @@ -476,20 +507,34 @@ func cloudAgentLessonsBlock(view cloudAgentLessonView) string { } func cloudAgentLessonMatchesTask(lesson model.AgentLesson, taskText string) bool { + return cloudAgentLessonTaskScore(lesson, taskText) > 0 +} + +func cloudAgentLessonTaskScore(lesson model.AgentLesson, taskText string) int { haystack := cloudAgentLessonNormalize(taskText) if haystack == "" { - return false + return 0 } + score := 0 for _, needle := range cloudAgentLessonNeedles(lesson) { - if utf8.RuneCountInString(needle) >= 3 && strings.Contains(haystack, needle) { - return true + runes := []rune(needle) + isHanPhrase := len(runes) >= 2 && unicode.Is(unicode.Han, runes[0]) && unicode.Is(unicode.Han, runes[1]) + if (!isHanPhrase && utf8.RuneCountInString(needle) < 3) || !strings.Contains(haystack, needle) { + continue } + weight := 1 + if strings.Contains(cloudAgentLessonNormalize(lesson.Topic), needle) { + weight = 4 + } else if strings.Contains(cloudAgentLessonNormalize(lesson.Situation), needle) { + weight = 2 + } + score += weight } - return false + return score } func cloudAgentLessonNeedles(lesson model.AgentLesson) []string { - needles := make([]string, 0, 8) + needles := make([]string, 0, 24) seen := map[string]bool{} push := func(value string) { value = cloudAgentLessonNormalize(value) @@ -499,6 +544,14 @@ func cloudAgentLessonNeedles(lesson model.AgentLesson) []string { seen[value] = true needles = append(needles, value) } + pushCJKBigrams := func(value string) { + runes := []rune(cloudAgentLessonNormalize(value)) + for index := 0; index+1 < len(runes); index++ { + if unicode.Is(unicode.Han, runes[index]) && unicode.Is(unicode.Han, runes[index+1]) { + push(string(runes[index : index+2])) + } + } + } var steps []model.AgentLessonStep if json.Unmarshal([]byte(lesson.StepsJSON), &steps) == nil { for _, step := range steps { @@ -509,13 +562,16 @@ func cloudAgentLessonNeedles(lesson model.AgentLesson) []string { return r == '.' || r == '-' || r == '_' || r == ' ' }) { push(chunk) + pushCJKBigrams(chunk) } + pushCJKBigrams(lesson.Situation) return needles } func cloudAgentLessonGenericToken(token string) bool { switch token { - case "video", "image", "canvas", "storyboard", "shot", "step", "task", "model", "with", "and", "the": + case "video", "image", "canvas", "storyboard", "shot", "step", "task", "model", "with", "and", "the", + "画布", "分镜", "镜头", "视频", "图片", "生成", "任务", "操作", "处理", "使用", "通过", "进行", "一个", "这个", "可以", "需要", "如果", "然后", "之后", "之前", "结果", "内容", "相关": return true } return false diff --git a/backend/internal/app/cloud_agent_lessons_test.go b/backend/internal/app/cloud_agent_lessons_test.go index a404577c7..65516e5b8 100644 --- a/backend/internal/app/cloud_agent_lessons_test.go +++ b/backend/internal/app/cloud_agent_lessons_test.go @@ -736,3 +736,110 @@ func TestAdminAgentLessonsFiltersByUser(t *testing.T) { t.Fatalf("不筛选时应看到全部:%d(%v)", len(all), err) } } + +func TestRememberLessonGateSurvivesEventWindowAndCheckpoint(t *testing.T) { + state := &cloudAgentRuntime{LessonEligibleToolSuccesses: 1} + for i := 0; i < repository.CloudAgentJournalWindow; i++ { + state.Events = append(state.Events, CloudAgentEvent{Type: "message_delta"}) + } + if got := cloudAgentLessonEligibleSuccesses(state); got != 1 { + t.Fatalf("delta 窗口不得抹掉已记录的合格成功数:got %d", got) + } + + state.Events = nil + run := &model.CloudAgentExecution{} + if err := cloudAgentSave(run, state); err != nil { + t.Fatal(err) + } + restored, err := cloudAgentDecode(run) + if err != nil { + t.Fatal(err) + } + if restored.LessonEligibleToolSuccesses != 1 || cloudAgentLessonEligibleSuccesses(&restored) != 1 { + t.Fatalf("checkpoint 恢复后写入门禁必须保持:%+v", restored) + } +} + +func TestPendingAgentLessonsExpireWithoutTouchingApproved(t *testing.T) { + repo := agentLessonTestRepo(t) + now := time.Now() + old := now.Add(-cloudAgentPendingLessonTTL - time.Hour) + entries := []*model.AgentLesson{ + {ID: newID(), Topic: "old-pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: old, UpdatedAt: old}, + {ID: newID(), Topic: "fresh-pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now}, + {ID: newID(), Topic: "old-approved", Status: model.AgentLessonStatusApproved, AuthorUserID: "user-a", CreatedAt: old, UpdatedAt: old}, + } + for _, entry := range entries { + if err := repo.Create(entry); err != nil { + t.Fatal(err) + } + } + changed, err := repo.ExpirePendingAgentLessons("", now.Add(-cloudAgentPendingLessonTTL), now, 500) + if err != nil || changed != 1 { + t.Fatalf("应只过期一条旧 pending:changed=%d err=%v", changed, err) + } + for _, want := range []struct{ id, status string }{ + {entries[0].ID, model.AgentLessonStatusRejected}, + {entries[1].ID, model.AgentLessonStatusPending}, + {entries[2].ID, model.AgentLessonStatusApproved}, + } { + got, findErr := repo.AgentLessonForUser("user-a", want.id) + if findErr != nil || got.Status != want.status { + t.Fatalf("记忆 %s 状态不符:got=%+v err=%v", want.id, got, findErr) + } + } +} + +func TestRememberLessonExpiresOldPendingBeforeApplyingLimit(t *testing.T) { + repo := agentLessonTestRepo(t) + now := time.Now() + old := now.Add(-cloudAgentPendingLessonTTL - time.Hour) + for index := 0; index < cloudAgentRememberLessonPendingPerUser; index++ { + created := now + if index == 0 { + created = old + } + entry := &model.AgentLesson{ID: newID(), Topic: "pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: created, UpdatedAt: created} + if err := repo.Create(entry); err != nil { + t.Fatal(err) + } + } + state := &cloudAgentRuntime{ + Request: CloudAgentRequest{PermissionMode: "auto"}, + LessonEligibleToolSuccesses: 1, + } + call := rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`) + if _, err := cloudAgentRememberLesson(repo, "user-a", state, call); err != nil { + t.Fatalf("过期一条后应可新建待审记忆:%v", err) + } + pending, err := repo.CountAgentLessonsByAuthor("user-a", model.AgentLessonStatusPending) + if err != nil || pending != cloudAgentRememberLessonPendingPerUser { + t.Fatalf("新写入后待审数应回到上限:%d (%v)", pending, err) + } +} + +func TestRememberLessonPendingLimitErrorShowsCountAndSettings(t *testing.T) { + repo := agentLessonTestRepo(t) + now := time.Now() + for index := 0; index < cloudAgentRememberLessonPendingPerUser; index++ { + entry := &model.AgentLesson{ID: newID(), Topic: "pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now} + if err := repo.Create(entry); err != nil { + t.Fatal(err) + } + } + state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto"}, LessonEligibleToolSuccesses: 1} + _, err := cloudAgentRememberLesson(repo, "user-a", state, rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`)) + if err == nil || !strings.Contains(err.Error(), "50") || !strings.Contains(err.Error(), "设置 → Agent 记忆") { + t.Fatalf("拒绝信息应包含待审数量和处理入口:%v", err) + } +} + +func TestLessonIndexRanksTaskRelevanceBeforeHitsAndMatchesTwoHanRunes(t *testing.T) { + now := time.Now() + unrelated := model.AgentLesson{ID: "unrelated", Topic: "video.duration", Situation: "竖屏视频生成时长", Hits: 9000, UpdatedAt: now} + relevant := model.AgentLesson{ID: "relevant", Topic: "对象绑定", Situation: "预演对象绑定角色时", Hits: 0, UpdatedAt: now.Add(-time.Hour)} + index, matched := cloudAgentPickLessonIndex([]model.AgentLesson{unrelated, relevant}, "请处理对象绑定", 20) + if matched != 1 || len(index) != 2 || index[0].ID != "relevant" { + t.Fatalf("任务相关记忆应先于高频无关记忆,且两字短语应命中:matched=%d index=%+v", matched, index) + } +} diff --git a/backend/internal/app/cloud_agent_memory_compact.go b/backend/internal/app/cloud_agent_memory_compact.go index 3f231e8ec..c58fa624e 100644 --- a/backend/internal/app/cloud_agent_memory_compact.go +++ b/backend/internal/app/cloud_agent_memory_compact.go @@ -217,12 +217,15 @@ func (s *Service) startAgentMemoryCompactScheduler() { } func (s *Service) dispatchDueAgentMemoryCompacts() { + now := time.Now() + if _, err := s.repo.ExpirePendingAgentLessons("", now.Add(-cloudAgentPendingLessonTTL), now, 500); err != nil { + log.Printf("agent memory pending cleanup: %v", err) + } rows, err := s.repo.ScheduledAgentMemorySettings(50) if err != nil { log.Printf("agent memory compact schedule: %v", err) return } - now := time.Now() started := 0 for index := range rows { setting := rows[index] diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index dfe3b52ef..bee35b9b1 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -169,9 +169,11 @@ type cloudAgentRuntime struct { // PiAssistantResponses counts successful assistant message_end events from // the Pi runtime. Completion must not be inferred from a clean Node exit: // a provider/session error can otherwise be reported as a successful run. - PiAssistantResponses int `json:"piAssistantResponses,omitempty"` - IsGenerating bool `json:"isGenerating,omitempty"` - LastError string `json:"lastError,omitempty"` + PiAssistantResponses int `json:"piAssistantResponses,omitempty"` + LessonEligibleToolSuccesses int `json:"lessonEligibleToolSuccesses,omitempty"` + RememberLessonSuccesses int `json:"rememberLessonSuccesses,omitempty"` + IsGenerating bool `json:"isGenerating,omitempty"` + LastError string `json:"lastError,omitempty"` // EmptyOutputEscalated 记录"空输出已经升级重试过几次"(关思考 + 放大输出预算)。 EmptyOutputEscalated int `json:"emptyOutputEscalated,omitempty"` // StepTimeoutEscalated 记录"单步墙钟到点后已经关思考重试过几次"。 diff --git a/backend/internal/app/cloud_agent_runtime_state.go b/backend/internal/app/cloud_agent_runtime_state.go index 31d8de4c5..41d1de677 100644 --- a/backend/internal/app/cloud_agent_runtime_state.go +++ b/backend/internal/app/cloud_agent_runtime_state.go @@ -142,6 +142,9 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent if state.Step < 0 || state.Generations < 0 || state.VideoSeconds < 0 { return errors.New("Agent runtime budget or step is invalid") } + if state.LessonEligibleToolSuccesses < 0 || state.RememberLessonSuccesses < 0 { + return errors.New("Agent lesson success counters are invalid") + } if state.ConfirmationRounds < 0 || state.ConfirmationRounds > cloudAgentMaxConfirmationRounds { return errors.New("Agent runtime confirmation round is invalid") } diff --git a/backend/internal/app/cloud_agent_runtime_tools.go b/backend/internal/app/cloud_agent_runtime_tools.go index e6adb1629..6fa44b030 100644 --- a/backend/internal/app/cloud_agent_runtime_tools.go +++ b/backend/internal/app/cloud_agent_runtime_tools.go @@ -16,6 +16,14 @@ import ( ) func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error) bool { + if state != nil && err == nil { + if cloudAgentLessonToolEligible(call.Function.Name) { + state.LessonEligibleToolSuccesses++ + } + if call.Function.Name == "remember_lesson" { + state.RememberLessonSuccesses++ + } + } payload := map[string]any{"toolName": call.Function.Name, "callId": call.ID, "arguments": call.Function.Arguments} if call.Function.Name == "skill_read_file" { var args struct { diff --git a/backend/internal/repository/agent_lesson.go b/backend/internal/repository/agent_lesson.go index d565080ba..17c266c1d 100644 --- a/backend/internal/repository/agent_lesson.go +++ b/backend/internal/repository/agent_lesson.go @@ -125,6 +125,28 @@ func (r *Repository) CountAgentLessonsByAuthor(userID, status string) (int64, er return count, err } +func (r *Repository) ExpirePendingAgentLessons(userID string, createdBefore, updatedAt time.Time, limit int) (int64, error) { + if limit <= 0 || limit > 1000 { + limit = 500 + } + query := r.db.Model(&model.AgentLesson{}).Select("id"). + Where("status = ? AND created_at <= ?", model.AgentLessonStatusPending, createdBefore) + if userID = strings.TrimSpace(userID); userID != "" { + query = query.Where("author_user_id = ?", userID) + } + var ids []string + if err := query.Order("created_at ASC").Limit(limit).Pluck("id", &ids).Error; err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + update := r.db.Model(&model.AgentLesson{}). + Where("id IN ? AND status = ?", ids, model.AgentLessonStatusPending). + Updates(map[string]any{"status": model.AgentLessonStatusRejected, "updated_at": updatedAt}) + return update.RowsAffected, update.Error +} + func (r *Repository) AdminAgentLessons(status, userID, keyword string, limit int) ([]model.AgentLesson, error) { query := r.db.Model(&model.AgentLesson{}) if trimmed := strings.TrimSpace(status); trimmed != "" {