Files
kandev/apps/backend/internal/automation/export_prompt.go
T
2026-08-24 11:09:03 +00:00

111 lines
4.1 KiB
Go

package automation
import (
"bytes"
"encoding/base64"
"fmt"
"strings"
"unicode/utf8"
"gopkg.in/yaml.v3"
)
// yamlNewlineChars is YAML 1.2's line-break set, per
// docs/specs/office/requirements/automations-yaml-export.md's Definitions section:
// {U+000A, U+000D, U+0085, U+2028, U+2029}. AC-17 and AC-46 are stated against this
// set, not against "\n" alone — a CR-only, NEL-only, LS-only, or PS-only prompt still
// forces a non-literal emission and must still warn under AC-17.
const yamlNewlineChars = "\n\r\u0085\u2028\u2029"
func containsYAMLNewline(s string) bool {
return strings.ContainsAny(s, yamlNewlineChars)
}
const (
warnPromptInvalidUTF8 = "prompt not emitted as a block scalar: invalid UTF-8"
warnPromptNotBlockScalar = "prompt not emitted as a block scalar"
warnPromptRequoted = "prompt re-quoted to preserve bytes"
)
// buildPromptNode resolves a non-empty, exported automation prompt to its final
// *yaml.Node form and an optional warning, following the ordered classification
// AC-15/16/17/46/47/49 require. Callers must not invoke this for an empty prompt:
// AC-4/AC-46 omit the `prompt` key entirely in that case, before any fidelity rule
// applies.
//
// Exactly one classification branch is taken, evaluated in this fixed order:
// 1. Not valid UTF-8 -> AC-47: emit as !!binary (base64), warn, never a failure.
// 2. Valid UTF-8, but the pinned marshaller's default-form probe does not decode
// back byte-for-byte -> AC-49: re-probe an explicit double-quoted !!str form; if
// that round-trips, emit it and warn "prompt re-quoted to preserve bytes"
// (this suppresses any AC-17 warning for this prompt); if it too fails to
// round-trip, that is a serialization failure (AC-36/AC-45) with no node.
// 3. Default form round-trips, but its emitted Style is not a literal block scalar
// and the prompt contains a YAML newline -> AC-17: emit the default form and
// warn, without overriding style.
// 4. Otherwise -> AC-16/AC-46: emit the default form, no warning.
//
// Both probes marshal the prompt alone through an encoder configured identically to
// the real document encoder (AC-12's 2-space indent, via newPinnedYAMLEncoder) and
// re-parse the result to read Tag/Style/Value — "observe, don't predict" per the
// spec's decision record, not an enumeration of which input characters are assumed
// to degrade.
func buildPromptNode(prompt string) (node *yaml.Node, warning string, err error) {
if !utf8.ValidString(prompt) {
return &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!binary",
Value: base64.StdEncoding.EncodeToString([]byte(prompt)),
}, warnPromptInvalidUTF8, nil
}
defaultNode, err := probeScalarEmission(prompt)
if err != nil {
return nil, "", err
}
if defaultNode.Value != prompt {
requoted := &yaml.Node{Kind: yaml.ScalarNode, Tag: yamlStrTag, Style: yaml.DoubleQuotedStyle, Value: prompt}
reprobed, err := probeScalarEmission(requoted)
if err != nil {
return nil, "", err
}
if reprobed.Value != prompt {
return nil, "", fmt.Errorf("automation: prompt does not round-trip under the default or double-quoted emission form")
}
return requoted, warnPromptRequoted, nil
}
if defaultNode.Style != yaml.LiteralStyle && containsYAMLNewline(prompt) {
return defaultNode, warnPromptNotBlockScalar, nil
}
return defaultNode, "", nil
}
// probeScalarEmission marshals v (a string or a *yaml.Node) through the pinned
// encoder, re-parses the result, and returns the resulting scalar node — from which
// Tag, Style, and Value can all be read. This is the probe step both branches of
// buildPromptNode share.
func probeScalarEmission(v any) (*yaml.Node, error) {
var buf bytes.Buffer
enc := newPinnedYAMLEncoder(&buf)
if err := enc.Encode(v); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
var doc yaml.Node
if err := yaml.Unmarshal(buf.Bytes(), &doc); err != nil {
return nil, err
}
if doc.Kind == yaml.DocumentNode {
if len(doc.Content) == 0 {
return nil, fmt.Errorf("automation: pinned encoder produced an empty document")
}
return doc.Content[0], nil
}
return &doc, nil
}