Add NPC normalization prompt context

This commit is contained in:
2026-07-26 01:27:34 +00:00
parent 8a12c56971
commit 6bd781d344
13 changed files with 619 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
package npcs
import "embed"
//go:embed assets/schemas/dnd_npcs_normalize_llm.v1.json assets/prompts/*.yaml assets/prompts/*.md
var embeddedAssets embed.FS

View File

@@ -0,0 +1,4 @@
The supplied NPC candidates are below. Use only these display names in the
response.
{{ input "candidates" }}

View File

@@ -0,0 +1,32 @@
id: dnd.npcs.normalize
version: "v1"
default_profile: gemini-2-flash
inputs:
- name: candidates
required: true
content_type: application/json
- name: transcript
required: true
content_type: application/json
messages:
- role: system
content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
cache_control:
type: ephemeral
- role: user
content_file: ./task.md
- role: user
content_file: ./instructions.md
cache_control:
type: ephemeral
- role: user
content_file: ./candidates.md
- role: user
content_file: ./sharedassets/common-dnd-transcript.md
output:
format: json
validation_mode: json_schema
schema_path: dnd_npcs_normalize_llm.v1.json
repair_attempts: 0

View File

@@ -0,0 +1,10 @@
Return duplicate groups only when the transcript context clearly establishes a
single individual. Prefer no group when identity is ambiguous.
Copy supplied display names into each group's members. Choose canonical_name
from that same group's supplied members. Prefer a complete stable proper name
over an abbreviation, but prefer an unadorned proper name over that name plus a
contextual class, role, title, or relationship descriptor unless the descriptor
is established as part of the name.
Do not invent names, source references, replacement records, or explanations.

View File

@@ -0,0 +1,2 @@
Identify only supplied NPC display names that clearly refer to the same
individual in the supplied transcript context.

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.npcs.normalize.llm",
"type": "object",
"additionalProperties": false,
"required": ["duplicate_groups"],
"properties": {
"duplicate_groups": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["members", "canonical_name"],
"properties": {
"members": {
"type": "array",
"items": {"type": "string"}
},
"canonical_name": {"type": "string"}
}
}
}
}
}

View File

