281 lines
7.9 KiB
Go
281 lines
7.9 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package generator
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
|
|
registryassets "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/registry"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
PersonaRegistryPathEnv = "DWS_SKILLS_PERSONAS_FILE"
|
|
RecipeRegistryPathEnv = "DWS_SKILLS_RECIPES_FILE"
|
|
)
|
|
|
|
type PersonaRegistry struct {
|
|
Personas []PersonaEntry `yaml:"personas"`
|
|
}
|
|
|
|
type PersonaEntry struct {
|
|
Name string `yaml:"name"`
|
|
Title string `yaml:"title"`
|
|
Description string `yaml:"description"`
|
|
Services []string `yaml:"services"`
|
|
Products []string `yaml:"products,omitempty"` // Backward-compatible alias of services.
|
|
Workflows []string `yaml:"workflows"`
|
|
Instructions []string `yaml:"instructions"`
|
|
Tips []string `yaml:"tips"`
|
|
}
|
|
|
|
type RecipeRegistry struct {
|
|
Recipes []RecipeEntry `yaml:"recipes"`
|
|
}
|
|
|
|
type RecipeEntry struct {
|
|
Name string `yaml:"name"`
|
|
Title string `yaml:"title"`
|
|
Description string `yaml:"description"`
|
|
Category string `yaml:"category"`
|
|
Services []string `yaml:"services"`
|
|
Products []string `yaml:"products,omitempty"` // Backward-compatible alias of services.
|
|
Steps []string `yaml:"steps"`
|
|
Caution string `yaml:"caution"`
|
|
}
|
|
|
|
var knownRegistryProducts = map[string]struct{}{
|
|
"aiapp": {},
|
|
"aidesign": {},
|
|
"aitable": {},
|
|
"attendance": {},
|
|
"calendar": {},
|
|
"chat": {},
|
|
"conference": {},
|
|
"contact": {},
|
|
"devdoc": {},
|
|
"ding": {},
|
|
"doc": {},
|
|
"docparse": {},
|
|
"drive": {},
|
|
"finance": {},
|
|
"law": {},
|
|
"live": {},
|
|
"mail": {},
|
|
"minutes": {},
|
|
"oa": {},
|
|
"report": {},
|
|
"todo": {},
|
|
"workbench": {},
|
|
}
|
|
|
|
func loadPersonaRegistry() (PersonaRegistry, error) {
|
|
data, err := readRegistryYAML(PersonaRegistryPathEnv, registryassets.PersonasYAML())
|
|
if err != nil {
|
|
return PersonaRegistry{}, err
|
|
}
|
|
var registry PersonaRegistry
|
|
if err := yaml.Unmarshal(data, ®istry); err != nil {
|
|
return PersonaRegistry{}, fmt.Errorf("decode personas registry: %w", err)
|
|
}
|
|
if err := validatePersonaRegistry(registry); err != nil {
|
|
return PersonaRegistry{}, err
|
|
}
|
|
return registry, nil
|
|
}
|
|
|
|
func loadRecipeRegistry() (RecipeRegistry, error) {
|
|
data, err := readRegistryYAML(RecipeRegistryPathEnv, registryassets.RecipesYAML())
|
|
if err != nil {
|
|
return RecipeRegistry{}, err
|
|
}
|
|
var registry RecipeRegistry
|
|
if err := yaml.Unmarshal(data, ®istry); err != nil {
|
|
return RecipeRegistry{}, fmt.Errorf("decode recipes registry: %w", err)
|
|
}
|
|
if err := validateRecipeRegistry(registry); err != nil {
|
|
return RecipeRegistry{}, err
|
|
}
|
|
return registry, nil
|
|
}
|
|
|
|
func validatePersonaRegistry(registry PersonaRegistry) error {
|
|
if len(registry.Personas) == 0 {
|
|
return fmt.Errorf("personas registry is empty")
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, persona := range registry.Personas {
|
|
name := strings.TrimSpace(persona.Name)
|
|
if name == "" {
|
|
return fmt.Errorf("personas registry has empty name")
|
|
}
|
|
if _, ok := seen[name]; ok {
|
|
return fmt.Errorf("personas registry has duplicate name %q", name)
|
|
}
|
|
seen[name] = struct{}{}
|
|
if strings.TrimSpace(persona.Title) == "" {
|
|
return fmt.Errorf("persona %q missing title", name)
|
|
}
|
|
if strings.TrimSpace(persona.Description) == "" {
|
|
return fmt.Errorf("persona %q missing description", name)
|
|
}
|
|
services := persona.serviceRefs()
|
|
if len(services) == 0 {
|
|
return fmt.Errorf("persona %q missing services", name)
|
|
}
|
|
if len(persona.Instructions) == 0 {
|
|
return fmt.Errorf("persona %q missing instructions", name)
|
|
}
|
|
for _, service := range services {
|
|
service = normalizeRegistryToken(service)
|
|
if service == "" {
|
|
return fmt.Errorf("persona %q has empty service", name)
|
|
}
|
|
if _, ok := knownRegistryProducts[service]; !ok {
|
|
return fmt.Errorf("persona %q references unknown service %q", name, service)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRecipeRegistry(registry RecipeRegistry) error {
|
|
if len(registry.Recipes) == 0 {
|
|
return fmt.Errorf("recipes registry is empty")
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, recipe := range registry.Recipes {
|
|
name := strings.TrimSpace(recipe.Name)
|
|
if name == "" {
|
|
return fmt.Errorf("recipes registry has empty name")
|
|
}
|
|
if _, ok := seen[name]; ok {
|
|
return fmt.Errorf("recipes registry has duplicate name %q", name)
|
|
}
|
|
seen[name] = struct{}{}
|
|
if strings.TrimSpace(recipe.Title) == "" {
|
|
return fmt.Errorf("recipe %q missing title", name)
|
|
}
|
|
if strings.TrimSpace(recipe.Description) == "" {
|
|
return fmt.Errorf("recipe %q missing description", name)
|
|
}
|
|
if strings.TrimSpace(recipe.Category) == "" {
|
|
return fmt.Errorf("recipe %q missing category", name)
|
|
}
|
|
services := recipe.serviceRefs()
|
|
if len(services) == 0 {
|
|
return fmt.Errorf("recipe %q missing services", name)
|
|
}
|
|
if len(recipe.Steps) == 0 {
|
|
return fmt.Errorf("recipe %q missing steps", name)
|
|
}
|
|
for _, service := range services {
|
|
service = normalizeRegistryToken(service)
|
|
if service == "" {
|
|
return fmt.Errorf("recipe %q has empty service", name)
|
|
}
|
|
if _, ok := knownRegistryProducts[service]; !ok {
|
|
return fmt.Errorf("recipe %q references unknown service %q", name, service)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRegistryReferences(personas PersonaRegistry, recipes RecipeRegistry) error {
|
|
validWorkflows := map[string]struct{}{}
|
|
for _, recipe := range recipes.Recipes {
|
|
name := normalizeRegistryToken(recipe.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
validWorkflows[name] = struct{}{}
|
|
validWorkflows["recipe-"+name] = struct{}{}
|
|
}
|
|
for _, persona := range personas.Personas {
|
|
name := strings.TrimSpace(persona.Name)
|
|
for _, workflow := range persona.Workflows {
|
|
token := normalizeRegistryToken(workflow)
|
|
if token == "" {
|
|
return fmt.Errorf("persona %q has empty workflow", name)
|
|
}
|
|
if _, ok := validWorkflows[token]; !ok {
|
|
return fmt.Errorf("persona %q references unknown workflow %q", name, workflow)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func uniqueSkillProducts(products []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(products))
|
|
for _, product := range products {
|
|
product = normalizeRegistryToken(product)
|
|
if product == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[product]; ok {
|
|
continue
|
|
}
|
|
seen[product] = struct{}{}
|
|
out = append(out, product)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
func (p PersonaEntry) serviceRefs() []string {
|
|
refs := make([]string, 0, len(p.Services)+len(p.Products))
|
|
refs = append(refs, p.Services...)
|
|
refs = append(refs, p.Products...)
|
|
return uniqueSkillProducts(refs)
|
|
}
|
|
|
|
func (r RecipeEntry) serviceRefs() []string {
|
|
refs := make([]string, 0, len(r.Services)+len(r.Products))
|
|
refs = append(refs, r.Services...)
|
|
refs = append(refs, r.Products...)
|
|
return uniqueSkillProducts(refs)
|
|
}
|
|
|
|
func normalizeRegistryToken(value string) string {
|
|
value = strings.TrimSpace(strings.ToLower(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
slug := safeSkillSegment(value)
|
|
if slug == "unknown" {
|
|
return ""
|
|
}
|
|
return slug
|
|
}
|
|
|
|
func readRegistryYAML(pathEnv string, embedded []byte) ([]byte, error) {
|
|
if path, ok := os.LookupEnv(pathEnv); ok && strings.TrimSpace(path) != "" {
|
|
data, err := os.ReadFile(strings.TrimSpace(path))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read registry file %q: %w", strings.TrimSpace(path), err)
|
|
}
|
|
return data, nil
|
|
}
|
|
if len(embedded) == 0 {
|
|
return nil, fmt.Errorf("embedded registry content is empty")
|
|
}
|
|
return embedded, nil
|
|
}
|