diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index ff274860..c2cfb383 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -405,10 +405,10 @@ func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *in projName = "当前项目" } - // 若用户输入实质上包含具体的技术测试、方案、难点或业务要点询问,不应机械回复商业接待犹豫套话 - if strings.Contains(rawInput, "测试") || strings.Contains(rawInput, "验证") || strings.Contains(rawInput, "型检") || + // 只在低置信度的模糊咨询里,才用关键词改走测试分析。高置信度保持原意图。 + if cand.Confidence < 0.6 && (strings.Contains(rawInput, "测试") || strings.Contains(rawInput, "验证") || strings.Contains(rawInput, "型检") || strings.Contains(rawInput, "难点") || strings.Contains(rawInput, "注意") || strings.Contains(rawInput, "比较难") || - strings.Contains(rawInput, "方案") { + strings.Contains(rawInput, "方案")) { if cand.FilledSlots == nil { cand.FilledSlots = make(map[string]string) } @@ -482,8 +482,14 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten projName = "肌骨超声AI辅助诊断系统" } + greet := strings.TrimSpace(userCtx.User.Name) + if greet == "" { + greet = "您好" + } else { + greet += ",您好" + } var sb strings.Builder - sb.WriteString(fmt.Sprintf("张工您好!针对「%s」的系统验证方案(SVP)与型式检验要求,我为您梳理以下核心难点项目与特别注意事项:\n\n", projName)) + sb.WriteString(fmt.Sprintf("%s!针对「%s」的系统验证方案(SVP)与型式检验要求,我为您梳理以下核心难点项目与特别注意事项:\n\n", greet, projName)) sb.WriteString("### 一、 核心测试难点项目\n\n") sb.WriteString("1. **声学输出安全与探头表面温升极限(GB 9706.237-2020)**\n") sb.WriteString(" • **难点分析**:肌骨超声常需配合高频线阵探头(10-18MHz)进行浅表滑膜微血流彩色多普勒(CDFI/Power Doppler)检测。连续高占空比发射脉冲会导致探头声透镜声吸收发热。根据强标要求,探头施加于人体的表面温升严禁超过 **41℃**,同时机械指数(MI)需严格低于 1.9,热指数(TIS/TIB)需低于 1.0。\n") @@ -499,7 +505,7 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten sb.WriteString("• **电磁兼容(EMC GB 9706.102-2021)抗扰度储备**:高频微弱回波信号接收通道抗射频电磁场辐射敏感度高,测试方案中需提前验证探头线缆双层屏蔽接地与机箱屏蔽缝隙,避免正式型检时辐射发射(RE)超标返工;\n") sb.WriteString("• **DICOM 与数据脱敏合规**:软件系统测试中,所有导出样本、测试影像的患者个人隐私标识(PII)脱敏规则需执行 100% 覆盖率验证;\n") sb.WriteString("• **极端异常输入与容错安全防呆**:探头带电热插拔、探头声透镜声胶脱落阻抗异常检测、长时间无操作探头自动降频防发热休眠逻辑,均需设计破坏性负向测试用例。\n\n") - sb.WriteString("💡 **建议行动**:张工,若您需要,我可以为您起草该模块对应的《系统验证方案 (SVP) 关键测试用例矩阵》,列出详细的测试条件、前置配置与合格判据。") + sb.WriteString("💡 **建议行动**:若您需要,我可以为您起草该模块对应的《系统验证方案 (SVP) 关键测试用例矩阵》,列出详细的测试条件、前置配置与合格判据。") return &CoordinationResult{ Status: StatusAccepted, @@ -928,10 +934,10 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent. var projectID, userID int64 if userCtx.Project.ID != "" { - fmt.Sscanf(userCtx.Project.ID, "%d", &projectID) + projectID = parseDecimalID(userCtx.Project.ID) } if userCtx.User.ID != "" { - fmt.Sscanf(userCtx.User.ID, "%d", &userID) + userID = parseDecimalID(userCtx.User.ID) } taskRes, err := e.tasks().ExecuteTask(ctx, stageflow.TaskRunRequest{ @@ -1065,10 +1071,10 @@ func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *in var projectID, userID int64 if userCtx.Project.ID != "" { - fmt.Sscanf(userCtx.Project.ID, "%d", &projectID) + projectID = parseDecimalID(userCtx.Project.ID) } if userCtx.User.ID != "" { - fmt.Sscanf(userCtx.User.ID, "%d", &userID) + userID = parseDecimalID(userCtx.User.ID) } if st := stream.FromContext(ctx); st != nil { @@ -1822,3 +1828,17 @@ func (e *CoordinatorEngine) handleQueryUserRights(ctx context.Context, cand *int UpdatedContext: userCtx, }, nil } + +// parseDecimalID accepts only a non-negative decimal integer. +// Values like u_sess_* must not become UserID 0 via a partial Sscanf match. +func parseDecimalID(raw string) int64 { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n < 0 { + return 0 + } + return n +} diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index 6776105d..4077eba3 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -981,4 +981,10 @@ func TestCoordinatorEngine_TestingAnalysis(t *testing.T) { if strings.Contains(res.NaturalReply, "完全理解您想再掂量一下") { t.Errorf("reply must not contain commercial triage boilerplate, got: %s", res.NaturalReply) } + if !strings.Contains(res.NaturalReply, "张工,您好") { + t.Errorf("greeting must use the user name, got: %s", res.NaturalReply) + } + if strings.Contains(res.NaturalReply, "张工您好") { + t.Errorf("hardcoded greeting must be gone, got: %s", res.NaturalReply) + } } diff --git a/internal/coordinator/parse_id_test.go b/internal/coordinator/parse_id_test.go new file mode 100644 index 00000000..adb5ca19 --- /dev/null +++ b/internal/coordinator/parse_id_test.go @@ -0,0 +1,18 @@ +package coordinator + +import "testing" + +func TestParseDecimalIDRejectsSessionTokens(t *testing.T) { + if got := parseDecimalID("u_sess_12"); got != 0 { + t.Fatalf("session token parsed as %d", got) + } + if got := parseDecimalID("108abc"); got != 0 { + t.Fatalf("partial decimal parsed as %d", got) + } + if got := parseDecimalID("108"); got != 108 { + t.Fatalf("got %d", got) + } + if got := parseDecimalID(" 0 "); got != 0 { + t.Fatalf("got %d", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d894483e..78447c65 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -406,6 +406,32 @@ func (s *Server) cleanStaleSessionsLocked(ttl time.Duration) int { return cleaned } +const maxLiveSessions = 4096 + +func (s *Server) enforceSessionCapLocked() { + for len(s.sessions) >= maxLiveSessions { + var oldestID string + var oldest time.Time + found := false + for id, sess := range s.sessions { + if sess == nil { + delete(s.sessions, id) + continue + } + at := sess.LastAccessed() + if !found || at.Before(oldest) { + found = true + oldest = at + oldestID = id + } + } + if !found || oldestID == "" { + return + } + delete(s.sessions, oldestID) + } +} + // getOrCreateSession 获取或动态构建会话。callerUserID>0 时校验持久层归属,不匹配返回 ErrSessionOwnerMismatch。 func (s *Server) getOrCreateSession( ctx context.Context, @@ -456,10 +482,12 @@ func (s *Server) getOrCreateSession( return existing, nil } - // 惰性内存防泄漏保护 (会话数超过 200 时清理空闲超 120 分钟会话) + // 惰性清理,并在仍超过硬上限时按最近访问淘汰,避免随机 session id 撑爆内存。 + // 会话 id 仍由客户端提供:历史恢复按该 id 查找,不能改成只接受服务端签发。 if len(s.sessions) > 200 { s.cleanStaleSessionsLocked(120 * time.Minute) } + s.enforceSessionCapLocked() // 尝试从持久层恢复会话上下文 (服务重启后跨进程保持 G3) var restored *ctxmgr.RestoredSessionContext diff --git a/internal/server/session_cap_test.go b/internal/server/session_cap_test.go new file mode 100644 index 00000000..552b4246 --- /dev/null +++ b/internal/server/session_cap_test.go @@ -0,0 +1,33 @@ +package server + +import ( + "fmt" + "testing" + "time" + + "chat-agent-system/internal/chat" +) + +func TestEnforceSessionCapEvictsOldest(t *testing.T) { + s := &Server{sessions: map[string]*chat.Session{}} + oldest := chat.NewSession("oldest", nil, nil, nil) + oldest.TouchLastAccessed() + s.sessions[oldest.ID] = oldest + time.Sleep(5 * time.Millisecond) + for i := 0; i < maxLiveSessions-1; i++ { + id := fmt.Sprintf("s%d", i) + sess := chat.NewSession(id, nil, nil, nil) + sess.TouchLastAccessed() + s.sessions[id] = sess + } + if len(s.sessions) != maxLiveSessions { + t.Fatalf("setup len=%d", len(s.sessions)) + } + s.enforceSessionCapLocked() + if _, ok := s.sessions["oldest"]; ok { + t.Fatal("oldest session must be evicted at the cap") + } + if len(s.sessions) != maxLiveSessions-1 { + t.Fatalf("len=%d", len(s.sessions)) + } +}