@@ -0,0 +1,210 @@
package npcs
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
const semanticContextRadius = 2
type normalizeContextMaterials struct {
Candidates contracts.LLMInputMaterial
Transcript contracts.LLMInputMaterial
}
type normalizeCandidateInput struct {
NPCs []normalizeCandidate `json:"npcs"`
}
type normalizeCandidate struct {
Name string `json:"name"`
SourceRefs []normalizeCandidateSourceRef `json:"source_refs"`
}
type normalizeCandidateSourceRef struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}
type normalizeTranscriptInput struct {
Windows []normalizeTranscriptWindow `json:"windows"`
}
type normalizeTranscriptWindow struct {
Units []normalizeTranscriptUnit `json:"units"`
}
type normalizeTranscriptUnit struct {
ID int `json:"id"`
Kind string `json:"kind"`
Text string `json:"text"`
Metadata map[string]any `json:"metadata,omitempty"`
Cited bool `json:"cited"`
}
type normalizeProposalResponse struct {
DuplicateGroups []normalizeProposalGroup `json:"duplicate_groups"`
}
type normalizeProposalGroup struct {
Members []string `json:"members"`
CanonicalName string `json:"canonical_name"`
}
type sourceInterval struct {
start int
end int
}
func buildDefaultNormalizeContextMaterials(doc *source.SourceDocument, records []dnd.NPC) (normalizeContextMaterials, bool, error) {
return buildNormalizeContextMaterials(doc, records, semanticContextRadius)
}
// buildNormalizeContextMaterials prepares the owned prompt inputs for a
// document-level normalization proposal. A false ready value means semantic
// normalization has no comparison-distinct eligible candidates to consider.
func buildNormalizeContextMaterials(doc *source.SourceDocument, records []dnd.NPC, radius int) (materials normalizeContextMaterials, ready bool, err error) {
if doc == nil {
return normalizeContextMaterials{}, false, nil
}
if radius < 0 {
return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: radius must not be negative")
}
index := source.NewDocumentIndex(doc)
candidates := make([]normalizeCandidate, 0, len(records))
intervals := make([]sourceInterval, 0)
cited := make([]bool, len(doc.Units))
seenKeys := make(map[string]struct{}, len(records))
for _, record := range records {
key := identity.ComparisonKey(record.Name)
if key == "" || len(record.SourceRefs) == 0 {
continue
}
if _, exists := seenKeys[key]; exists {
continue
}
references, recordIntervals, valid := normalizeRecordReferences(index, record.SourceRefs)
if !valid {
continue
}
seenKeys[key] = struct{}{}
candidates = append(candidates, normalizeCandidate{Name: record.Name, SourceRefs: references})
for _, interval := range recordIntervals {
for position := interval.start; position <= interval.end; position++ {
cited[position] = true
}
intervals = append(intervals, sourceInterval{
start: maxInt(0, interval.start-radius),
end: minInt(len(doc.Units)-1, interval.end+radius),
})
}
}
if len(candidates) < 2 {
return normalizeContextMaterials{}, false, nil
}
windows, err := normalizeContextWindows(doc.Units, coalesceIntervals(intervals), cited)
if err != nil {
return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: copy source metadata: %w", err)
}
candidateContent, err := json.Marshal(normalizeCandidateInput{NPCs: candidates})
if err != nil {
return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: encode candidates: %w", err)
}
transcriptContent, err := json.Marshal(normalizeTranscriptInput{Windows: windows})
if err != nil {
return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: encode transcript: %w", err)
}
return normalizeContextMaterials{
Candidates: newNormalizeInputMaterial("candidates", candidateContent),
Transcript: newNormalizeInputMaterial("transcript", transcriptContent),
}, true, nil
}
func normalizeRecordReferences(index source.DocumentIndex, refs []source.SourceRef) ([]normalizeCandidateSourceRef, []sourceInterval, bool) {
references := make([]normalizeCandidateSourceRef, 0, len(refs))
intervals := make([]sourceInterval, 0, len(refs))
for _, ref := range refs {
if err := index.ValidateRef(ref); err != nil {
return nil, nil, false
}
start, _ := index.Position(ref.StartUnitID)
end, _ := index.Position(ref.EndUnitID)
references = append(references, normalizeCandidateSourceRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID})
intervals = append(intervals, sourceInterval{start: start, end: end})
}
return references, intervals, true
}
func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
if len(intervals) == 0 {
return nil
}
ordered := append([]sourceInterval(nil), intervals...)
sort.Slice(ordered, func(i, j int) bool {
if ordered[i].start != ordered[j].start {
return ordered[i].start < ordered[j].start
}
return ordered[i].end < ordered[j].end
})
coalesced := make([]sourceInterval, 0, len(ordered))
for _, interval := range ordered {
if len(coalesced) == 0 || interval.start > coalesced[len(coalesced)-1].end+1 {
coalesced = append(coalesced, interval)
continue
}
if interval.end > coalesced[len(coalesced)-1].end {
coalesced[len(coalesced)-1].end = interval.end
}
}
return coalesced
}
func normalizeContextWindows(units []source.SourceUnit, intervals []sourceInterval, cited []bool) ([]normalizeTranscriptWindow, error) {
windows := make([]normalizeTranscriptWindow, 0, len(intervals))
for _, interval := range intervals {
window := normalizeTranscriptWindow{Units: make([]normalizeTranscriptUnit, 0, interval.end-interval.start+1)}
for position := interval.start; position <= interval.end; position++ {
unit := units[position]
metadata, err := source.CloneMetadata(unit.Metadata)
if err != nil {
return nil, err
}
window.Units = append(window.Units, normalizeTranscriptUnit{
ID: unit.ID, Kind: unit.Kind, Text: unit.Text, Metadata: metadata, Cited: cited[position],
})
}
windows = append(windows, window)
}
return windows, nil
}
func newNormalizeInputMaterial(name string, content []byte) contracts.LLMInputMaterial {
digest := sha256.Sum256(content)
return contracts.NewLLMInputMaterial(name, "application/json", content, "sha256:"+hex.EncodeToString(digest[:]), "")
}
func minInt(left, right int) int {
if left < right {
return left
}
return right
}
func maxInt(left, right int) int {
if left > right {
return left
}
return right
}

