286 lines
8.8 KiB
Go
286 lines
8.8 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
// Package docresolver resolves document IDs, URLs, and natural titles to one
|
|
// typed document target before a shortcut performs a business operation.
|
|
package docresolver
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
)
|
|
|
|
const (
|
|
searchPageSize = 30
|
|
searchPageLimit = 40
|
|
)
|
|
|
|
// Reader is the read-only RuntimeContext surface needed by the resolver.
|
|
type Reader interface {
|
|
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
|
|
}
|
|
|
|
// Candidate is a credential-free document search candidate.
|
|
type Candidate struct {
|
|
CanonicalID string `json:"canonicalId"`
|
|
Name string `json:"name,omitempty"`
|
|
Product string `json:"product"`
|
|
ResourceType string `json:"resourceType,omitempty"`
|
|
URL string `json:"canonicalUrl,omitempty"`
|
|
ContainerID string `json:"containerId,omitempty"`
|
|
}
|
|
|
|
// Resolution is the stable result consumed by document shortcuts.
|
|
type Resolution struct {
|
|
ContractVersion string `json:"contractVersion"`
|
|
Status string `json:"status"`
|
|
Query string `json:"query,omitempty"`
|
|
MatchedBy string `json:"matchedBy"`
|
|
Complete bool `json:"complete"`
|
|
Selected Candidate `json:"selected"`
|
|
Candidates []Candidate `json:"candidates"`
|
|
}
|
|
|
|
// Resolve accepts exactly one stable node/URL or natural title. Natural title
|
|
// resolution exhausts pagination before selecting a unique candidate.
|
|
func Resolve(rt Reader, node, query string) (Resolution, error) {
|
|
node = strings.TrimSpace(node)
|
|
query = strings.TrimSpace(query)
|
|
if (node == "") == (query == "") {
|
|
return Resolution{}, apperrors.NewValidation(
|
|
"文档目标必须且只能提供一个:--node 或 --query",
|
|
apperrors.WithReason("document_target_invalid"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
)
|
|
}
|
|
if node != "" {
|
|
return Resolution{
|
|
ContractVersion: "doc.target.v1",
|
|
Status: "resolved",
|
|
MatchedBy: "stable_id_or_url",
|
|
Complete: true,
|
|
Selected: Candidate{
|
|
CanonicalID: node,
|
|
Product: "doc",
|
|
},
|
|
Candidates: []Candidate{},
|
|
}, nil
|
|
}
|
|
|
|
candidates, err := searchAll(rt, query)
|
|
if err != nil {
|
|
return Resolution{}, err
|
|
}
|
|
selected, matchedBy := selectCandidates(candidates, query)
|
|
if len(selected) == 0 {
|
|
return Resolution{}, resolutionError("not_found", query, candidates, "没有找到匹配的在线文字文档")
|
|
}
|
|
if len(selected) > 1 {
|
|
return Resolution{}, resolutionError("ambiguous", query, selected, "找到多个匹配文档,请使用 nodeId 或 URL 消歧")
|
|
}
|
|
if typ := normalizeType(selected[0].ResourceType); typ != "" && typ != "adoc" && typ != "document" && typ != "doc" {
|
|
return Resolution{}, apperrors.NewValidation(
|
|
fmt.Sprintf("目标 %q 的资源类型为 %s,不是在线文字文档", selected[0].Name, selected[0].ResourceType),
|
|
apperrors.WithReason("document_target_type_mismatch"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithDetails(map[string]any{"status": "type_mismatch", "target": selected[0]}),
|
|
)
|
|
}
|
|
return Resolution{
|
|
ContractVersion: "doc.target.v1",
|
|
Status: "resolved",
|
|
Query: query,
|
|
MatchedBy: matchedBy,
|
|
Complete: true,
|
|
Selected: selected[0],
|
|
Candidates: []Candidate{},
|
|
}, nil
|
|
}
|
|
|
|
func searchAll(rt Reader, query string) ([]Candidate, error) {
|
|
cursor := ""
|
|
seenCursors := map[string]bool{}
|
|
all := make([]Candidate, 0)
|
|
for page := 1; page <= searchPageLimit; page++ {
|
|
params := map[string]any{"keyword": query, "pageSize": searchPageSize}
|
|
if cursor != "" {
|
|
params["pageToken"] = cursor
|
|
}
|
|
data, err := rt.CallMCPData("doc", "search_documents", params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows := extractRows(data)
|
|
all = append(all, extractCandidates(rows)...)
|
|
hasMore, hasMoreKnown, next := extractPage(data)
|
|
switch {
|
|
case hasMoreKnown && !hasMore:
|
|
return dedupe(all), nil
|
|
case hasMoreKnown && hasMore:
|
|
// next is validated below.
|
|
case next != "":
|
|
// Older responses may only publish the continuation.
|
|
case len(rows) < searchPageSize:
|
|
return dedupe(all), nil
|
|
default:
|
|
return nil, incompleteError(query, all, "搜索返回满页结果但没有结束标记或下一页游标")
|
|
}
|
|
next = strings.TrimSpace(next)
|
|
if next == "" {
|
|
return nil, incompleteError(query, all, "搜索声明仍有更多结果但缺少下一页游标")
|
|
}
|
|
if next == cursor || seenCursors[next] {
|
|
return nil, incompleteError(query, all, fmt.Sprintf("搜索分页游标停滞在 %q", next))
|
|
}
|
|
seenCursors[next] = true
|
|
cursor = next
|
|
}
|
|
return nil, incompleteError(query, all, fmt.Sprintf("搜索超过最大页数 %d", searchPageLimit))
|
|
}
|
|
|
|
func selectCandidates(candidates []Candidate, query string) ([]Candidate, string) {
|
|
exact := make([]Candidate, 0)
|
|
for _, candidate := range candidates {
|
|
if strings.EqualFold(strings.TrimSpace(candidate.Name), query) {
|
|
exact = append(exact, candidate)
|
|
}
|
|
}
|
|
if len(exact) > 0 {
|
|
return exact, "exact_title"
|
|
}
|
|
return candidates, "search_candidate"
|
|
}
|
|
|
|
func extractCandidates(rows []any) []Candidate {
|
|
out := make([]Candidate, 0, len(rows))
|
|
for _, row := range rows {
|
|
item, ok := row.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
candidate := Candidate{
|
|
CanonicalID: firstString(item, "nodeId", "node_id", "docId", "doc_id", "id"),
|
|
Name: firstString(item, "name", "title", "docName", "fileName"),
|
|
Product: "doc",
|
|
ResourceType: firstString(item, "docType", "doc_type", "extension", "fileType", "type"),
|
|
URL: firstString(item, "url", "docUrl", "nodeUrl", "webUrl"),
|
|
ContainerID: firstString(item, "workspaceId", "spaceId", "folderId", "parentId"),
|
|
}
|
|
if candidate.CanonicalID != "" {
|
|
out = append(out, candidate)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupe(in []Candidate) []Candidate {
|
|
seen := make(map[string]bool, len(in))
|
|
out := make([]Candidate, 0, len(in))
|
|
for _, candidate := range in {
|
|
if candidate.CanonicalID == "" || seen[candidate.CanonicalID] {
|
|
continue
|
|
}
|
|
seen[candidate.CanonicalID] = true
|
|
out = append(out, candidate)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractRows(data map[string]any) []any {
|
|
for _, key := range []string{"documents", "nodes", "items", "list", "records", "result", "data"} {
|
|
value, ok := data[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if rows, ok := value.([]any); ok {
|
|
return rows
|
|
}
|
|
if nested, ok := value.(map[string]any); ok {
|
|
if rows := extractRows(nested); rows != nil {
|
|
return rows
|
|
}
|
|
}
|
|
}
|
|
return []any{}
|
|
}
|
|
|
|
func extractPage(data map[string]any) (bool, bool, string) {
|
|
var hasMore bool
|
|
var hasMoreKnown bool
|
|
var next string
|
|
var walk func(map[string]any)
|
|
walk = func(value map[string]any) {
|
|
if !hasMoreKnown {
|
|
for _, key := range []string{"hasMore", "has_more"} {
|
|
if flag, ok := value[key].(bool); ok {
|
|
hasMore, hasMoreKnown = flag, true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if next == "" {
|
|
next = firstString(value, "nextPageToken", "nextCursor", "next_page_token")
|
|
}
|
|
for _, key := range []string{"result", "data", "page", "pagination"} {
|
|
if nested, ok := value[key].(map[string]any); ok {
|
|
walk(nested)
|
|
}
|
|
}
|
|
}
|
|
walk(data)
|
|
return hasMore, hasMoreKnown, next
|
|
}
|
|
|
|
func firstString(value map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
|
|
return strings.TrimSpace(text)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeType(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, ".")))
|
|
}
|
|
|
|
func resolutionError(status, query string, candidates []Candidate, message string) error {
|
|
return apperrors.NewValidation(
|
|
message,
|
|
apperrors.WithReason("document_target_"+status),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(false),
|
|
apperrors.WithDetails(map[string]any{
|
|
"contractVersion": "doc.target.v1",
|
|
"status": status,
|
|
"query": query,
|
|
"complete": true,
|
|
"candidates": candidates,
|
|
}),
|
|
)
|
|
}
|
|
|
|
func incompleteError(query string, candidates []Candidate, cause string) error {
|
|
return apperrors.NewAPI(
|
|
"无法证明文档搜索候选完整,已停止目标解析",
|
|
apperrors.WithReason("document_target_incomplete"),
|
|
apperrors.WithFailureStage("target_resolution"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(true),
|
|
apperrors.WithActions("稍后以相同条件重试", "使用已知 nodeId 或 URL 直接指定目标"),
|
|
apperrors.WithDetails(map[string]any{
|
|
"contractVersion": "doc.target.v1",
|
|
"status": "incomplete",
|
|
"query": query,
|
|
"complete": false,
|
|
"cause": cause,
|
|
"candidates": dedupe(candidates),
|
|
}),
|
|
)
|
|
}
|