feat(nomen): name-composition engine + fold in the two shipped schemes (t-paliad-356 Slice 1)

Slice 1 of the filename-generator train (PRD 2026-06-01 §8). Pure refactor
behind byte-equality — no user-visible change.

pkg/nomen: the reusable engine. A Composition (ordered Segments, each with
a trailing separator, optional wrap, and an omit/placeholder/literal
missing-rule) renders against a VarResolver and a RenderTarget. Targets
split into SanitiseValue (per-variable) + Finalise (whole-string + suffix)
so a human title and a sanitised filename are two targets of one
composition. VarCatalog + Validate guard stored compositions.

internal/services/namegen.go: paliad-side wiring — the two seed system-
default compositions that reproduce AutoSubmissionTitle (#155) and
submissionFileName (354) as DATA, their variable catalogs, the resolvers
(built from the existing submission_autoname helpers), and the artifact
registry binding artifact -> catalog -> target -> default.

Repointed call-sites: AutoSubmissionTitle and handlers.submissionFileName
are now thin wrappers rendering through the registry; the assembly logic
lives in the engine. Removed the hardcoded title/filename assembly and the
handler's Az.-folgt const (now the case_number segment's placeholder).

FLAG resolved (separators): the PRD sketched LEADING separators; that can't
reproduce #155's client-absent case (date must join forum with a space
while forum->opponent stays ' ./. '). Switched to TRAILING separators
(owned by the left segment) — the minimal faithful fix. PRD §2.1 annotated.

FLAG resolved (back-compat): the shipped composer_meta.filename_keyword
override still flows through the engine — live round-trip test green.

Acceptance: all existing #155/354 test matrices pass UNCHANGED (the
byte-equality gate); new pkg/nomen unit tests cover trailing-sep, the three
missing-rules, targets, and Validate; namegen_test validates the seeds
against their catalogs. go vet + go test ./... + bun build all clean.
This commit is contained in:
mAi
2026-06-01 11:56:27 +02:00
parent 385abc7a98
commit 4920328b09
7 changed files with 654 additions and 83 deletions

228
pkg/nomen/nomen.go Normal file
View File

