package recovery import ( "context" "fmt" "strings" ) type KnowledgeRetriever interface { Search(ctx context.Context, query string, rc RecoveryContext) (KnowledgeRetrieval, error) } type PlanOptions struct { EventID string EnableDocSearch bool } type Planner struct { retriever KnowledgeRetriever } func NewPlanner(retriever KnowledgeRetriever) *Planner { return &Planner{retriever: retriever} } func (p *Planner) Plan(ctx context.Context, rc RecoveryContext) RecoveryPlan { return p.PlanWithOptions(ctx, rc, PlanOptions{EnableDocSearch: true}) } func (p *Planner) PlanWithOptions(ctx context.Context, rc RecoveryContext, opts PlanOptions) RecoveryPlan { plan, exact := planExact(rc) if !exact { plan = RecoveryPlan{ Category: "unknown", DecisionOwner: DecisionOwnerAgent, Confidence: 0.35, Evidence: []string{"未命中内置高频恢复规则"}, } } routeReasons := buildAgentRouteReasons(plan) if opts.EnableDocSearch && len(routeReasons) > 0 { query := BuildFallbackQuery(rc) retrieval := p.searchKnowledge(ctx, query, rc) plan.DocSearch = retrieval.DocSearch if query != "" { plan.Evidence = append(plan.Evidence, fmt.Sprintf("fallback_query=%s", query)) } switch plan.DocSearch.Status { case "success": plan.Evidence = append(plan.Evidence, "命中开放平台文档检索结果") case "empty": plan.Evidence = append(plan.Evidence, "开放平台文档检索无结果") case "error": if strings.TrimSpace(plan.DocSearch.Error) != "" { plan.Evidence = append(plan.Evidence, fmt.Sprintf("开放平台文档检索失败=%s", plan.DocSearch.Error)) } else { plan.Evidence = append(plan.Evidence, "开放平台文档检索失败") } } if len(retrieval.KBHits) > 0 { plan.KBHits = append(plan.KBHits, retrieval.KBHits...) if plan.Category == "unknown" { plan.Confidence = 0.55 } } } else { plan.DocSearch = DocSearch{Status: "skipped"} } plan.KBHits = dedupeKBHits(plan.KBHits) plan.DocActions = dedupeDocActions(extractDocActions(plan.KBHits)) plan.SafeActions = nonNilStrings(normalizeSafeActions(plan.AutoActions)) plan.AutoActions = nonNilStrings(plan.SafeActions) plan.DecisionOwner = normalizeDecisionOwner(plan) plan.DecisionHints = buildDecisionHints(plan, rc) plan.HumanActions = nonNilStrings(uniqueStrings(plan.HumanActions)) plan.Evidence = nonNilStrings(uniqueStrings(plan.Evidence)) plan.RuleHints = buildRuleHints(plan) plan.AgentRoute = buildAgentRoute(opts.EventID, rc, Replay{}, plan, routeReasons) return plan } func (p *Planner) searchKnowledge(ctx context.Context, query string, rc RecoveryContext) KnowledgeRetrieval { retrieval := KnowledgeRetrieval{DocSearch: DocSearch{Query: query, Status: "skipped"}} if query == "" || p == nil || p.retriever == nil { return retrieval } result, err := p.retriever.Search(ctx, query, rc) if result.DocSearch.Query == "" { result.DocSearch.Query = query } if err != nil { if result.DocSearch.Status == "" { result.DocSearch.Status = "error" } if strings.TrimSpace(result.DocSearch.Error) == "" { result.DocSearch.Error = err.Error() } return result } if result.DocSearch.Status == "" { if len(result.DocSearch.Items) > 0 { result.DocSearch.Status = "success" } else { result.DocSearch.Status = "empty" } } return result } func dedupeKBHits(hits []KBHit) []KBHit { if len(hits) <= 1 { return hits } seen := make(map[string]struct{}, len(hits)) out := make([]KBHit, 0, len(hits)) for _, hit := range hits { key := strings.TrimSpace(hit.Source) + "|" + strings.TrimSpace(hit.Title) + "|" + strings.TrimSpace(hit.URL) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, hit) } return out } func planExact(rc RecoveryContext) (RecoveryPlan, bool) { evidence := []string{} if rc.CLIErrorCode != "" { evidence = append(evidence, "cli_error_code="+rc.CLIErrorCode) } if rc.HTTPStatus != 0 { evidence = append(evidence, fmt.Sprintf("http_status=%d", rc.HTTPStatus)) } if rc.CallStage != "" { evidence = append(evidence, "call_stage="+rc.CallStage) } switch { case rc.CLIErrorCode == cliAuthExpiredCode || strings.Contains(strings.ToLower(rc.RawError), "token验证失败") || strings.Contains(strings.ToLower(rc.RawError), "user_token_illegal"): shouldRetry := safeToRetry(rc, "auth") plan := RecoveryPlan{ Category: "auth", DecisionOwner: DecisionOwnerBuiltinRule, Confidence: 0.98, HumanActions: []string{"如果重复执行两次仍失败,再运行 dws auth login 重新登录"}, Evidence: append(evidence, "命中认证失效规则"), KBHits: []KBHit{{Source: "builtin", Title: "认证失效恢复规则", Score: 1}}, ShouldRetry: shouldRetry, ShouldStop: !shouldRetry, } if shouldRetry { plan.AutoActions = []string{"rerun_original_command"} } return plan, true case rc.CLIErrorCode == cliInvalidJSONCode || rc.CLIErrorCode == cliMissingParamCode || strings.Contains(strings.ToLower(rc.RawError), "json 解析失败"): return RecoveryPlan{ Category: "input", DecisionOwner: DecisionOwnerBuiltinRule, Confidence: 0.96, HumanActions: []string{"检查 JSON 结构、必填参数和字段名后再重试原命令"}, Evidence: append(evidence, "命中输入参数规则"), KBHits: []KBHit{{Source: "builtin", Title: "输入参数修正规则", Score: 1}}, ShouldStop: true, }, true case strings.HasSuffix(rc.CLIErrorCode, "_NOT_FOUND") || rc.CLIErrorCode == cliResourceNotFoundCode || containsAny(rc.RawError, "not found", "资源不存在", "不存在", "does not exist", "has been deleted", "deleted", "base_not_found", "document.notfound", "resource.notfound", "specified base does not exist"): return RecoveryPlan{ Category: "resource", DecisionOwner: DecisionOwnerBuiltinRule, Confidence: 0.92, HumanActions: []string{"先确认资源是否仍存在、ID 是否正确,再决定是否重试原命令"}, Evidence: append(evidence, "命中资源不存在规则"), KBHits: []KBHit{{Source: "builtin", Title: "资源不存在恢复规则", Score: 1}}, ShouldStop: true, }, true case rc.CLIErrorCode == cliPermissionCode || rc.HTTPStatus == 403 || containsAny(rc.RawError, "forbidden", "permission", "权限不足", "无权限", "operationillegal"): return RecoveryPlan{ Category: "permission", DecisionOwner: DecisionOwnerBuiltinRule, Confidence: 0.95, HumanActions: []string{"确认当前账号对目标资源有访问权限,必要时申请授权后再重试"}, Evidence: append(evidence, "命中权限规则"), KBHits: []KBHit{{Source: "builtin", Title: "权限不足恢复规则", Score: 1}}, ShouldStop: true, }, true case rc.CLIErrorCode == cliRateLimitCode || rc.HTTPStatus == 429 || containsAny(rc.RawError, "too many requests", "rate limit"): shouldRetry := safeToRetry(rc, "rate_limit") plan := RecoveryPlan{ Category: "rate_limit", DecisionOwner: DecisionOwnerBuiltinRule, Confidence: 0.94, HumanActions: []string{"如仍失败,请降低调用频率后再重试"}, Evidence: append(evidence, "命中限流规则"), KBHits: []KBHit{{Source: "builtin", Title: "限流恢复规则", Score: 1}}, ShouldRetry: shouldRetry, ShouldStop: !shouldRetry, } if shouldRetry { plan.AutoActions = []string{"wait_and_retry"} } return plan, true case rc.CLIErrorCode == cliTimeoutCode || rc.HTTPStatus >= 500 || containsAny(rc.RawError, "timeout", "deadline exceeded", "connection refused", "connection reset", "broken pipe", "502", "503", "504"): shouldRetry := safeToRetry(rc, "network") plan := RecoveryPlan{ Category: "network", DecisionOwner: DecisionOwnerBuiltinRule, Confidence: 0.9, HumanActions: []string{"如果重试后仍失败,请检查网络或稍后再试"}, Evidence: append(evidence, "命中网络/服务异常规则"), KBHits: []KBHit{{Source: "builtin", Title: "网络异常恢复规则", Score: 1}}, ShouldRetry: shouldRetry, ShouldStop: !shouldRetry, } if shouldRetry { plan.AutoActions = []string{"retry_original_command"} } return plan, true } return RecoveryPlan{}, false } func BuildFallbackQuery(rc RecoveryContext) string { parts := make([]string, 0, 6) if rc.CLIErrorCode != "" { parts = append(parts, rc.CLIErrorCode) } if rc.ToolName != "" { parts = append(parts, rc.ToolName) } if len(rc.CommandPath) > 0 { parts = append(parts, strings.Join(rc.CommandPath, " ")) } parts = append(parts, stableKeywords(rc.RawError)...) return strings.Join(uniqueStrings(parts), " ") } func safeToRetry(rc RecoveryContext, category string) bool { switch rc.OperationKind { case OperationRead: return category == "auth" || category == "rate_limit" || category == "network" case OperationWrite: if rc.CallStage != "http" { return false } return category == "auth" || category == "rate_limit" default: return false } } func normalizeDecisionOwner(plan RecoveryPlan) DecisionOwner { if plan.DecisionOwner != "" { return plan.DecisionOwner } if plan.Category == "unknown" { return DecisionOwnerAgent } return DecisionOwnerBuiltinRule } func stableKeywords(raw string) []string { normalized := normalizeRawError(raw) if normalized == "" { return nil } parts := strings.FieldsFunc(normalized, func(r rune) bool { return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' }) out := make([]string, 0, len(parts)) for _, part := range parts { if len(part) < 3 || strings.HasPrefix(part, "<") { continue } out = append(out, part) if len(out) >= 6 { break } } return uniqueStrings(out) } func uniqueStrings(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func containsAny(value string, targets ...string) bool { value = strings.ToLower(value) for _, target := range targets { if strings.Contains(value, strings.ToLower(target)) { return true } } return false } const ( cliAuthExpiredCode = "AUTH_TOKEN_EXPIRED" cliInvalidJSONCode = "INPUT_INVALID_JSON" cliMissingParamCode = "INPUT_MISSING_PARAM" cliPermissionCode = "AUTH_PERMISSION_DENIED" cliResourceNotFoundCode = "RESOURCE_NOT_FOUND" cliRateLimitCode = "NETWORK_RATE_LIMITED" cliTimeoutCode = "NETWORK_TIMEOUT" ) func normalizeSafeActions(actions []string) []string { allowed := make([]string, 0, len(actions)) for _, action := range actions { switch action { case "rerun_original_command", "retry_original_command", "wait_and_retry": allowed = append(allowed, action) } } return uniqueStrings(allowed) } func buildDecisionHints(plan RecoveryPlan, rc RecoveryContext) DecisionHints { signals := []string{rc.CLIErrorCode, rc.RawError} for _, hit := range plan.KBHits { signals = append(signals, hit.Title, hit.Snippet) } for _, action := range plan.DocActions { signals = append(signals, action.Action, action.Reason) } text := strings.Join(signals, " ") return DecisionHints{ Retryable: plan.ShouldRetry || len(plan.SafeActions) > 0, PermissionSensitive: plan.Category == "permission" || containsAny(text, "permission", "forbidden", "无权限", "operatorid has permission", "有访问权限", "inaccessible"), AuthRelated: plan.Category == "auth" || containsAny(text, "auth_token_expired", "token验证失败", "user_token_illegal", "认证失效", "token 已过期"), ResourceStateRelated: plan.Category == "resource" || containsAny(text, "not found", "does not exist", "deleted", "document.notfound", "resource.notfound", "资源不存在", "文档不存在", "彻底删除", "base_not_found"), } } func buildRuleHints(plan RecoveryPlan) RuleHints { return RuleHints{ Category: plan.Category, DecisionOwner: normalizeDecisionOwner(plan), Confidence: plan.Confidence, SafeActions: nonNilStrings(plan.SafeActions), HumanActions: nonNilStrings(plan.HumanActions), Evidence: nonNilStrings(plan.Evidence), ShouldRetry: plan.ShouldRetry, ShouldStop: plan.ShouldStop, } } func buildAgentRouteReasons(plan RecoveryPlan) []string { reasons := make([]string, 0, 2) if plan.Category == "unknown" { reasons = append(reasons, "unknown_category") } return nonNilStrings(uniqueStrings(reasons)) } func buildAgentRoute(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, reasons []string) AgentRoute { required := len(reasons) > 0 var payload *AgentRoutePayload decisionOwner := normalizeDecisionOwner(plan) if required { payload = &AgentRoutePayload{ EventID: eventID, Context: cloneRecoveryContext(rc), Replay: cloneReplay(replay), RawError: rc.RawError, Category: plan.Category, DecisionOwner: decisionOwner, Confidence: plan.Confidence, ShouldRetry: plan.ShouldRetry, ShouldStop: plan.ShouldStop, SafeActions: nonNilStrings(plan.SafeActions), DocActions: cloneDocActions(plan.DocActions), KBHits: cloneKBHits(plan.KBHits), DocSearch: cloneDocSearch(plan.DocSearch), HumanActions: nonNilStrings(plan.HumanActions), DecisionHints: plan.DecisionHints, Evidence: nonNilStrings(plan.Evidence), RuleHints: plan.RuleHints, } } return AgentRoute{ Required: required, Target: ternaryString(required, "agent_llm", ""), Executor: ternaryString(required, "dingtalk-workspace", ""), Reasons: nonNilStrings(reasons), Payload: payload, } } func HydratePlanForEvent(eventID string, rc RecoveryContext, replay Replay, plan *RecoveryPlan) { if plan == nil { return } plan.RuleHints = buildRuleHints(*plan) plan.AgentRoute = buildAgentRoute(eventID, rc, replay, *plan, buildAgentRouteReasons(*plan)) } func extractDocActions(hits []KBHit) []DocAction { out := make([]DocAction, 0) for _, hit := range hits { if strings.TrimSpace(hit.Snippet) == "" { continue } lines := strings.Split(hit.Snippet, "\n") for _, line := range lines { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "|") { continue } cells := splitTableRow(line) if len(cells) < 5 { continue } if strings.EqualFold(cells[0], "httpcode") || strings.HasPrefix(cells[0], "--") { continue } action := strings.TrimSpace(cells[len(cells)-1]) if action == "" || action == "%s" { continue } reasonParts := make([]string, 0, 2) if len(cells) > 1 && cells[1] != "" { reasonParts = append(reasonParts, cells[1]) } if len(cells) > 3 && cells[3] != "" && cells[3] != "%s" { reasonParts = append(reasonParts, cells[3]) } out = append(out, DocAction{ Action: action, Reason: strings.Join(reasonParts, ": "), SourceTitle: hit.Title, SourceURL: hit.URL, }) } } return out } func splitTableRow(line string) []string { parts := strings.Split(line, "|") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } out = append(out, part) } return out } func dedupeDocActions(actions []DocAction) []DocAction { if len(actions) <= 1 { return actions } seen := make(map[string]struct{}, len(actions)) out := make([]DocAction, 0, len(actions)) for _, action := range actions { key := strings.TrimSpace(action.Action) + "|" + strings.TrimSpace(action.Reason) + "|" + strings.TrimSpace(action.SourceTitle) + "|" + strings.TrimSpace(action.SourceURL) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, action) } return out } func cloneDocActions(actions []DocAction) []DocAction { if len(actions) == 0 { return nil } out := make([]DocAction, len(actions)) copy(out, actions) return out } func cloneKBHits(hits []KBHit) []KBHit { if len(hits) == 0 { return nil } out := make([]KBHit, len(hits)) copy(out, hits) return out } func cloneDocSearch(search DocSearch) DocSearch { cloned := search if search.Request != nil { cloned.Request = &ToolCallRecord{ ServerID: search.Request.ServerID, ToolName: search.Request.ToolName, Arguments: cloneMap(search.Request.Arguments), } } if search.Response != nil { cloned.Response = &ToolResponse{IsError: search.Response.IsError} if len(search.Response.Content) > 0 { cloned.Response.Content = make([]ToolResponseBlock, len(search.Response.Content)) copy(cloned.Response.Content, search.Response.Content) } } if len(search.Items) > 0 { cloned.Items = make([]DocSearchItem, len(search.Items)) copy(cloned.Items, search.Items) } return cloned } func ternaryString(ok bool, yes, no string) string { if ok { return yes } return no } func nonNilStrings(values []string) []string { if len(values) == 0 { return []string{} } return append([]string{}, values...) }