Files
sundynix-agentix/sundynix-mcp-go/internal/mcp/gateway.go
T
Blizzard a17e25b6ba feat(rag): 检索质量评测 —— 单路检索能力 + recall@k/MRR 评测台
- 引擎抽 searchPaths(三路召回) + SearchByMode(vector/fulltext/graph/hybrid,
  纯检索不 rerank,公平对比);kb_search 加 mode 参数(空=生产含rerank),
  gateway KbSearch 透传 mode
- scripts/rageval.py:标注语料+查询 → 四模式 recall@k/MRR 对比表(可复用)
- live 量化:纯语义改写让全文0.88/图谱0.75 漏召回,混合 1.00 兜回,
  混合=各路上界的稳健组合

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:56:39 +08:00

532 lines
23 KiB
Go
Raw 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 mcp 实现 MCP 协议网关,把工具注册到 NATS 并响应调用。
package mcp
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
sharedbus "github.com/sundynix/sundynix-shared/bus"
"github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-mcp-go/internal/history"
"github.com/sundynix/sundynix-mcp-go/internal/memory"
"github.com/sundynix/sundynix-mcp-go/internal/office"
"github.com/sundynix/sundynix-mcp-go/internal/rag"
"github.com/sundynix/sundynix-mcp-go/internal/search"
)
// Gateway 暴露 MCP 协议端点,经共享 bus 订阅 sundynix.tools.go.* 响应调用。
type Gateway struct {
bus *sharedbus.Bus
search *search.Hybrid
memory *memory.Store
history *history.Store
rag *rag.Engine
tools map[string]toolDef // 工具注册表:唯一事实源,dispatch 与 list_tools 共用,杜绝漂移
pgDSN string // 平台 PG DSNsql_query 兜底库;SQL_QUERY_DSN 未设时用它)
sqlOnce sync.Once // sql_query 只读连接懒连一次
sqlDB *sql.DB //
sqlDBErr error //
}
// paramSpec 是一个工具参数的声明(供自主 agent 据此生成调用入参)。
type paramSpec struct {
Name string `json:"name"`
Type string `json:"type"` // string / number / integer / boolean / object / array
Desc string `json:"desc"`
Required bool `json:"required"`
}
// toolDef 是一个注册工具的元信息 + 处理函数。新增 agent 暴露元信息,让自主 agent 能动态发现工具:
// agent=是否给模型自主调用;agentName=模型可见名(空=注册键);params=模型可填参数;
// inject=服务端运行时注入、不暴露给模型的参数名(如 user_id / session_id / kb / task_id)。
type toolDef struct {
cn string
desc string
agent bool
agentName string
params []paramSpec
inject []string
handler func(context.Context, *contract.ToolCall) *contract.ToolResult
}
func NewGateway(b *sharedbus.Bus, s *search.Hybrid, m *memory.Store, h *history.Store, r *rag.Engine, pgDSN string) *Gateway {
g := &Gateway{bus: b, search: s, memory: m, history: h, rag: r, pgDSN: pgDSN}
g.tools = g.buildRegistry()
return g
}
// Serve 以队列组通配订阅 sundynix.tools.go.>,按工具名分发并阻塞。
func (g *Gateway) Serve(ctx context.Context) error {
drain, err := g.bus.ServeTool(contract.SubjectToolsGoAll, contract.QueueToolsGo, g.dispatch)
if err != nil {
return err
}
log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, kb_ingest, kb_search, kb_graph, report_render, memory_*, history_*, echo",
contract.SubjectToolsGoAll, contract.QueueToolsGo)
<-ctx.Done() // 收到停机信号
// 优雅停机:停止接新工具调用 + 等在途调用回完(至多 DrainTimeout),让 dispatcher 拿到结果而非干等超时。
to := sharedbus.DrainTimeout()
log.Printf("[mcp_go] 收到停机信号,drain 在途工具调用(≤%s)…", to)
dctx, cancel := context.WithTimeout(context.Background(), to)
defer cancel()
drain(dctx)
log.Printf("[mcp_go] drain 完成,退出")
return nil
}
// buildRegistry 注册 mcp-go 全部工具:名称 → (中文名, 作用, 处理函数)。
// 这是工具的唯一事实源——dispatch 据此路由、list_tools 据此上报,二者永不漂移。
// 想让某工具能被自主 agent 调用:把 agent 设 true,写清 params(模型可填)与 inject(服务端注入)。
// 加新工具只改这一处——dispatcher 经 list_tools 动态发现,无需改调度代码。
func (g *Gateway) buildRegistry() map[string]toolDef {
return map[string]toolDef{
// —— 暴露给自主 agent 的工具(带参数 schema / 注入声明)——
"wiki_search": {
cn: "知识检索", desc: "检索知识库,返回与查询最相关的资料片段。需要外部知识/事实依据时调用。",
agent: true,
params: []paramSpec{{Name: "q", Type: "string", Desc: "检索查询语句", Required: true}},
inject: []string{"kb"}, handler: g.wikiSearch,
},
"memory_get": {
cn: "记忆召回", desc: "召回当前用户的长期画像与偏好(称呼/职业/回答偏好等)。需要个性化、了解“我是谁”时调用。",
agent: true, agentName: "recall_user_memory", inject: []string{"user_id"}, handler: g.memoryGet,
},
"memory_upsert": {
cn: "记忆写入", desc: "把关于用户的一条事实/偏好长期记住(如称呼、职业、回答偏好)。",
agent: true, agentName: "remember_user_fact",
params: []paramSpec{
{Name: "key", Type: "string", Desc: "记忆条目的键,如 称呼/职业/回答偏好", Required: true},
{Name: "value", Type: "string", Desc: "记忆条目的值", Required: true},
},
inject: []string{"user_id"}, handler: g.memoryUpsert,
},
"history_get": {
cn: "历史召回", desc: "取当前会话最近多轮对话,用于理解上下文。",
agent: true, inject: []string{"session_id"}, handler: g.historyGet,
},
"web_search": {
cn: "联网搜索", desc: "联网搜索,返回最新网页结果(标题/链接/摘要)。需要实时/最新信息或外部事实时调用。",
agent: true,
params: []paramSpec{
{Name: "q", Type: "string", Desc: "搜索关键词", Required: true},
{Name: "topK", Type: "integer", Desc: "返回结果条数(默认 5,最多 10)"},
},
handler: g.webSearch,
},
"web_fetch": {
cn: "网页抓取", desc: "抓取一个网页 URL 并提取正文文本。需要读取某个链接的内容时调用。",
agent: true,
params: []paramSpec{{Name: "url", Type: "string", Desc: "要抓取的网页 URL", Required: true}},
handler: g.webFetch,
},
"calculator": {
cn: "计算器", desc: "精确计算数学表达式(+ - * / % ^ 与括号)。涉及算术/数值计算时调用,不要心算。",
agent: true,
params: []paramSpec{{Name: "expr", Type: "string", Desc: "数学表达式,如 (3+4)*2^3", Required: true}},
handler: g.calculator,
},
"current_datetime": {
cn: "当前时间", desc: "获取当前日期与时间(含星期)。需要“现在/今天几号/星期几”等时间信息时调用。",
agent: true,
params: []paramSpec{{Name: "tz", Type: "string", Desc: "可选时区,如 Asia/Shanghai;缺省服务器本地时区"}},
handler: g.currentDatetime,
},
"sql_query": {
cn: "SQL查询", desc: "对数据库执行只读 SQL 查询(仅 SELECT/WITH),返回结果表。需要查业务数据/统计时调用。",
agent: true,
params: []paramSpec{{Name: "sql", Type: "string", Desc: "只读 SQL,如 SELECT count(*) FROM sundynix_task", Required: true}},
handler: g.sqlQuery,
},
"chart": {
cn: "图表", desc: "把数据生成图表。返回图表 JSON——请在最终答复中用 ```chart 代码块原样包裹该 JSON,前端会渲染成图。需要可视化数据分布/趋势时调用。",
agent: true,
params: []paramSpec{
{Name: "type", Type: "string", Desc: "图表类型:bar / line / pie", Required: true},
{Name: "title", Type: "string", Desc: "图表标题"},
{Name: "labels", Type: "array", Desc: "x 轴/扇区标签数组,如 [\"Q1\",\"Q2\"]", Required: true},
{Name: "series", Type: "array", Desc: "数据系列数组,如 [{\"name\":\"销量\",\"data\":[120,180]}]", Required: true},
},
handler: g.chart,
},
// —— 仅内部/流水线/管理用,不暴露给自主 agent ——
"kb_ingest": {cn: "知识入库", desc: "文本切块 → 向量化 → 写入 Milvus / Bleve", handler: g.kbIngest},
"kb_delete": {cn: "知识删除", desc: "按 file_id 级联删某文档的向量/全文/图谱", handler: g.kbDelete},
"kb_search": {cn: "检索台查询", desc: "结构化返回命中内容与相似度分数", handler: g.kbSearch},
"kb_graph": {cn: "知识图谱", desc: "取某库的实体关系三元组(Neo4j)", handler: g.kbGraph},
"report_render": {cn: "报告渲染", desc: "把结构化报告渲染为 Word.docx", handler: g.reportRender},
"report_store": {cn: "报告存源", desc: "暂存报告源数据,供导出时按需渲染", handler: g.reportStore},
"report_export": {cn: "报告导出", desc: "按需把已存报告导出为 Word / Markdown", handler: g.reportExport},
"external_api": {cn: "外部接口", desc: "受控调用第三方 HTTP API(带 SSRF 校验)", handler: g.externalAPI},
"memory_delete": {cn: "记忆删除", desc: "软删一条偏好(对账判定过时 / 矛盾时)", handler: g.memoryDelete},
"memory_list": {cn: "记忆列表", desc: "列出用户全部偏好(供管理面板查看)", handler: g.memoryList},
"history_append": {cn: "历史追加", desc: "往会话写入一条消息", handler: g.historyAppend},
"health": {cn: "健康检查", desc: "上报 Milvus / Neo4j / embedding 就绪情况",
handler: func(_ context.Context, _ *contract.ToolCall) *contract.ToolResult {
data, _ := json.Marshal(g.rag.Status())
return &contract.ToolResult{OK: true, Content: string(data)}
}},
"echo": {cn: "回显", desc: "原样返回入参(调试用)",
handler: func(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
return &contract.ToolResult{OK: true, Content: fmt.Sprint(call.Args["text"])}
}},
}
}
// dispatch 按 ToolCall.Tool 从注册表路由到具体工具实现。
// list_tools 是元工具(自省),不在业务注册表内,单独处理。
func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
log.Printf("[mcp_go] tool=%s task=%s args=%v", call.Tool, call.TaskID, call.Args)
if call.Tool == "list_tools" {
return g.listTools()
}
td, ok := g.tools[call.Tool]
if !ok {
return &contract.ToolResult{OK: false, Error: "unknown tool: " + call.Tool}
}
return td.handler(ctx, call)
}
// listTools 自省:上报本服务注册的工具清单(名称 + 中文名 + 作用 + agent 暴露元信息),
// 供管理端展示 & dispatcher 动态构建自主 agent 工具集(加工具只改注册表,无需改调度代码)。
func (g *Gateway) listTools() *contract.ToolResult {
type info struct {
Name string `json:"name"`
CN string `json:"cn"`
Desc string `json:"desc"`
Agent bool `json:"agent_exposed"` // 是否给自主 agent
AgentName string `json:"agent_name,omitempty"` // 模型可见名(空=name
Params []paramSpec `json:"params,omitempty"` // 模型可填参数
Inject []string `json:"inject,omitempty"` // 服务端注入参数(不暴露给模型)
}
out := make([]info, 0, len(g.tools))
for name, td := range g.tools {
out = append(out, info{
Name: name, CN: td.cn, Desc: td.desc,
Agent: td.agent, AgentName: td.agentName, Params: td.params, Inject: td.inject,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) // map 无序 → 稳定输出
data, _ := json.Marshal(map[string]any{"service": "mcp-go", "tools": out})
return &contract.ToolResult{OK: true, Content: string(data)}
}
// memoryGet 召回某用户的常驻画像(已渲染为可注入 prompt 的多行文本)。
func (g *Gateway) memoryGet(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
profile, err := g.memory.Get(ctx, uid)
if err != nil {
return &contract.ToolResult{OK: false, Error: "memory_get: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: profile}
}
// historyGet 召回某会话最近多轮历史,Content 为 JSON 数组 [{role,content},...](正序)。
func (g *Gateway) historyGet(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
session, _ := call.Args["session_id"].(string)
turns, err := g.history.Get(ctx, session)
if err != nil {
return &contract.ToolResult{OK: false, Error: "history_get: " + err.Error()}
}
data, _ := json.Marshal(turns)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// historyAppend 追加一条会话消息(session_id + role + content)。
func (g *Gateway) historyAppend(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
session, _ := call.Args["session_id"].(string)
role, _ := call.Args["role"].(string)
content, _ := call.Args["content"].(string)
if session == "" || role == "" {
return &contract.ToolResult{OK: false, Error: "history_append: session_id 和 role 必填"}
}
if err := g.history.Append(ctx, session, role, content); err != nil {
return &contract.ToolResult{OK: false, Error: "history_append: " + err.Error()}
}
return &contract.ToolResult{OK: true}
}
// memoryUpsert 写入/更新一条画像偏好(user_id + key + value + 可选 importance(1~10))。
func (g *Gateway) memoryUpsert(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
key, _ := call.Args["key"].(string)
val, _ := call.Args["value"].(string)
importance, _ := call.Args["importance"].(float64) // NATS JSON 数字解为 float64
if uid == "" || key == "" {
return &contract.ToolResult{OK: false, Error: "memory_upsert: user_id 和 key 必填"}
}
if err := g.memory.Upsert(ctx, uid, key, val, importance); err != nil {
return &contract.ToolResult{OK: false, Error: "memory_upsert: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("已记住 %s 的「%s」", uid, key)}
}
// memoryList 返回某用户全部 active 偏好(结构化 JSON,供管理面板查看/编辑)。
func (g *Gateway) memoryList(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
rows, err := g.memory.List(ctx, uid)
if err != nil {
return &contract.ToolResult{OK: false, Error: "memory_list: " + err.Error()}
}
type item struct {
Key string `json:"key"`
Value string `json:"value"`
Importance float64 `json:"importance"`
LastSeen string `json:"last_seen"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
out = append(out, item{Key: r.Key, Value: r.Value, Importance: r.Importance, LastSeen: r.LastSeenAt.Format(time.RFC3339)})
}
data, _ := json.Marshal(out)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// memoryDelete 软删一条画像偏好(user_id + key)—— consolidate 判定过时/矛盾时调用。
func (g *Gateway) memoryDelete(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
key, _ := call.Args["key"].(string)
if uid == "" || key == "" {
return &contract.ToolResult{OK: false, Error: "memory_delete: user_id 和 key 必填"}
}
if err := g.memory.Delete(ctx, uid, key); err != nil {
return &contract.ToolResult{OK: false, Error: "memory_delete: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("已删除 %s 的「%s」", uid, key)}
}
// wikiSearch 经 RAG 引擎做向量检索(embedding + Milvus)。
// RAG 未就绪时降级返回空命中(不阻断图执行)。
func (g *Gateway) wikiSearch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
q, _ := call.Args["q"].(string)
kb, _ := call.Args["kb"].(string)
topK := 5
if v, ok := call.Args["topK"].(float64); ok && v > 0 {
topK = int(v)
}
if !g.rag.Ready() {
return &contract.ToolResult{OK: true, Content: "[wiki_search] RAG 未配置(需 embedding + Milvus),无召回"}
}
hits, err := g.rag.Search(ctx, kb, q, topK)
if err != nil {
return &contract.ToolResult{OK: false, Error: "wiki_search: " + err.Error()}
}
var b strings.Builder
fmt.Fprintf(&b, "[wiki_search] 命中 %d 条(Milvus 向量检索):\n", len(hits))
for i, h := range hits {
fmt.Fprintf(&b, "%d. (%.3f) %s\n", i+1, h.Score, h.Text)
}
return &contract.ToolResult{OK: true, Content: strings.TrimRight(b.String(), "\n")}
}
// kbSearch 检索台用:返回结构化命中 JSON [{text,score},...](供检索台展示分数)。
func (g *Gateway) kbSearch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
q, _ := call.Args["q"].(string)
kb, _ := call.Args["kb"].(string)
topK := 5
if v, ok := call.Args["topK"].(float64); ok && v > 0 {
topK = int(v)
}
if !g.rag.Ready() {
return &contract.ToolResult{OK: true, Content: "[]"}
}
// mode 空=生产混合检索(含 rerank);显式 vector/fulltext/graph/hybrid=评测用单路/纯融合(不 rerank)。
mode, _ := call.Args["mode"].(string)
var hits []rag.Hit
var err error
if mode == "" {
hits, err = g.rag.Search(ctx, kb, q, topK)
} else {
hits = g.rag.SearchByMode(ctx, kb, q, topK, mode)
}
if err != nil {
return &contract.ToolResult{OK: false, Error: "kb_search: " + err.Error()}
}
data, _ := json.Marshal(hits)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// kbGraph 返回某知识库的图谱三元组 JSON [{s,p,o},...](供 UI 可视化 Neo4j 情况)。
func (g *Gateway) kbGraph(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
kb, _ := call.Args["kb"].(string)
limit := 100
if v, ok := call.Args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
triples := g.rag.Triples(ctx, kb, limit)
data, _ := json.Marshal(triples)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// reportRender 把结构化报告(title + sections[{heading,body}])渲染为真实 .docx
// 落盘到 contract.ReportPath(task_id),返回绝对路径供 Gateway 提供下载。
func (g *Gateway) reportRender(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
title, _ := call.Args["title"].(string)
id, _ := call.Args["task_id"].(string)
if id == "" {
id = call.TaskID
}
if id == "" {
return &contract.ToolResult{OK: false, Error: "report_render: task_id 必填"}
}
// sections 经 NATS JSON 透传,统一 re-marshal 再解出强类型。
var secs []office.Section
if raw, err := json.Marshal(call.Args["sections"]); err == nil {
_ = json.Unmarshal(raw, &secs)
}
data, err := office.NewRenderer().RenderReport(ctx, title, secs)
if err != nil {
return &contract.ToolResult{OK: false, Error: "report_render: " + err.Error()}
}
path := contract.ReportPath(id)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return &contract.ToolResult{OK: false, Error: "report_render: mkdir " + err.Error()}
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return &contract.ToolResult{OK: false, Error: "report_render: write " + err.Error()}
}
log.Printf("[mcp_go] report_render 已生成 %s (%d 字节, %d 章节)", path, len(data), len(secs))
return &contract.ToolResult{OK: true, Content: path}
}
// reportSource 是报告的可序列化源数据(标题 + 章节),导出时据此渲染各格式。
type reportSource struct {
Title string `json:"title"`
Sections []office.Section `json:"sections"`
}
// reportStore 把报告源数据(title + sections)落盘为 JSON,供导出时按需渲染 Word/PDF/Markdown。
// 生成阶段只存源、不渲染("导出时再处理")。
func (g *Gateway) reportStore(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
id, _ := call.Args["task_id"].(string)
if id == "" {
id = call.TaskID
}
if id == "" {
return &contract.ToolResult{OK: false, Error: "report_store: task_id 必填"}
}
title, _ := call.Args["title"].(string)
var secs []office.Section
if raw, err := json.Marshal(call.Args["sections"]); err == nil {
_ = json.Unmarshal(raw, &secs)
}
data, _ := json.Marshal(reportSource{Title: title, Sections: secs})
path := contract.ReportSourcePath(id)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return &contract.ToolResult{OK: false, Error: "report_store: mkdir " + err.Error()}
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return &contract.ToolResult{OK: false, Error: "report_store: write " + err.Error()}
}
log.Printf("[mcp_go] report_store 已存源 %s (%d 章节)", path, len(secs))
return &contract.ToolResult{OK: true, Content: path}
}
// reportExport 按需把已存报告源渲染为指定格式:
// docx → 渲染并落盘,返回 .docx 路径;md → 返回 Markdown 文本。
func (g *Gateway) reportExport(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
id, _ := call.Args["task_id"].(string)
if id == "" {
id = call.TaskID
}
if id == "" {
return &contract.ToolResult{OK: false, Error: "report_export: task_id 必填"}
}
format, _ := call.Args["format"].(string)
raw, err := os.ReadFile(contract.ReportSourcePath(id))
if err != nil {
return &contract.ToolResult{OK: false, Error: "report_export: 报告尚未生成或已过期"}
}
var src reportSource
if err := json.Unmarshal(raw, &src); err != nil {
return &contract.ToolResult{OK: false, Error: "report_export: 源解析失败"}
}
switch format {
case "md", "markdown":
return &contract.ToolResult{OK: true, Content: reportMarkdown(src)}
default: // docx
data, rerr := office.NewRenderer().RenderReport(ctx, src.Title, src.Sections)
if rerr != nil {
return &contract.ToolResult{OK: false, Error: "report_export: " + rerr.Error()}
}
path := contract.ReportPath(id)
if err := os.WriteFile(path, data, 0o644); err != nil {
return &contract.ToolResult{OK: false, Error: "report_export: write " + err.Error()}
}
log.Printf("[mcp_go] report_export 已渲染 docx %s (%d 字节)", path, len(data))
return &contract.ToolResult{OK: true, Content: path}
}
}
// reportMarkdown 把报告源拼为 Markdown(标题 + 各章 ## 小标题 + 正文)。
func reportMarkdown(src reportSource) string {
var b strings.Builder
if src.Title != "" {
b.WriteString("# " + src.Title + "\n\n")
}
for _, s := range src.Sections {
if s.Heading != "" {
b.WriteString("## " + s.Heading + "\n\n")
}
b.WriteString(strings.TrimSpace(s.Body) + "\n\n")
}
return b.String()
}
// kbIngest 把文本入库(切块→embedding→Milvus+Bleve)。
// 带 job_id 时逐阶段把进度发到 sundynix.streams.<job_id>,供 UI 实时入库监控。
func (g *Gateway) kbIngest(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
kb, _ := call.Args["kb"].(string)
doc, _ := call.Args["doc"].(string)
text, _ := call.Args["text"].(string)
jobID, _ := call.Args["job_id"].(string)
if text == "" {
return &contract.ToolResult{OK: false, Error: "kb_ingest: text 必填"}
}
var onProgress func(contract.IngestEvent)
if jobID != "" {
onProgress = func(ev contract.IngestEvent) {
if data, err := json.Marshal(ev); err == nil {
_ = g.bus.PublishToken(jobID, data)
}
}
}
n, err := g.rag.Ingest(ctx, kb, doc, text, onProgress)
if jobID != "" {
if err != nil {
onProgress(contract.IngestEvent{Stage: "失败", Error: err.Error()})
} else {
onProgress(contract.IngestEvent{Stage: "完成", Done: n, Total: n, Msg: fmt.Sprintf("已入库 %d 块", n)})
}
_ = g.bus.CompleteStream(jobID)
}
if err != nil {
return &contract.ToolResult{OK: false, Error: "kb_ingest: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("已入库 %d 块到知识库 %q", n, kb)}
}
// kbDelete 按 file_id 级联删某文档在三库的痕迹(向量/全文/图谱)。
func (g *Gateway) kbDelete(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
kb, _ := call.Args["kb"].(string)
fileID, _ := call.Args["file_id"].(string)
if fileID == "" {
return &contract.ToolResult{OK: false, Error: "kb_delete: file_id 必填"}
}
if err := g.rag.DeleteDoc(ctx, kb, fileID); err != nil {
return &contract.ToolResult{OK: false, Error: "kb_delete: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("已删除文档 %s 的向量/全文/图谱", fileID)}
}