@@ -0,0 +1,228 @@
// Package nomen renders human- and machine-facing names from a reusable
// composition model (Latin nomen, "name"). It is the engine extracted from
// the one-off naming functions that shipped for submission draft titles
// (m/paliad#155) and exported .docx filenames (t-paliad-354); see
// docs/plans/prd-filename-generator-2026-06-01.md.
//
// The package is pure: no DB, no HTTP, no filesystem, and no dependency on
// the rest of paliad. A consumer supplies a Composition (the template), a
// VarResolver (the values for this render), and a RenderTarget (the output
// policy — a human title vs a sanitised filename). The same Composition
// renders to different targets.
//
// # Separator semantics (trailing, not leading)
//
// Each Segment carries a Sep that is the separator emitted AFTER it, and
// only when a later segment also emits. So the separator between two
// consecutive emitted segments is owned by the LEFT segment. This is what
// lets a composition stay byte-faithful when a middle segment drops out:
// the draft-title scheme joins the date to the party trio with a space and
// the parties to each other with " ./. ", and when the client is absent the
// date must still join the forum with a space — which only works if the
// space is the date's trailing separator, independent of which identity
// segment happens to come next. A leading-separator model can't express
// that (the same segment would need two different leading separators
// depending on what was omitted before it).
package nomen
import "strings"
// Version is the current Composition schema version. Stored compositions
// (firm/user overrides, once those land) carry it so a future change can be
// detected and migrated; the seed system defaults always use this value.
const Version = 1
// MaxSegments is a sanity cap on how many segments a single composition may
// have. The wired artifacts use 34; the cap exists so a stored override
// can't smuggle an unbounded blob through Validate.
const MaxSegments = 16
// MissingKind selects what a segment contributes when its variable resolves
// empty or unavailable.
type MissingKind int
const (
// KindOmit drops the segment entirely (and suppresses its trailing
// separator). Generalises the #155 "drop empty segment with its
// separator" rule.
KindOmit MissingKind = iota
// KindPlaceholder substitutes a stand-in value for missing data, e.g.
// "(Az. folgt)" for an as-yet-unknown case number (t-paliad-354).
KindPlaceholder
// KindLiteral substitutes a fixed label. Functionally identical to a
// placeholder today, but kept distinct so the settings UI can word them
// differently ("fixed label" vs "stand-in for missing data") and so
// future policy can diverge.
KindLiteral
)
// MissingRule is a segment's missing-value policy. Value is ignored for
// KindOmit.
type MissingRule struct {
Kind MissingKind
Value string
}
// Omit returns a drop-when-empty rule.
func Omit() MissingRule { return MissingRule{Kind: KindOmit} }
// Placeholder returns a substitute-when-empty rule for missing data.
func Placeholder(v string) MissingRule { return MissingRule{Kind: KindPlaceholder, Value: v} }
// Literal returns a substitute-when-empty rule for a fixed label.
func Literal(v string) MissingRule { return MissingRule{Kind: KindLiteral, Value: v} }
// Segment is one piece of a composition.
type Segment struct {
// Var is the variable key resolved against the catalog/resolver.
Var string
// Sep is the trailing separator: emitted AFTER this segment iff a later
// segment also emits. The last emitted segment's Sep is never used.
Sep string
// Wrap surrounds the resolved value with fixed literals, e.g.
// {"(", ")"} for a bracketed case number. The wrap is part of the frame:
// it is NOT passed through the target's value sanitiser.
Wrap [2]string
// Missing is the policy applied when Var resolves empty/unavailable.
Missing MissingRule
}
// Composition is the canonical, validated name template: an ordered list of
// segments plus a schema version.
type Composition struct {
Version int
Segments []Segment
}
// VarResolver yields a variable's value for one render. It returns
// (value, true) when the variable is available (even if the consumer wants
// to force it empty by returning ("", true) — though the engine treats a
// blank value as absent regardless), and ("", false) when the variable is
// unavailable in this context, in which case the segment's MissingRule
// applies.
type VarResolver func(key string) (value string, ok bool)
// RenderTarget post-processes a render. SanitiseValue runs per resolved
// variable value (before wrapping/assembly); Finalise runs once on the
// fully-assembled string (e.g. to append an extension).
type RenderTarget interface {
Name() string
SanitiseValue(v string) string
Finalise(assembled string) string
}
// Render assembles the name. For each segment in order it resolves the
// value (applying the MissingRule when empty), sanitises the value via the
// target, wraps it, and joins it to the previous emitted segment using that
// previous segment's trailing Sep. The assembled string is passed once
// through Finalise.
func (c Composition) Render(resolve VarResolver, target RenderTarget) string {
var b strings.Builder
var pendingSep string
emitted := false
for _, seg := range c.Segments {
val, ok := effectiveValue(seg, resolve)
if !ok {
continue
}
val = target.SanitiseValue(val)
piece := seg.Wrap[0] + val + seg.Wrap[1]
if emitted {
b.WriteString(pendingSep)
}
b.WriteString(piece)
pendingSep = seg.Sep
emitted = true
}
return target.Finalise(b.String())
}
// effectiveValue resolves a segment to its emitted value, applying the
// MissingRule. The second return is false when the segment contributes
// nothing (omit, or a placeholder/literal whose value is itself blank).
// A resolved value is trimmed; a blank resolved value is treated as absent.
func effectiveValue(seg Segment, resolve VarResolver) (string, bool) {
val, ok := resolve(seg.Var)
val = strings.TrimSpace(val)
if ok && val != "" {
return val, true
}
switch seg.Missing.Kind {
case KindPlaceholder, KindLiteral:
v := strings.TrimSpace(seg.Missing.Value)
if v == "" {
return "", false
}
return v, true
default: // KindOmit
return "", false
}
}
// VarDef is a variable's catalog metadata: it drives write-time validation
// and the settings palette. Values come from the per-render VarResolver, not
// from here — the catalog is metadata only.
type VarDef struct {
Key string
Label string // DE primary
LabelEN string
Description string
Group string
}
// VarCatalog is the set of variables available to an artifact, keyed by Var.
type VarCatalog map[string]VarDef
// Validate enforces the structural invariants on a composition against the
// catalog of an artifact. Used on writes (stored firm/user overrides). The
// seed system defaults are validated by a unit test so a typo can't ship.
func (c Composition) Validate(catalog VarCatalog) error {
if c.Version != Version {
return &ValidationError{Msg: "unsupported composition version"}
}
if len(c.Segments) > MaxSegments {
return &ValidationError{Msg: "too many segments"}
}
for _, seg := range c.Segments {
if strings.TrimSpace(seg.Var) == "" {
return &ValidationError{Msg: "segment has empty variable"}
}
if _, ok := catalog[seg.Var]; !ok {
return &ValidationError{Msg: "unknown variable: " + seg.Var}
}
}
return nil
}
// ValidationError is returned by Composition.Validate. It is a distinct type
// so consumers can map it to a 400 without string-matching.
type ValidationError struct{ Msg string }
func (e *ValidationError) Error() string { return "nomen: " + e.Msg }
// FuncTarget is the general RenderTarget: an optional per-value sanitiser
// and a fixed suffix appended on finalise. A zero FuncTarget (nil sanitiser,
// empty suffix) is an identity target suitable for human titles.
type FuncTarget struct {
NameVal string
Sanitiser func(string) string
Suffix string
}
// Name reports the target name (e.g. "title", "filename").
func (t FuncTarget) Name() string { return t.NameVal }
// SanitiseValue applies the per-value sanitiser, or is identity when none.
func (t FuncTarget) SanitiseValue(v string) string {
if t.Sanitiser == nil {
return v
}
return t.Sanitiser(v)
}
// Finalise appends the target's suffix to the assembled string.
func (t FuncTarget) Finalise(assembled string) string { return assembled + t.Suffix }
// PlainTarget returns an identity target (no sanitisation, no suffix) for
// human-facing names such as draft titles.
func PlainTarget(name string) RenderTarget { return FuncTarget{NameVal: name} }

