diff --git a/README.md b/README.md index 44b03c7..5ccbf46 100644 --- a/README.md +++ b/README.md @@ -251,3 +251,16 @@ LBCTL_TEST_ENDPOINT=http://localhost:5300 \ ``` `make cross-build` 生成 macOS、Linux、Windows 的 amd64/arm64 二进制及 `dist/checksums.txt`。交叉构建成功不代表已经在对应系统完成实机验收。 + +## RAG 配置发现 + +这些命令需要 Core 声明对应 capability,并要求 `resource.view` 权限;可用 `-o json` 或 `-o yaml` 输出。 + +```sh +lbctl knowledge-engine list +lbctl knowledge-engine creation-schema author/engine -o yaml +lbctl knowledge-engine retrieval-schema author/engine -o json +lbctl knowledge-parser list --mime-type application/pdf +``` + +从引擎列表选择 `plugin_id`;creation schema 定义创建知识库的参数,retrieval schema 定义检索参数。随后使用已有 `knowledge-base create`、`ingest`、`retrieve` 命令完成入库和检索。引擎不存在、权限不足或服务端未声明 capability 时返回非零退出码。 diff --git a/internal/api/client.go b/internal/api/client.go index b7818c0..0eabc4d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -107,6 +107,11 @@ type TaskListFilters struct { } var capabilityOperationIDs = []string{ + "knowledge_engine.list", + "knowledge_engine.creation_schema", + "knowledge_engine.retrieval_schema", + "knowledge_parser.list", + "bot.list", "bot.get", "bot.create", diff --git a/internal/api/operations.go b/internal/api/operations.go index 2ea173f..daa6839 100644 --- a/internal/api/operations.go +++ b/internal/api/operations.go @@ -19,8 +19,11 @@ type Operation struct { // ResolveOperation 只接受已登记的路径,避免 api 子命令退化为任意 HTTP 代理。 func ResolveOperation(method, requestPath string) (Operation, bool) { parsed, err := url.Parse(requestPath) + if err != nil { + return Operation{}, false + } escapedPath := strings.ToLower(parsed.EscapedPath()) - if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.Fragment != "" || + if parsed.IsAbs() || parsed.Host != "" || parsed.Fragment != "" || strings.Contains(escapedPath, "%2f") || strings.Contains(escapedPath, "%5c") { return Operation{}, false } @@ -50,6 +53,8 @@ func ResolveOperation(method, requestPath string) (Operation, bool) { func validOperationQuery(operation string, query url.Values) bool { allowed := map[string]bool{} switch operation { + case "knowledge_parser.list": + allowed = map[string]bool{"mime_type": true} case "task.list": allowed = map[string]bool{"type": true, "kind": true} case "plugin.logs", "mcp_server.logs": @@ -82,6 +87,9 @@ func validOperationQuery(operation string, query url.Values) bool { } func resolveGetOperation(path string) (Operation, bool) { + if op, ok := resolveRAGOperation(path); ok { + return op, true + } switch path { case tasksPath: return Operation{ID: "task.list", Method: http.MethodGet, Path: path, ReadOnly: true, Permission: "resource.view"}, true diff --git a/internal/api/rag.go b/internal/api/rag.go new file mode 100644 index 0000000..2c0dda3 --- /dev/null +++ b/internal/api/rag.go @@ -0,0 +1,22 @@ +package api + +import ( + "net/http" +) + +func resolveRAGOperation(path string) (Operation, bool) { + id := "" + switch path { + case "/api/v1/knowledge/engines": + id = "knowledge_engine.list" + case "/api/v1/knowledge/parsers": + id = "knowledge_parser.list" + default: + for _, kind := range []string{"creation", "retrieval"} { + if nestedPath(path, "/api/v1/knowledge/engines", 2, kind+"-schema") { + id = "knowledge_engine." + kind + "_schema" + } + } + } + return Operation{ID: id, Method: http.MethodGet, Path: path, ReadOnly: true, Permission: "resource.view"}, id != "" +} diff --git a/internal/api/rag_test.go b/internal/api/rag_test.go new file mode 100644 index 0000000..6541d96 --- /dev/null +++ b/internal/api/rag_test.go @@ -0,0 +1,14 @@ +package api + +import ( + "net/http" + "testing" +) + +func TestRAGPathsRejectMalformedPluginIDs(t *testing.T) { + for _, path := range []string{"%zz", "/api/v1/knowledge/engines/author/../creation-schema", "/api/v1/knowledge/engines/author%2Fname/creation-schema", "/api/v1/knowledge/parsers?unsupported=true"} { + if _, ok := ResolveOperation(http.MethodGet, path); ok { + t.Errorf("accepted %s", path) + } + } +} diff --git a/internal/command/command.go b/internal/command/command.go index 4326c90..b32c4c4 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -148,6 +148,7 @@ func newRoot(deps Dependencies, flags *globalFlags, service *app.Service) *cobra root.AddCommand(newModelCommand(service, deps, flags)) root.AddCommand(newTaskCommand(service, deps, flags)) root.AddCommand(newKnowledgeBaseCommand(service, deps, flags)) + root.AddCommand(newKnowledgeEngineCommand(service, deps, flags), newKnowledgeParserCommand(service, deps, flags)) root.AddCommand(newPluginCommand(service, deps, flags)) root.AddCommand(newSkillCommand(service, deps, flags)) root.AddCommand(newMCPServerCommand(service, deps, flags)) diff --git a/internal/command/rag.go b/internal/command/rag.go new file mode 100644 index 0000000..a77f92e --- /dev/null +++ b/internal/command/rag.go @@ -0,0 +1,41 @@ +package command + +import ( + "github.com/langbot-app/langbot-cli/internal/app" + "github.com/spf13/cobra" + "net/http" + "net/url" +) + +func ragGet(cmd *cobra.Command, service *app.Service, deps Dependencies, flags *globalFlags, path string) error { + return emitCall(deps, flags, func() (app.Result, error) { + return service.APIRequest(cmd.Context(), http.MethodGet, path, nil, connectionOptions(flags)) + }) +} +func newKnowledgeEngineCommand(service *app.Service, deps Dependencies, flags *globalFlags) *cobra.Command { + root := &cobra.Command{Use: "knowledge-engine", Short: "查询知识引擎及配置 schema", Args: cobra.NoArgs} + root.AddCommand(&cobra.Command{Use: "list", Short: "列出知识引擎及能力", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + return ragGet(cmd, service, deps, flags, "/api/v1/knowledge/engines") + }}) + for _, kind := range []string{"creation", "retrieval"} { + root.AddCommand(&cobra.Command{Use: kind + "-schema ", Short: "读取 " + kind + " schema", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // The operation registry validates the two plugin ID segments. + return ragGet(cmd, service, deps, flags, "/api/v1/knowledge/engines/"+args[0]+"/"+kind+"-schema") + }}) + } + return root +} +func newKnowledgeParserCommand(service *app.Service, deps Dependencies, flags *globalFlags) *cobra.Command { + root := &cobra.Command{Use: "knowledge-parser", Short: "查询文档解析器", Args: cobra.NoArgs} + var mime string + list := &cobra.Command{Use: "list", Short: "列出文档解析器", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + path := "/api/v1/knowledge/parsers" + if mime != "" { + path += "?" + url.Values{"mime_type": {mime}}.Encode() + } + return ragGet(cmd, service, deps, flags, path) + }} + list.Flags().StringVar(&mime, "mime-type", "", "按 MIME type 筛选") + root.AddCommand(list) + return root +} diff --git a/internal/command/schema.go b/internal/command/schema.go index 616598c..d6ba42f 100644 --- a/internal/command/schema.go +++ b/internal/command/schema.go @@ -126,6 +126,11 @@ type schemaFlag struct { var placeholderPattern = regexp.MustCompile(`(<[^>]+>|\[[^]]+\])`) var schemaMetaByName = map[string]schemaCommandMeta{ + "knowledge-engine.list": readMeta(operationIDs("knowledge_engine.list")...), + "knowledge-engine.creation-schema": readMeta(operationIDs("knowledge_engine.creation_schema")...), + "knowledge-engine.retrieval-schema": readMeta(operationIDs("knowledge_engine.retrieval_schema")...), + "knowledge-parser.list": readMeta(operationIDs("knowledge_parser.list")...), + "schema": {}, "completion": {}, "completion.bash": {}, diff --git a/internal/integration/rag_cli_test.go b/internal/integration/rag_cli_test.go new file mode 100644 index 0000000..c04c07c --- /dev/null +++ b/internal/integration/rag_cli_test.go @@ -0,0 +1,78 @@ +package integration_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestRAGDiscoveryCommands(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + permissions := []string{"resource.view"} + supported, missing := true, false + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("unexpected mutation %s", r.Method) + w.WriteHeader(405) + return + } + switch r.URL.Path { + case "/api/v1/system/context": + p, _ := json.Marshal(permissions) + fmt.Fprintf(w, `{"code":0,"data":{"instance_uuid":"instance-a","workspace_uuid":"workspace-a","api_key_id":"key-a","permissions":%s}}`, p) + case "/api/v1/system/capabilities": + if supported { + fmt.Fprint(w, capabilitiesJSON("knowledge_engine.list", "knowledge_engine.creation_schema", "knowledge_engine.retrieval_schema", "knowledge_parser.list")) + } else { + fmt.Fprint(w, capabilitiesJSON()) + } + default: + requests = append(requests, r.URL.RequestURI()) + if missing { + writeCLIError(w, http.StatusNotFound) + return + } + fmt.Fprint(w, `{"code":0,"data":{"items":[],"text":"connection-secret"}}`) + } + })) + defer server.Close() + configureWriteContext(t, configPath, server.URL, "FEATURE_KEY") + env := map[string]string{"FEATURE_KEY": "connection-secret"} + for _, test := range []struct { + args []string + path string + }{{[]string{"knowledge-engine", "list"}, "/api/v1/knowledge/engines"}, + {[]string{"knowledge-engine", "creation-schema", "author/engine"}, "/api/v1/knowledge/engines/author/engine/creation-schema"}, + {[]string{"knowledge-engine", "retrieval-schema", "author/engine"}, "/api/v1/knowledge/engines/author/engine/retrieval-schema"}, + {[]string{"knowledge-parser", "list", "--mime-type", "text/plain"}, "/api/v1/knowledge/parsers?mime_type=text%2Fplain"}} { + got := run(t, configPath, env, "", test.args...) + requireSuccess(t, got) + if strings.Contains(got.out, "connection-secret") { + t.Fatalf("secret leaked: %s", got.out) + } + if requests[len(requests)-1] != test.path { + t.Fatalf("request=%s want=%s", requests[len(requests)-1], test.path) + } + } + missing = true + if got := run(t, configPath, env, "", "knowledge-engine", "list"); got.code != 5 { + t.Fatalf("not found: %s", got.out) + } + missing = false + permissions = []string{} + before := len(requests) + if got := run(t, configPath, env, "", "knowledge-engine", "list"); got.code != 4 || len(requests) != before { + t.Fatalf("permission: %s", got.out) + } + permissions = []string{"resource.view"} + supported = false + if got := run(t, configPath, env, "", "knowledge-engine", "list"); got.code != 6 || len(requests) != before { + t.Fatalf("capability: %s", got.out) + } + +}