package tools import ( "context" "encoding/json" "fmt" "sort" "strconv" "strings" "github.com/mark3labs/mcp-go/mcp" "fwh-toolbox-server/agentmcp/kit" "fwh-toolbox-server/dto" "fwh-toolbox-server/services" ) func parseEnv(v string) (string, error) { return kit.Enum("env", v, "prod", "prod", "test") } // ---- tou_template_list ---- type touTemplateListArgs struct { Env string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"` Search string `json:"search,omitempty" jsonschema:"搜索关键字(模版名称、电力公司等)"` Type *int `json:"type,omitempty" jsonschema:"模版类型:0 系统模版、1 API 模版、2 用户推荐;不填为全部"` Status *int `json:"status,omitempty" jsonschema:"发布状态:0 未发布、2 已发布;不填为全部"` Page int `json:"page,omitempty" jsonschema:"页码,默认 1"` Size int `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"` } var TouTemplateList = kit.Def[touTemplateListArgs]{ Name: "tou_template_list", Title: "查询 TOU 调度模版列表", Description: "分页查询 FWH 云平台的 TOU 调度模版(生产服或测试服)。返回 total 与 items(模版 ID、名称、类型、国家/地区、电力公司、电价类型、发布状态、更新时间等,以实际返回为准)。" + "查看某个模版的时段与调度模式用 tou_template_get。", ReadOnly: true, Handler: func(ctx context.Context, a touTemplateListArgs) (*mcp.CallToolResult, error) { env, err := parseEnv(a.Env) if err != nil { return kit.Fail(err), nil } page := kit.Page(a.Page) size := kit.Limit(a.Size, 20, 100) var data *dto.ResPage[interface{}] if env == "test" { data, err = services.FwhTestGetTouTemplateProList(uint(page), uint(size), a.Type, a.Status, strings.TrimSpace(a.Search)) } else { data, err = services.GetTouTemplateProList(uint(page), uint(size), a.Type, a.Status, strings.TrimSpace(a.Search)) } if err != nil { return kit.Upstream("FWH 平台 TOU 模版接口("+env+")", err), nil } if data == nil { return kit.Empty(""), nil } return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页", data.Total, page)), nil }, } // ---- tou_template_get ---- type touTemplateGetArgs struct { Env string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"` ID int `json:"id" jsonschema:"模版 ID"` Raw bool `json:"raw,omitempty" jsonschema:"为 true 时返回云端原始详情(字段完整但较长),默认 false 返回整理后的结构"` } var TouTemplateGet = kit.Def[touTemplateGetArgs]{ Name: "tou_template_get", Title: "查询 TOU 调度模版详情", Description: "查询单个 TOU 调度模版的详情,默认整理为:template(模版主体字段)与 strategies(按季节 × 日类型的策略)。" + "每个策略含 seasonName、month、dayName、ccociateSn、电价,以及 periods(时段):start、end、waveType(谷/平/峰/尖峰/低谷)、" + "solarPriority / loadPriority(原始值)、mode(识别出的调度模式:A~H、E/G、F/H、强制充电、强制放电、Zero Import、未设置、自定义)、params(设置了的扩展参数,-1 视为未设置)、issues(异常说明)。" + "优先级顺序:光伏 [市电, 负载, aPower],负载 [市电, 光伏, aPower]。raw=true 返回原始详情。", ReadOnly: true, Handler: func(ctx context.Context, a touTemplateGetArgs) (*mcp.CallToolResult, error) { env, err := parseEnv(a.Env) if err != nil { return kit.Fail(err), nil } if a.ID <= 0 { return kit.Failf("参数 id 必填且为正整数"), nil } var data *interface{} if env == "test" { data, err = services.FwhTestGetTouTemplateProDetailInfo(uint(a.ID)) } else { data, err = services.GetTouTemplateProDetailInfo(uint(a.ID)) } if err != nil { return kit.Upstream("FWH 平台 TOU 模版接口("+env+")", err), nil } if data == nil || *data == nil { return kit.Empty("模版不存在。"), nil } if a.Raw { return kit.Object(*data), nil } organized, ok := OrganizeTouDetail(*data) if !ok { // 结构与预期不符时退回原始详情,避免整理失败导致无结果 return kit.Object(*data), nil } return kit.Object(organized), nil }, } // ---- 详情整理与调度模式识别(规则见 docs/despatch-config/requirements.md 第 3 节) ---- var waveTypeNames = map[int]string{0: "谷", 1: "平", 2: "峰", 3: "尖峰", 4: "低谷"} // touParamKeys 时段中的扩展参数(-1 为未设置的哨兵值) var touParamKeys = []string{ "gridDischargeMax", "gridChargeMax", "chargePower", "dischargePower", "chargeMax", "dischargeMax", "gcaoMax", "gridFeedMax", "gridMax", "minDischargeSoc", "maxChargeSoc", "heatEnable", "powerOffApower", "rampTime", "offGrid", "solarCutoff", } // baseModes 基础模式:归一化后的光伏/负载优先级 → 模式 var baseModes = []struct { Name, Solar, Load string }{ {"A", "1,2,0", "1,3,2"}, {"B", "1,2,0", "1,2,0"}, {"C", "1,3,2", "1,2,0"}, {"D", "1,3,2", "1,3,2"}, {"E/G", "1,2,3", "1,2,0"}, {"F/H", "1,2,0", "1,2,3"}, } // TouPeriod 整理后的时段 type TouPeriod struct { Start string `json:"start"` End string `json:"end"` WaveType string `json:"waveType,omitempty"` Name string `json:"name,omitempty"` SolarPriority string `json:"solarPriority"` LoadPriority string `json:"loadPriority"` Mode string `json:"mode"` Params map[string]any `json:"params,omitempty"` Issues []string `json:"issues,omitempty"` } // OrganizeTouDetail 把云端详情整理为 template + strategies;找不到 strategyList 时返回 false。 func OrganizeTouDetail(detail any) (map[string]any, bool) { b, err := json.Marshal(detail) if err != nil { return nil, false } var root map[string]any if err := json.Unmarshal(b, &root); err != nil { return nil, false } // 兼容 { template, strategyList } 与 { ...模版字段, strategyList } 两种形态 strategies, _ := root["strategyList"].([]any) template := root if t, ok := root["template"].(map[string]any); ok { template = t if strategies == nil { strategies, _ = t["strategyList"].([]any) } } if strategies == nil { return nil, false } tpl := map[string]any{} for k, v := range template { if k != "strategyList" { tpl[k] = v } } out := make([]map[string]any, 0, len(strategies)) for _, s := range strategies { sm, ok := s.(map[string]any) if !ok { continue } st := map[string]any{} for _, k := range []string{"seasonName", "month", "dayName", "dayType", "ccociateType", "ccociateSn"} { if v, ok := sm[k]; ok { st[k] = v } } rates := map[string]any{} for k, v := range sm { if strings.HasPrefix(k, "eleticRate") || strings.HasPrefix(k, "eleticSell") { rates[k] = v } } if len(rates) > 0 { st["rates"] = rates } details, _ := sm["detailVoList"].([]any) periods := make([]TouPeriod, 0, len(details)) for _, d := range details { if dm, ok := d.(map[string]any); ok { periods = append(periods, organizePeriod(dm)) } } sort.SliceStable(periods, func(i, j int) bool { return periods[i].Start < periods[j].Start }) st["periods"] = periods if issues := checkCoverage(periods); len(issues) > 0 { st["issues"] = issues } out = append(out, st) } return map[string]any{"template": tpl, "strategies": out}, true } func organizePeriod(dm map[string]any) TouPeriod { p := TouPeriod{ Start: str(dm["startHourTime"]), End: str(dm["endHourTime"]), Name: str(dm["name"]), SolarPriority: str(dm["solarPriority"]), LoadPriority: str(dm["loadPriority"]), } if w, ok := num(dm["waveType"]); ok { if n, ok := waveTypeNames[int(w)]; ok { p.WaveType = n } else { p.WaveType = fmt.Sprintf("未知(%v)", w) } } params := map[string]any{} for _, k := range touParamKeys { v, ok := dm[k] if !ok || v == nil || str(v) == "" { continue } if n, isNum := num(v); isNum && n == -1 { continue } params[k] = v } if len(params) > 0 { p.Params = params } p.Mode, p.Issues = RecognizeMode(p.SolarPriority, p.LoadPriority, params) return p } // RecognizeMode 识别调度模式。params 只包含"设置了"的参数(有值且不为 -1)。 func RecognizeMode(solar, load string, params map[string]any) (string, []string) { solar, load = strings.TrimSpace(solar), strings.TrimSpace(load) var issues []string // 1. 特殊模式按原始值精确匹配 if solar == "9,9,9" && load == "9,9,9" { for k, want := range map[string]float64{"gridDischargeMax": 0.1, "gridFeedMax": 0.1} { if got, ok := num(params[k]); !ok || got != want { issues = append(issues, fmt.Sprintf("Zero Import 的 %s 应为 %v,实际为 %v", k, want, params[k])) } } sort.Strings(issues) return "Zero Import", issues } // 2. 两个优先级都为空 if solar == "" && load == "" { _, hasCharge := params["chargePower"] _, hasDischarge := params["dischargePower"] switch { case hasCharge && !hasDischarge: return "强制充电", nil case hasDischarge && !hasCharge: return "强制放电", nil case !hasCharge && !hasDischarge: return "未设置", nil default: return "自定义", []string{"优先级为空但同时设置了 chargePower 和 dischargePower"} } } if solar == "" || load == "" { return "自定义", []string{"光伏与负载优先级只有一个为空"} } // 3. 归一化后匹配 A~H ns, ok1 := normalizePriority(solar) nl, ok2 := normalizePriority(load) if ok1 && ok2 { for _, m := range baseModes { if ns == m.Solar && nl == m.Load { return m.Name, nil } } } return "自定义", nil } // normalizePriority 稠密排名归一化:0 保持 0,非 0 值按从小到大映射为 1、2、3,相同值映射为同一个数。 func normalizePriority(s string) (string, bool) { parts := strings.Split(s, ",") if len(parts) != 3 { return "", false } vals := make([]int, 3) uniq := map[int]bool{} for i, p := range parts { v, err := strconv.Atoi(strings.TrimSpace(p)) if err != nil || v < 0 { return "", false } vals[i] = v if v != 0 { uniq[v] = true } } sorted := make([]int, 0, len(uniq)) for v := range uniq { sorted = append(sorted, v) } sort.Ints(sorted) rank := map[int]int{} for i, v := range sorted { rank[v] = i + 1 } out := make([]string, 3) for i, v := range vals { if v == 0 { out[i] = "0" } else { out[i] = strconv.Itoa(rank[v]) } } return strings.Join(out, ","), true } // checkCoverage 检查时段是否首尾衔接覆盖 00:00~24:00。 func checkCoverage(ps []TouPeriod) []string { if len(ps) == 0 { return []string{"没有时段"} } var issues []string if ps[0].Start != "00:00" { issues = append(issues, "第一个时段不是从 00:00 开始") } for i := 1; i < len(ps); i++ { if ps[i].Start != ps[i-1].End { issues = append(issues, fmt.Sprintf("%s 与 %s 之间不衔接(%s → %s)", ps[i-1].End, ps[i].Start, ps[i-1].End, ps[i].Start)) } } if last := ps[len(ps)-1].End; last != "24:00" && last != "00:00" && last != "23:59" { issues = append(issues, "最后一个时段没有到 24:00") } return issues } func str(v any) string { switch t := v.(type) { case nil: return "" case string: return t case float64: return strconv.FormatFloat(t, 'f', -1, 64) default: return fmt.Sprint(t) } } func num(v any) (float64, bool) { switch t := v.(type) { case float64: return t, true case int: return float64(t), true case string: f, err := strconv.ParseFloat(strings.TrimSpace(t), 64) return f, err == nil } return 0, false }