115
pkg/nomen/nomen_test.go Normal file
View File

@@ -0,0 +1,115 @@
package nomen
import (
"strings"
"testing"
)
// mapResolver builds a VarResolver from a map: a present key (even empty) is
// reported present only when its value is non-blank, matching the engine's
// blank-is-absent contract.
func mapResolver(m map[string]string) VarResolver {
return func(key string) (string, bool) {
v, ok := m[key]
return v, ok
}
}
// upperSanitiser is a stand-in per-value transform used to prove SanitiseValue
// runs on values but not on separators or wraps.
func upperSanitiser(s string) string { return strings.ToUpper(s) }
func TestRender_TrailingSeparators(t *testing.T) {
// date joins with " ", parties join with " ./. " — the draft-title shape.
comp := Composition{Version: Version, Segments: []Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "client", Sep: " ./. ", Missing: Omit()},
{Var: "forum", Sep: " ./. ", Missing: Omit()},
{Var: "opponent", Sep: "", Missing: Omit()},
}}
cases := []struct {
name string
vars map[string]string
want string
}{
{"all present", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "forum": "UPC", "opponent": "Novartis"}, "2026-05-31 Bayer AG ./. UPC ./. Novartis"},
{"client absent — date joins forum with a space", map[string]string{"date": "2026-05-31", "forum": "UPC", "opponent": "Novartis"}, "2026-05-31 UPC ./. Novartis"},
{"only opponent absent", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "forum": "UPC"}, "2026-05-31 Bayer AG ./. UPC"},
{"forum absent", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "opponent": "Acme"}, "2026-05-31 Bayer AG ./. Acme"},
{"date only", map[string]string{"date": "2026-05-31"}, "2026-05-31"},
{"blank value treated as absent", map[string]string{"date": "2026-05-31", "client": " ", "forum": "UPC"}, "2026-05-31 UPC"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := comp.Render(mapResolver(c.vars), PlainTarget("title"))
if got != c.want {
t.Errorf("Render = %q, want %q", got, c.want)
}
})
}
}
func TestRender_MissingRulesAndTargets(t *testing.T) {
// The filename shape: keyword literal fallback, case placeholder + wrap,
// a sanitiser + suffix target.
comp := Composition{Version: Version, Segments: []Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "keyword", Sep: " ", Missing: Literal("submission")},
{Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: Placeholder("Az. folgt")},
}}
target := FuncTarget{NameVal: "filename", Sanitiser: upperSanitiser, Suffix: ".docx"}
cases := []struct {
name string
vars map[string]string
want string
}{
{"all present — value sanitised, frame preserved", map[string]string{"date": "2026-05-31", "keyword": "Replik", "case_number": "x/y"}, "2026-05-31 REPLIK (X/Y).docx"},
{"keyword empty → literal fallback (also sanitised)", map[string]string{"date": "2026-05-31", "case_number": "abc"}, "2026-05-31 SUBMISSION (ABC).docx"},
{"case empty → placeholder, wrapped", map[string]string{"date": "2026-05-31", "keyword": "Replik"}, "2026-05-31 REPLIK (AZ. FOLGT).docx"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := comp.Render(mapResolver(c.vars), target)
if got != c.want {
t.Errorf("Render = %q, want %q", got, c.want)
}
})
}
}
func TestRender_EmptyPlaceholderOmits(t *testing.T) {
// A placeholder/literal whose value is itself blank contributes nothing
// (and suppresses its trailing separator).
comp := Composition{Version: Version, Segments: []Segment{
{Var: "a", Sep: "-", Missing: Placeholder(" ")},
{Var: "b", Sep: "", Missing: Omit()},
}}
got := comp.Render(mapResolver(map[string]string{"b": "tail"}), PlainTarget("x"))
if got != "tail" {
t.Errorf("Render = %q, want %q", got, "tail")
}
}
func TestValidate(t *testing.T) {
cat := VarCatalog{"date": {Key: "date"}, "client": {Key: "client"}}
ok := Composition{Version: Version, Segments: []Segment{{Var: "date"}, {Var: "client"}}}
if err := ok.Validate(cat); err != nil {
t.Fatalf("valid composition rejected: %v", err)
}
bad := []struct {
name string
comp Composition
}{
{"wrong version", Composition{Version: 0, Segments: []Segment{{Var: "date"}}}},
{"unknown var", Composition{Version: Version, Segments: []Segment{{Var: "nope"}}}},
{"empty var", Composition{Version: Version, Segments: []Segment{{Var: " "}}}},
}
for _, c := range bad {
t.Run(c.name, func(t *testing.T) {
if err := c.comp.Validate(cat); err == nil {
t.Errorf("expected validation error, got nil")
}
})
}
}