fix(workflow): AI JSON 解析支持数字类型参数,补充 health-report wiki 发布逻辑

- parseAIResponse 将 args 中的数字值统一转为字符串,避免类型不匹配
- health-report 步骤 AI 模式下补充规则引擎的 wiki 发布动作
- 简化 init 工作流参数,去掉冗余选项
- 新增 mapKeys 辅助函数
This commit is contained in:
yetja 2026-07-07 16:52:39 +08:00
parent 43eb36cb33
commit c6ebe659bc
4 changed files with 85 additions and 9 deletions

View File

@ -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.

View File

@ -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

View File

@ -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",

Binary file not shown.