first commit
This commit is contained in:
349
go-api/internal/definition/skill.go
Normal file
349
go-api/internal/definition/skill.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package definition
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// One Markdown definition → one skill.
|
||||
//
|
||||
// A port of parseSkill and validateSkillSource in src/lib/skills/registry.js,
|
||||
// restricted to the definition contract — see the package documentation in
|
||||
// definition.go for exactly where that boundary is and why the `ui:` and
|
||||
// `owliver:` blocks are on the other side of it.
|
||||
|
||||
// Level is one rung of a workforce ladder, read from the body's own headings.
|
||||
type Level struct {
|
||||
Level string `json:"level"`
|
||||
Label string `json:"label"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Skill is a definition as the backend reads it.
|
||||
//
|
||||
// The five fields migration 000005 projects into columns — ID, Name,
|
||||
// Description, Status, Pages — are the compatibility contract; the rest is
|
||||
// carried because it is free once the frontmatter is parsed and because the
|
||||
// conformance suite compares it.
|
||||
type Skill struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
|
||||
// Pages as the AUTHOR WROTE THEM, not canonicalised.
|
||||
//
|
||||
// This is not an oversight and must not be "fixed": parseSkill keeps the
|
||||
// declared strings, so a skill written against `Talent Pool` is registered
|
||||
// under `Talent Pool` and resolved through normalizeKey at every use.
|
||||
// Agents are the other way round — see Agent.Pages. Canonicalising here
|
||||
// would make the backend's projection disagree with the editor's.
|
||||
Pages []string `json:"pages"`
|
||||
|
||||
Kind string `json:"kind"`
|
||||
Category string `json:"category"`
|
||||
Actions []string `json:"actions"`
|
||||
Triggers []string `json:"triggers"`
|
||||
DeclaredTriggers bool `json:"declaredTriggers"`
|
||||
Prompt *string `json:"prompt"`
|
||||
SkillID *string `json:"skillId"`
|
||||
Levels []Level `json:"levels"`
|
||||
|
||||
// Body is the Markdown after the frontmatter, trimmed. The definition
|
||||
// itself is NEVER rewritten — see Definition.Markdown.
|
||||
Body string `json:"-"`
|
||||
|
||||
// Deferred names the frontmatter blocks whose semantics this package does
|
||||
// not check and the frontend does. Empty for every definition the backend
|
||||
// can fully validate on its own. See package documentation.
|
||||
Deferred []string `json:"deferred,omitempty"`
|
||||
}
|
||||
|
||||
// AuthoredPath is the origin an authored definition has when the caller names
|
||||
// none. It is a value rather than an absence for one reason: it is the
|
||||
// frontend's own default parameter.
|
||||
//
|
||||
// parseSkill(raw, { path = 'custom', custom = false } = {})
|
||||
// parseAgent(raw, { path = 'custom', custom = false } = {})
|
||||
//
|
||||
// validateSkillSource and validateAgentSource both call their parser with no
|
||||
// path, so every definition the EDITOR checks derives its last-resort id from
|
||||
// the literal string `custom`. That is the same call the backend is making — a
|
||||
// definition submitted to the API is authored, not shipped — so the backend
|
||||
// must derive the same id.
|
||||
//
|
||||
// The difference is reachable and it is not cosmetic. A definition with no
|
||||
// `id:`, no `name:` and a valid `pages:` list gets the id `custom` on the
|
||||
// frontend, passes the id-format check and is ACCEPTED. Deriving no id here
|
||||
// would refuse it with "The frontmatter needs an `id`." — a definition that
|
||||
// validates in the editor and fails on save, which is the exact failure mode
|
||||
// this package exists to prevent. Fixture case: id-omitted-unnamed.
|
||||
const AuthoredPath = "custom"
|
||||
|
||||
// Options carries what the caller knows that the definition does not.
|
||||
type Options struct {
|
||||
// Path is the definition's origin, used only as the last fallback for an
|
||||
// id. Leave it empty for anything authored rather than shipped — which is
|
||||
// what the backend always has — and it becomes AuthoredPath, exactly as the
|
||||
// frontend's default parameter does.
|
||||
Path string
|
||||
}
|
||||
|
||||
// path is the origin an id is derived from, with the frontend's default
|
||||
// applied.
|
||||
func (o Options) path() string {
|
||||
if o.Path == "" {
|
||||
return AuthoredPath
|
||||
}
|
||||
return o.Path
|
||||
}
|
||||
|
||||
// ParseSkill reads a skill definition.
|
||||
//
|
||||
// Returns a *Error when the frontmatter cannot be read. A definition with no
|
||||
// frontmatter at all is not an error here: it parses to a skill carrying the
|
||||
// derived id and no pages, and ValidateSkill is what refuses it — the same
|
||||
// division of labour the frontend has.
|
||||
func ParseSkill(raw string, opts Options) (*Skill, error) {
|
||||
doc, err := ParseFrontmatter(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := doc.Data
|
||||
|
||||
declaredPages, _ := data["pages"].([]any)
|
||||
|
||||
// `id:` always wins. An explicit id is the address other definitions and
|
||||
// stored preferences refer to, and deriving over the top of one would
|
||||
// silently rename a skill. Slugging the name is what an author means by
|
||||
// leaving it out; the filename is right only for a file, which is why it
|
||||
// is last.
|
||||
id := jsTrim(jsString(data["id"]))
|
||||
if !jsTruthy(data["id"]) {
|
||||
id = slugify(data["name"])
|
||||
if id == "" {
|
||||
id = fileStem(opts.path())
|
||||
}
|
||||
}
|
||||
|
||||
levels := sectionLevels(doc.Body)
|
||||
|
||||
// Two things wear the same format. A definition that names a ladder is a
|
||||
// workforce skill; nothing else distinguishes them, so an author declares
|
||||
// one by writing one rather than by setting a flag.
|
||||
kind := jsTrim(jsString(data["kind"]))
|
||||
if !jsTruthy(data["kind"]) {
|
||||
kind = "assistant"
|
||||
if len(levels) > 0 {
|
||||
kind = "workforce"
|
||||
}
|
||||
}
|
||||
|
||||
skill := &Skill{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Levels: levels,
|
||||
Body: doc.Body,
|
||||
Name: "Untitled skill",
|
||||
Pages: stringsOf(declaredPages),
|
||||
}
|
||||
|
||||
if jsTruthy(data["name"]) {
|
||||
skill.Name = jsString(data["name"])
|
||||
}
|
||||
if jsTruthy(data["description"]) {
|
||||
skill.Description = jsString(data["description"])
|
||||
}
|
||||
if s, ok := data["category"].(string); ok {
|
||||
skill.Category = jsTrim(s)
|
||||
}
|
||||
|
||||
// The whole of a skill's lifecycle, and deliberately a coercion rather
|
||||
// than a check: the frontend reads anything that is not `inactive` as
|
||||
// `active`, so `status: bogus` registers as active rather than being
|
||||
// refused. Reproduced, not corrected — see the divergence note in
|
||||
// docs/phase-4d-parser-contract.md.
|
||||
skill.Status = "active"
|
||||
if s, ok := data["status"].(string); ok && s == "inactive" {
|
||||
skill.Status = "inactive"
|
||||
}
|
||||
|
||||
if actions, ok := data["actions"].([]any); ok {
|
||||
skill.Actions = stringsOf(actions)
|
||||
} else {
|
||||
skill.Actions = []string{}
|
||||
}
|
||||
|
||||
// A skill with no declared triggers answers to its own name, so a
|
||||
// definition that omits the field is still reachable by asking for it.
|
||||
// Explicit triggers replace the fallback rather than adding to it.
|
||||
triggers, hasTriggers := data["triggers"].([]any)
|
||||
skill.DeclaredTriggers = hasTriggers && len(triggers) > 0
|
||||
skill.Triggers = []string{}
|
||||
if skill.DeclaredTriggers {
|
||||
for _, t := range triggers {
|
||||
skill.Triggers = append(skill.Triggers, strings.ToLower(jsString(t)))
|
||||
}
|
||||
} else if jsTruthy(data["name"]) {
|
||||
skill.Triggers = append(skill.Triggers, strings.ToLower(jsString(data["name"])))
|
||||
}
|
||||
|
||||
if jsTruthy(data["prompt"]) {
|
||||
p := jsString(data["prompt"])
|
||||
skill.Prompt = &p
|
||||
}
|
||||
|
||||
// The capability in the skill graph a workforce definition governs:
|
||||
// `skill:`, or the id with a `-training` suffix dropped and dashes swapped
|
||||
// for underscores.
|
||||
if kind == "workforce" {
|
||||
base := id
|
||||
if jsTruthy(data["skill"]) {
|
||||
base = jsString(data["skill"])
|
||||
} else {
|
||||
base = strings.TrimSuffix(base, "-training")
|
||||
}
|
||||
s := strings.ReplaceAll(base, "-", "_")
|
||||
skill.SkillID = &s
|
||||
}
|
||||
|
||||
skill.Deferred = deferredBlocks(data)
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// sectionLevels reads the ladder a workforce definition defines, in order, from
|
||||
// the body's own headings. A rung with no prose is not a rung.
|
||||
func sectionLevels(body string) []Level {
|
||||
out := []Level{}
|
||||
for _, heading := range levelHeadings {
|
||||
summary := SectionText(body, heading)
|
||||
if summary == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Level{
|
||||
Level: strings.ToLower(heading),
|
||||
Label: heading,
|
||||
Summary: summary,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deferredBlocks names the frontmatter this package does not semantically
|
||||
// check. See the package documentation for why they are deferred rather than
|
||||
// validated or rejected.
|
||||
func deferredBlocks(data map[string]any) []string {
|
||||
out := []string{}
|
||||
for _, key := range []string{"ui", "owliver"} {
|
||||
if _, present := data[key]; present {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stringsOf renders a parsed sequence as the strings the frontend would read
|
||||
// out of it. Non-string entries are stringified rather than dropped, because
|
||||
// that is what every consumer of `pages` and `actions` does with them.
|
||||
func stringsOf(list []any) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, v := range list {
|
||||
out = append(out, jsString(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fileStem(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.LastIndexByte(path, '/'); i >= 0 {
|
||||
path = path[i+1:]
|
||||
}
|
||||
return strings.TrimSuffix(path, ".md")
|
||||
}
|
||||
|
||||
// ValidateSkill decides whether a skill definition may be stored.
|
||||
//
|
||||
// Returns nil when it may. The order is the order an author would fix things
|
||||
// in, and every message below is the frontend's message character for
|
||||
// character — an author who sees one in the editor and a different one from
|
||||
// the API is being told about two different problems.
|
||||
//
|
||||
// Two rules are the backend's own and are marked as such: the size bound and
|
||||
// the deferred-block rule. Both are explained in
|
||||
// docs/phase-4d-parser-contract.md.
|
||||
func ValidateSkill(raw string) error {
|
||||
if jsTrim(raw) == "" {
|
||||
return &Rejection{Message: "Paste or upload a Markdown definition."}
|
||||
}
|
||||
|
||||
// The backend's own rule, from migration 000005's markdown_size CHECK. The
|
||||
// editor does not enforce it, so a definition over the bound is one the
|
||||
// frontend accepts and the DATABASE refuses; refusing it here turns a
|
||||
// constraint violation into a message. See the contract document.
|
||||
if n := len([]rune(raw)); n > MaxMarkdownLength {
|
||||
return &Rejection{
|
||||
BackendOnly: true,
|
||||
Message: fmt.Sprintf(
|
||||
"That definition is %d characters. The limit is %d.", n, MaxMarkdownLength),
|
||||
}
|
||||
}
|
||||
|
||||
skill, err := ParseSkill(raw, Options{})
|
||||
if err != nil {
|
||||
// The subset reports the line it failed on, which is far more useful
|
||||
// than "could not be parsed".
|
||||
return &Rejection{Message: jsTrim("That definition could not be parsed. " + err.Error())}
|
||||
}
|
||||
|
||||
if skill.ID == "" {
|
||||
return &Rejection{Message: "The frontmatter needs an `id`."}
|
||||
}
|
||||
if !isDefinitionID(skill.ID) {
|
||||
return &Rejection{Message: "`id` must be lower-case letters, numbers and dashes."}
|
||||
}
|
||||
// Faithful to the frontend, where `name` has already fallen back to
|
||||
// `Untitled skill` and this check can therefore never fire. Kept so the
|
||||
// two validators have the same shape and the same order.
|
||||
if skill.Name == "" {
|
||||
return &Rejection{Message: "The frontmatter needs a `name`."}
|
||||
}
|
||||
// The backend's own rule, and it must be asked BEFORE the generic one
|
||||
// below. A `ui:` block declares the pages it draws on, and parseSkill falls
|
||||
// back to those pages when `pages:` is absent — a fallback this package
|
||||
// cannot compute, because it does not read the `ui:` vocabulary. Rather
|
||||
// than report an empty page list it never really established, say what is
|
||||
// actually missing. No shipped definition relies on the fallback: all
|
||||
// nineteen that carry a `ui:` or `owliver:` block also declare `pages:`.
|
||||
if len(skill.Pages) == 0 && len(skill.Deferred) > 0 {
|
||||
return &Rejection{
|
||||
BackendOnly: true,
|
||||
Message: "A definition with a `ui:` block needs an explicit `pages:` list.",
|
||||
}
|
||||
}
|
||||
if len(skill.Pages) == 0 {
|
||||
return &Rejection{Message: "The frontmatter needs at least one `pages` entry."}
|
||||
}
|
||||
|
||||
unknown := []string{}
|
||||
for _, p := range skill.Pages {
|
||||
if !SurfaceExists(p) {
|
||||
unknown = append(unknown, p)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
plural := ""
|
||||
if len(unknown) > 1 {
|
||||
plural = "s"
|
||||
}
|
||||
return &Rejection{Message: fmt.Sprintf(
|
||||
"Unsupported page%s: %s. Supported pages: %s.",
|
||||
plural, strings.Join(unknown, ", "), strings.Join(SupportedPages, ", "))}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user