diff --git a/shortcuts/workflow/aiclient.go b/shortcuts/workflow/aiclient.go index 6c8164e..6ec6a53 100644 --- a/shortcuts/workflow/aiclient.go +++ b/shortcuts/workflow/aiclient.go @@ -123,19 +123,63 @@ func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) { } text := result.Choices[0].Message.Content - var aiResp AIResponse - if err := json.Unmarshal([]byte(text), &aiResp); err != nil { + aiResp, err := parseAIResponse(text) + if err != nil { // Fallback: try to extract JSON from markdown code fences. if extracted := extractJSONFromMarkdown(text); extracted != "" { - if err2 := json.Unmarshal([]byte(extracted), &aiResp); err2 != nil { + aiResp2, err2 := parseAIResponse(extracted) + if err2 != nil { return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text) } + aiResp = aiResp2 } else { return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text) } } - return &aiResp, nil + return aiResp, nil +} + +// parseAIResponse unmarshals the AI's JSON output, coercing numeric arg values to strings. +func parseAIResponse(text string) (*AIResponse, error) { + // First pass: unmarshal into a flexible structure that accepts numbers in args. + raw := struct { + Analysis json.RawMessage `json:"analysis"` + Actions []struct { + Type string `json:"type"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Body map[string]interface{} `json:"body,omitempty"` + Module string `json:"module,omitempty"` + Command string `json:"command,omitempty"` + Args map[string]interface{} `json:"args,omitempty"` + } `json:"actions"` + }{} + if err := json.Unmarshal([]byte(text), &raw); err != nil { + return nil, err + } + + resp := &AIResponse{} + if err := json.Unmarshal(raw.Analysis, &resp.Analysis); err != nil { + // If it's not valid JSON, treat it as a plain string. + resp.Analysis = string(raw.Analysis) + } + for _, a := range raw.Actions { + args := make(map[string]string, len(a.Args)) + for k, v := range a.Args { + args[k] = fmt.Sprint(v) + } + resp.Actions = append(resp.Actions, AIAction{ + Type: a.Type, + Method: a.Method, + Path: a.Path, + Body: a.Body, + Module: a.Module, + Command: a.Command, + Args: args, + }) + } + return resp, nil } // HasKey reports whether the AI client is configured. diff --git a/shortcuts/workflow/steps.go b/shortcuts/workflow/steps.go index 1356e37..861d4b3 100644 --- a/shortcuts/workflow/steps.go +++ b/shortcuts/workflow/steps.go @@ -221,7 +221,34 @@ func executeSkillStep(ctx *common.RuntimeContext, step StepDef, sr *StepResult, fmt.Fprintf(os.Stderr, "[workflow] triage rule action fallback failed: %v\n", err) } } - // In AI mode, the rule engine's wiki actions were not generated. + // In AI mode, supplement health-report with deterministic wiki action. + // AI provides richer semantic analysis; rule engine ensures wiki publishing. + if usedAI && step.Name == "health-report" { + fmt.Fprintf(os.Stderr, "[workflow] health-report AI supplement: upstream_keys=%v\n", mapKeys(upstream)) + if ruleResp, err := runRuleEngine(step, upstream); err == nil && len(ruleResp.Actions) > 0 { + fmt.Fprintf(os.Stderr, "[workflow] health-report rule engine returned %d actions\n", len(ruleResp.Actions)) + content := "" + if s, ok := aiResp.Analysis.(string); ok && s != "" { + content = s + } + if content == "" { + if b, err := json.MarshalIndent(aiResp.Analysis, "", " "); err == nil { + content = "# 项目健康度报告\n\n" + string(b) + "\n" + } + } + // Rewrite the rule engine's wiki +create with AI-enhanced content. + for _, a := range ruleResp.Actions { + if a.Module == "wiki" && a.Command == "+create" { + if content != "" { + a.Args["content"] = content + } + aiResp.Actions = append(aiResp.Actions, a) + } + } + } else { + fmt.Fprintf(os.Stderr, "[workflow] health-report rule engine failed or empty: err=%v actions=%d\n", err, len(ruleResp.Actions)) + } + } // Supplement AI responses with wiki publishing so the full pipeline runs. if usedAI && step.Name == "contributor-ranking" { content := "" @@ -448,6 +475,14 @@ func isActionAllowed(action AIAction) bool { // parseCommandTarget splits a CLI command string into tokens, // respecting quoted arguments. +func mapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + func parseCommandTarget(target string) []string { var parts []string var current strings.Builder diff --git a/showcase/index.html b/showcase/index.html index 2a5c1ce..838f378 100644 --- a/showcase/index.html +++ b/showcase/index.html @@ -528,10 +528,7 @@ const WORKFLOWS = [ features: ["params", "execute"], desc:"输入仓库名 → 检查现状 → 补齐缺失(README/LICENSE/标签/里程碑/Issue)", params: [ - {k:"description", l:"项目描述", t:"textarea", v:"一个基于 Go 的 GitLink CLI 工具,提供命令行方式管理仓库、Issue、PR 等"}, - {k:"template", l:"模板类型", t:"select", opts:["通用项目","前端项目","后端项目","Go 项目","Python 项目","Java 项目"]}, - {k:"license", l:"许可证", t:"select", opts:["MIT","Apache-2.0","GPL-3.0","BSD-3-Clause","无"]}, - {k:"private", l:"仓库可见性", t:"select", opts:["公开","私有"]}, + {k:"desc", l:"项目描述", t:"textarea", v:"一个基于 Go 的 GitLink CLI 工具,提供命令行方式管理仓库、Issue、PR 等"}, ], steps:["获取仓库基本信息","检查文件结构","获取已有标签和里程碑","检查分支保护状态","[AI] 对比初始化清单,识别缺失项","[AI] 生成建议的初始化内容","[AI] 自动创建缺失的标签/里程碑/Issue","[AI] 生成项目初始化报告"] }, { name:"multi-repo", category:"协同", triggerType:"cron", triggerOn:"0 9 * * 1", diff --git a/showcase/showcase-server-linux b/showcase/showcase-server-linux index da30353..2d806af 100644 Binary files a/showcase/showcase-server-linux and b/showcase/showcase-server-linux differ