182 lines
6.9 KiB
Go
182 lines
6.9 KiB
Go
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
|
||
}
|