View File

@@ -0,0 +1,132 @@
package npcs
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestBuildNormalizeContextMaterialsUsesDocumentOrderAndOwnedInputs(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 40, Kind: "narration", Text: "zero"},
{ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}},
{ID: 70, Kind: "speech", Text: "two"},
{ID: 20, Kind: "narration", Text: "three"},
{ID: 90, Kind: "speech", Text: "four"},
{ID: 30, Kind: "narration", Text: "five"},
}}
records := []dnd.NPC{
{Name: "Mira Thorn", ID: "npc:sha256:internal", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 20}}},
{Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 90, EndUnitID: 90}}},
{Name: "Broken", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 10}}},
}
before := append([]dnd.NPC(nil), records...)
materials, ready, err := buildNormalizeContextMaterials(doc, records, 1)
if err != nil || !ready {
t.Fatalf("buildNormalizeContextMaterials() = %#v, %t, %v; want ready materials", materials, ready, err)
}
if !reflect.DeepEqual(records, before) {
t.Fatalf("records mutated to %#v", records)
}
for _, material := range []struct {
name string
data []byte
}{
{name: "candidates", data: materials.Candidates.Content},
{name: "transcript", data: materials.Transcript.Content},
} {
if !json.Valid(material.data) || string(material.data) == "" {
t.Fatalf("%s content = %q, want JSON", material.name, material.data)
}
digest := sha256.Sum256(material.data)
wantDigest := "sha256:" + hex.EncodeToString(digest[:])
got := materials.Candidates
if material.name == "transcript" {
got = materials.Transcript
}
if got.Name != material.name || got.MediaType != "application/json" || got.OriginURI != "" || got.Digest != wantDigest {
t.Fatalf("%s material = %#v, want owned JSON material", material.name, got)
}
}
encoded := string(materials.Candidates.Content) + string(materials.Transcript.Content)
if strings.Contains(encoded, "npc:sha256:internal") || strings.Contains(encoded, doc.ID) {
t.Fatalf("model material leaked private identifier or source id: %s", encoded)
}
var candidates normalizeCandidateInput
if err := json.Unmarshal(materials.Candidates.Content, &candidates); err != nil {
t.Fatal(err)
}
if len(candidates.NPCs) != 2 || candidates.NPCs[0].Name != "Mira Thorn" || candidates.NPCs[0].SourceRefs[0] != (normalizeCandidateSourceRef{StartUnitID: 10, EndUnitID: 20}) {
t.Fatalf("candidates = %#v, want two valid current-order candidates", candidates)
}
var transcript normalizeTranscriptInput
if err := json.Unmarshal(materials.Transcript.Content, &transcript); err != nil {
t.Fatal(err)
}
if len(transcript.Windows) != 1 || len(transcript.Windows[0].Units) != 6 {
t.Fatalf("transcript = %#v, want one coalesced window", transcript)
}
units := transcript.Windows[0].Units
for index, wantID := range []int{40, 10, 70, 20, 90, 30} {
if units[index].ID != wantID {
t.Fatalf("unit %d id = %d, want source-order id %d", index, units[index].ID, wantID)
}
}
if units[0].Cited || !units[1].Cited || !units[2].Cited || !units[3].Cited || !units[4].Cited || units[5].Cited {
t.Fatalf("citation markers = %#v, want original ranges only", units)
}
if units[1].Metadata["speaker"].(map[string]any)["name"] != "Mira" {
t.Fatalf("metadata = %#v, want copied generic metadata", units[1].Metadata)
}
windows, err := normalizeContextWindows(doc.Units, []sourceInterval{{start: 1, end: 1}}, make([]bool, len(doc.Units)))
if err != nil {
t.Fatal(err)
}
windows[0].Units[0].Metadata["speaker"].(map[string]any)["name"] = "changed"
if doc.Units[1].Metadata["speaker"].(map[string]any)["name"] != "Mira" {
t.Fatal("copied metadata aliases source document")
}
}
func TestBuildNormalizeContextMaterialsExcludesInvalidReferencesAndCoalescesAdjacentWindows(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}, {ID: 2}, {ID: 6},
}}
records := []dnd.NPC{
{Name: "One", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
{Name: "Two", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 8, EndUnitID: 8}}},
{Name: "Blank"},
{Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}},
{Name: "Foreign", SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}},
{Name: "Reversed", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 6, EndUnitID: 3}}},
}
materials, ready, err := buildNormalizeContextMaterials(doc, records, 0)
if err != nil || !ready {
t.Fatalf("buildNormalizeContextMaterials() error = %v, ready = %t", err, ready)
}
var candidates normalizeCandidateInput
if err := json.Unmarshal(materials.Candidates.Content, &candidates); err != nil {
t.Fatal(err)
}
if got := []string{candidates.NPCs[0].Name, candidates.NPCs[1].Name}; !reflect.DeepEqual(got, []string{"One", "Two"}) {
t.Fatalf("candidate names = %#v, want only valid records", got)
}
var transcript normalizeTranscriptInput
if err := json.Unmarshal(materials.Transcript.Content, &transcript); err != nil {
t.Fatal(err)
}
if len(transcript.Windows) != 1 || len(transcript.Windows[0].Units) != 2 {
t.Fatalf("windows = %#v, want adjacent cited units coalesced", transcript.Windows)
}
if transcript.Windows[0].Units[0].ID != 3 || transcript.Windows[0].Units[1].ID != 8 {
t.Fatalf("window units = %#v, want document-order adjacent units", transcript.Windows[0].Units)
}
}

