Files
xk-ai-agent/internal/service/observe.go
2026-08-14 21:50:48 +08:00

182 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
// ========================================================================
// Observe —— 观测与调试支持(/agent/view 面板「调试工具」「系统状态」数据源)
// ========================================================================
// 本文件集中放面板用的只读/无副作用能力:
// - GuardTest :医疗守卫测试台(贴文本看命中词)
// - MedicalGuardKeywords:守卫白/黑名单只读导出(配置总览 Tab 展示)
// - KBTest :知识库检索测试(走 enhance 同款检索路径)
// - ModelTest :模型连通性测试(发一条小消息,max_tokens 64)
// - 并发探针 :router 注册回调,暴露 enhance 当前并发/上限
//
// 设计原则:全部只读或无持久副作用(ModelTest 会真实调一次 LLM,
// 消耗极少量 token,这是「连通性测试」的业务语义,可接受)
// ========================================================================
import (
"context"
"time"
"tcm-agent/internal/llm"
"tcm-agent/internal/types"
)
// ------------------------------------------------------------------
// 医疗守卫测试
// ------------------------------------------------------------------
// GuardTest 用给定文本跑一次医疗守卫(与 Enhance 入口同款逻辑)
//
// 用途:面板「守卫测试台」——运维贴一段 prompt 就能看到会命中哪些词、
// 是否会被拦截,用于调守卫词表或排查「为什么这个请求被拦了」
func GuardTest(text string) MedicalGuardResult {
return validateMedicalRelevance([]types.Message{{Role: "user", Content: text}})
}
// MedicalGuardKeywords 导出守卫白/黑名单(只读拷贝)
//
// 面板「配置总览」Tab 展示当前词表;返回拷贝防止调用方改动内部切片
func MedicalGuardKeywords() (whitelist []string, blacklist []string) {
w := make([]string, len(defaultMedicalGuardConfig.whitelist))
copy(w, defaultMedicalGuardConfig.whitelist)
b := make([]string, len(defaultMedicalGuardConfig.blacklist))
copy(b, defaultMedicalGuardConfig.blacklist)
return w, b
}
// ------------------------------------------------------------------
// 知识库检索测试
// ------------------------------------------------------------------
// KBTestResult 知识库测试返回
type KBTestResult struct {
Source string `json:"source"` // 实际走的检索源(local/maxkb)
DurationMs int `json:"duration_ms"` // 检索耗时
Docs []string `json:"docs"` // 命中的文档片段(【标题】+内容 格式)
Error string `json:"error,omitempty"`
}
// KBTest 用给定 query 跑一次知识库检索(与 enhance 主流程完全同款路径)
//
// 为什么复用 performKBRetrieval 而不是直接调 searcher:
// 面板测试的意义是回答「Agent 实际会检索到什么」,
// 必须连 ai_kb_source 分流、默认库选择、TopK 兜底这些逻辑一起走
func (s *EnhancerService) KBTest(ctx context.Context, query string, topK int) KBTestResult {
step, docs, formulas, err := s.performKBRetrieval(ctx, &EnhanceRequest{
Context: query,
TopK: topK,
})
// 测试台把方剂块也并入 docs 展示(测试请求 scene 为空时 formulas 恒为空,
// 若以后测试台支持选 scene=prescription 也能直接看到方剂命中)
res := KBTestResult{
DurationMs: step.DurationMs,
Docs: append(docs, formulas...),
}
// step.Detail 形如 "[local] 命中 3 条相关文档",把 source 提出来单独给前端
res.Source = step.Detail
if err != nil {
res.Error = err.Error()
}
return res
}
// ------------------------------------------------------------------
// 模型连通性测试
// ------------------------------------------------------------------
// ModelTestResult 模型测试返回
type ModelTestResult struct {
OK bool `json:"ok"` // 是否连通
Provider string `json:"provider"` // 实际使用的 provider
Model string `json:"model"` // 实际使用的模型名
CfgSource string `json:"cfg_source"` // 配置来源(active/yaml_default 等)
APIKeyID int `json:"api_key_id"` // 使用的 API Key ID
DurationMs int `json:"duration_ms"` // 调用耗时
Reply string `json:"reply"` // 模型回复片段(截断 200 字符)
Error string `json:"error,omitempty"`
}
// ModelTest 发一条小消息测试模型连通性
//
// 参数:
// - provider:留空 = 测当前生效配置;指定则强制走该 provider
// - message :测试消息,留空用默认的"请回复:连接正常"
//
// 走 resolveClient 同款解析逻辑(DB 优先),所以测的就是线上真实链路;
// max_tokens 压到 64,单次测试成本可忽略
func (s *EnhancerService) ModelTest(ctx context.Context, provider, message string) ModelTestResult {
if message == "" {
message = "这是一次连通性测试,请只回复四个字:连接正常"
}
client, actualProvider, meta, err := s.resolveClient("", provider)
if err != nil {
return ModelTestResult{OK: false, Provider: provider, Error: "解析模型配置失败: " + err.Error()}
}
res := ModelTestResult{
Provider: actualProvider,
Model: client.Name(),
CfgSource: meta.CfgSource,
APIKeyID: meta.APIKeyID,
}
messages := []types.Message{
{Role: "user", Content: message},
}
opts := llm.ChatOpts{MaxTokens: 64}
start := time.Now()
var msg *types.Message
if optClient, ok := client.(llm.OptAwareClient); ok {
msg, err = optClient.ChatWithOpts(ctx, messages, nil, opts)
} else {
msg, err = client.Chat(ctx, messages, nil)
}
res.DurationMs = int(time.Since(start).Milliseconds())
if err != nil {
res.Error = err.Error()
return res
}
if msg == nil {
res.Error = "模型返回空"
return res
}
res.OK = true
res.Reply = truncateForLog(msg.Content, 200)
return res
}
// ------------------------------------------------------------------
// enhance 并发探针
// ------------------------------------------------------------------
// concurrencyProbe 由 router 启动时注册的回调,返回 (当前并发数, 并发上限)
//
// 为什么用回调而不是把信号量挪进 service 包:
// 信号量的生命周期属于路由层(限流是 HTTP 层职责),
// service 只是观测方;回调注入避免 router ↔ service 反向依赖
var concurrencyProbe func() (used, capacity int)
// SetConcurrencyProbe 注册并发探针(router.Setup 启动时调用一次)
func SetConcurrencyProbe(f func() (used, capacity int)) {
concurrencyProbe = f
}
// GetConcurrency 读取当前 enhance 并发状态(未注册时返回 0,0)
func GetConcurrency() (used, capacity int) {
if concurrencyProbe == nil {
return 0, 0
}
return concurrencyProbe()
}
// RunLogUsage RunLog 缓冲占用(系统状态 Tab 用)
func RunLogUsage() (used, capacity int) {
runLog.mu.RLock()
defer runLog.mu.RUnlock()
return runLog.count, runLog.size
}