package handler // ======================================================================== // ObserveHandler —— /agent/view 面板观测与调试接口 // ======================================================================== // 端点清单(全部走全局 Auth,无放行): // GET /api/v1/agent/logs 增量日志(内存环形缓冲) // GET /api/v1/agent/system 进程运行时状态 // GET /api/v1/agent/config Agent 配置只读视图(含守卫词表) // POST /api/v1/agent/guard-test 医疗守卫测试台 // POST /api/v1/agent/kb-test 知识库检索测试 // POST /api/v1/models/test 模型连通性测试(注册在 modelGroup) // // 安全边界: // - 全部只读或无持久副作用(ModelTest 消耗少量 token) // - 日志可能含 PHI(debug 开关打开时),logs 接口必须有 Auth // - config 视图不返回任何密钥(EmbeddingAPIKey 等一律不出) // ======================================================================== import ( "runtime" "strconv" "time" "tcm-agent/internal/agentcfg" "tcm-agent/internal/service" "github.com/gin-gonic/gin" ) // ObserveHandler 观测接口处理器 type ObserveHandler struct { enhancer *service.EnhancerService // 复用 enhance 同款检索/模型解析逻辑 startedAt time.Time // 进程启动时间(算 uptime) } // NewObserveHandler 构造函数(router.Setup 启动时创建一次) func NewObserveHandler(enhancer *service.EnhancerService) *ObserveHandler { return &ObserveHandler{ enhancer: enhancer, startedAt: time.Now(), } } // Logs 增量拉取内存日志 // // GET /api/v1/agent/logs?since_id=0&limit=200 // 面板首次加载 since_id=0 全量拉,之后带上一次返回的最大 ID 增量拉 func (h *ObserveHandler) Logs(c *gin.Context) { sinceID, _ := strconv.ParseInt(c.DefaultQuery("since_id", "0"), 10, 64) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200")) entries := service.MemLog.List(sinceID, limit) // 返回最大 ID 作为下次轮询游标(无新日志时沿用请求值) lastID := sinceID if len(entries) > 0 { lastID = entries[len(entries)-1].ID } c.JSON(200, gin.H{ "code": 200, "data": gin.H{ "entries": entries, "last_id": lastID, }, }) } // System 进程运行时状态 // // GET /api/v1/agent/system // 面板「系统状态」Tab 5s 轮询,全部从 runtime 取,零外部依赖 func (h *ObserveHandler) System(c *gin.Context) { var ms runtime.MemStats runtime.ReadMemStats(&ms) concUsed, concCap := service.GetConcurrency() runUsed, runCap := service.RunLogUsage() c.JSON(200, gin.H{ "code": 200, "data": gin.H{ "started_at": h.startedAt.Unix(), "uptime_seconds": int64(time.Since(h.startedAt).Seconds()), "go_version": runtime.Version(), "goroutines": runtime.NumGoroutine(), // 内存三个关键指标:堆占用 / 累计分配 / 从 OS 拿到的总量 "heap_alloc_mb": float64(ms.HeapAlloc) / 1024 / 1024, "sys_mb": float64(ms.Sys) / 1024 / 1024, "num_gc": ms.NumGC, // enhance 并发状态(信号量探针) "enhance_concurrency": gin.H{"used": concUsed, "capacity": concCap}, // RunLog 缓冲占用 "runlog_usage": gin.H{"used": runUsed, "capacity": runCap}, }, }) } // Config Agent 配置只读视图 // // GET /api/v1/agent/config // 数据来自 agentcfg.Get()(xk_system_config 实时加载,30s 缓存)+ 守卫词表。 // 面板只展示不修改——开关的写入口统一在 PHP 后台,避免双写 func (h *ObserveHandler) Config(c *gin.Context) { cfg := agentcfg.Get() white, black := service.MedicalGuardKeywords() c.JSON(200, gin.H{ "code": 200, "data": gin.H{ "react": gin.H{ "enabled": cfg.ReAct.Enabled, "max_iterations": cfg.ReAct.MaxIterations, "planning_enabled": cfg.ReAct.PlanningEnabled, "reflection_enabled": cfg.ReAct.ReflectionEnabled, "json_repair_enabled": cfg.ReAct.JSONRepairEnabled, }, "token_budget": gin.H{ "enabled": cfg.TokenBudget.Enabled, "per_request": cfg.TokenBudget.PerRequest, "max_tokens_per_call": cfg.TokenBudget.MaxTokensPerCall, }, "kb": gin.H{ "source": cfg.KB.Source, "embedding_provider": cfg.KB.EmbeddingProvider, "top_k": cfg.KB.TopK, "search_mode": cfg.KB.SearchMode, }, "medical_guard": gin.H{ "enabled": cfg.MedicalGuard.Enabled, "whitelist": white, "blacklist": black, }, "debug": gin.H{ "log_request_body": cfg.Debug.LogRequestBody, }, }, }) } // GuardTest 医疗守卫测试台 // // POST /api/v1/agent/guard-test body: {"text": "..."} // 用与 Enhance 入口完全相同的守卫逻辑跑一遍给定文本 func (h *ObserveHandler) GuardTest(c *gin.Context) { var req struct { Text string `json:"text" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"code": 400, "message": "text 必填"}) return } result := service.GuardTest(req.Text) c.JSON(200, gin.H{ "code": 200, "data": gin.H{ "passed": result.Passed, "reason": result.Reason, "hit_whitelist": result.HitWhitelist, "hit_blacklist": result.HitBlacklist, }, }) } // KBTest 知识库检索测试 // // POST /api/v1/agent/kb-test body: {"query": "...", "top_k": 5} // 走 enhance 主流程同款检索路径(含 ai_kb_source 分流), // 回答「Agent 实际会检索到什么」 func (h *ObserveHandler) KBTest(c *gin.Context) { var req struct { Query string `json:"query" binding:"required"` TopK int `json:"top_k"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"code": 400, "message": "query 必填"}) return } result := h.enhancer.KBTest(c.Request.Context(), req.Query, req.TopK) c.JSON(200, gin.H{"code": 200, "data": result}) } // ModelTest 模型连通性测试 // // POST /api/v1/models/test body: {"provider": "", "message": ""} // provider 留空 = 测当前生效配置;会真实调一次 LLM(max_tokens 64) func (h *ObserveHandler) ModelTest(c *gin.Context) { var req struct { Provider string `json:"provider"` Message string `json:"message"` } // body 可以整个为空(全默认),解析失败也不阻断 _ = c.ShouldBindJSON(&req) result := h.enhancer.ModelTest(c.Request.Context(), req.Provider, req.Message) c.JSON(200, gin.H{"code": 200, "data": result}) }