View File

@@ -0,0 +1,21 @@
package npcs
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
const (
PromptID = "dnd.npcs.normalize"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npcs_normalize_llm")
ResponseSchemaID = "notarius.dnd.npcs.normalize.llm"
ResponseSchemaName = "notarius_dnd_npcs_normalize_llm_v1"
SchemaVersion = "v1"
)
func loadResponseSchema() (llm.ResponseSchema, error) {
return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{
Key: ResponseSchemaKey,
ID: ResponseSchemaID,
Version: SchemaVersion,
Name: ResponseSchemaName,
AssetPath: "assets/schemas/dnd_npcs_normalize_llm.v1.json",
})
}

View File

@@ -0,0 +1,65 @@
package npcs
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestNormalizeResponseSchemaIsStrictlyStructural(t *testing.T) {
schema, err := loadResponseSchema()
if err != nil {
t.Fatalf("loadResponseSchema() error = %v", err)
}
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
t.Fatalf("schema = %#v, want private normalization schema identity", schema)
}
for _, test := range []struct {
name string
value any
valid bool
}{
{name: "empty groups", value: map[string]any{"duplicate_groups": []any{}}, valid: true},
{name: "semantically invalid group", value: map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{"", "unknown"}, "canonical_name": ""}}}, valid: true},
{name: "missing groups", value: map[string]any{}},
{name: "unknown top level field", value: map[string]any{"duplicate_groups": []any{}, "extra": true}},
{name: "unknown group field", value: map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical_name": "Mira", "extra": true}}}},
{name: "wrong groups type", value: map[string]any{"duplicate_groups": "no"}},
{name: "wrong member type", value: map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{1}, "canonical_name": "Mira"}}}},
} {
t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.value)
if err != nil {
t.Fatal(err)
}
err = validateNormalizeSchema(content, schema.JSONSchema)
if (err == nil) != test.valid {
t.Fatalf("validateNormalizeSchema() error = %v, want valid=%t", err, test.valid)
}
})
}
}
func validateNormalizeSchema(instanceContent, schemaContent []byte) error {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(instanceContent))
if err != nil {
return err
}
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaContent))
if err != nil {
return err
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", document); err != nil {
return err
}
compiled, err := compiler.Compile("schema.json")
if err != nil {
return err
}
return compiled.Validate(instance)
}

View File

@@ -0,0 +1,51 @@
package npcs
import (
"fmt"
"sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const scriptoriumPromptRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{
ModuleDir: PromptID,
ModuleFiles: []promptfs.ModulePromptFile{
{Name: "dnd.npcs.normalize.yaml", Path: "assets/prompts/dnd.npcs.normalize.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
{Name: "candidates.md", Path: "assets/prompts/candidates.md"},
},
SharedFiles: []string{
"common-dnd-system.md",
"common-dnd-identity.md",
"common-dnd-transcript.md",
},
}
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
if err != nil {
return fmt.Errorf("prepare NPC normalization prompt assets: %w", err)
}
if err := registry.RegisterPromptFS(promptFS, scriptoriumPromptRoot); err != nil {
return err
}
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
}
func scriptoriumPromptMetadata() (string, error) {
scriptoriumPromptHashOnce.Do(func() {
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
})
return scriptoriumPromptHash, scriptoriumPromptHashErr
}
var (
scriptoriumPromptHashOnce sync.Once
scriptoriumPromptHash string
scriptoriumPromptHashErr error
)

View File

@@ -0,0 +1,61 @@
package npcs
import (
"context"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/scriptorium"
)
func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("RegisterPromptAssets() error = %v", err)
}
options, err := registry.ScriptoriumOptions()
if err != nil {
t.Fatalf("ScriptoriumOptions() error = %v", err)
}
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
ID: "normalize-test-profile", Endpoint: "http://127.0.0.1:1/v1", Model: "normalize-test-model",
})))
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("NewEngine() error = %v", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "normalize-test-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"candidates": scriptorium.Inline(`{"npcs":[{"name":"Mira","source_refs":[]}]}`),
"transcript": scriptorium.Inline(`{"windows":[{"units":[]}]}`),
},
})
if err != nil {
t.Fatalf("Prepare() error = %v", err)
}
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_normalize_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want normalization prompt identity and schema", prepared)
}
if len(prepared.Messages) != 6 {
t.Fatalf("prepared messages = %d, want 6", len(prepared.Messages))
}
for _, index := range []int{1, 3} {
if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != scriptorium.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, cache)
}
}
for _, index := range []int{0, 2, 4, 5} {
if cache := prepared.Messages[index].CacheControl; cache != nil {
t.Errorf("message %d cache control = %#v, want nil", index, cache)
}
}
if !strings.Contains(prepared.Messages[4].Content, `"Mira"`) || strings.Contains(prepared.Messages[4].Content, `"windows"`) {
t.Fatalf("candidate message = %q, want only rendered candidates", prepared.Messages[4].Content)
}
if !strings.Contains(prepared.Messages[5].Content, `"windows"`) || strings.Contains(prepared.Messages[5].Content, `"Mira"`) {
t.Fatalf("transcript message = %q, want only rendered transcript", prepared.Messages[5].Content)
}
}

View File

@@ -95,6 +95,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error {
{name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
{name: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }},
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
{name: "npc normalization prompt assets", register: func() error { return npcnormalize.RegisterPromptAssets(assets) }},
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
{name: "item events prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }},
{name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }},