diff --git a/internal/modules/dnd/normalize/npcs/assets.go b/internal/modules/dnd/normalize/npcs/assets.go new file mode 100644 index 0000000..2990279 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/assets.go @@ -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 diff --git a/internal/modules/dnd/normalize/npcs/assets/prompts/candidates.md b/internal/modules/dnd/normalize/npcs/assets/prompts/candidates.md new file mode 100644 index 0000000..7ba9fa8 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/assets/prompts/candidates.md @@ -0,0 +1,4 @@ +The supplied NPC candidates are below. Use only these display names in the +response. + +{{ input "candidates" }} diff --git a/internal/modules/dnd/normalize/npcs/assets/prompts/dnd.npcs.normalize.yaml b/internal/modules/dnd/normalize/npcs/assets/prompts/dnd.npcs.normalize.yaml new file mode 100644 index 0000000..88610f0 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/assets/prompts/dnd.npcs.normalize.yaml @@ -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 diff --git a/internal/modules/dnd/normalize/npcs/assets/prompts/instructions.md b/internal/modules/dnd/normalize/npcs/assets/prompts/instructions.md new file mode 100644 index 0000000..d6a4896 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/assets/prompts/instructions.md @@ -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. diff --git a/internal/modules/dnd/normalize/npcs/assets/prompts/task.md b/internal/modules/dnd/normalize/npcs/assets/prompts/task.md new file mode 100644 index 0000000..b8e6576 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/assets/prompts/task.md @@ -0,0 +1,2 @@ +Identify only supplied NPC display names that clearly refer to the same +individual in the supplied transcript context. diff --git a/internal/modules/dnd/normalize/npcs/assets/schemas/dnd_npcs_normalize_llm.v1.json b/internal/modules/dnd/normalize/npcs/assets/schemas/dnd_npcs_normalize_llm.v1.json new file mode 100644 index 0000000..40de207 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/assets/schemas/dnd_npcs_normalize_llm.v1.json @@ -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"} + } + } + } + } +} diff --git a/internal/modules/dnd/normalize/npcs/context_material.go b/internal/modules/dnd/normalize/npcs/context_material.go new file mode 100644 index 0000000..6320d31 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/context_material.go @@ -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 +} diff --git a/internal/modules/dnd/normalize/npcs/context_material_test.go b/internal/modules/dnd/normalize/npcs/context_material_test.go new file mode 100644 index 0000000..d7afa1f --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/context_material_test.go @@ -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) + } +} diff --git a/internal/modules/dnd/normalize/npcs/schema.go b/internal/modules/dnd/normalize/npcs/schema.go new file mode 100644 index 0000000..f196a7c --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/schema.go @@ -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", + }) +} diff --git a/internal/modules/dnd/normalize/npcs/schema_test.go b/internal/modules/dnd/normalize/npcs/schema_test.go new file mode 100644 index 0000000..3e8e03c --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/schema_test.go @@ -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) +} diff --git a/internal/modules/dnd/normalize/npcs/scriptorium_assets.go b/internal/modules/dnd/normalize/npcs/scriptorium_assets.go new file mode 100644 index 0000000..e840aab --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/scriptorium_assets.go @@ -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 +) diff --git a/internal/modules/dnd/normalize/npcs/scriptorium_assets_test.go b/internal/modules/dnd/normalize/npcs/scriptorium_assets_test.go new file mode 100644 index 0000000..d72a7c7 --- /dev/null +++ b/internal/modules/dnd/normalize/npcs/scriptorium_assets_test.go @@ -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) + } +} diff --git a/internal/modules/dnd/register/modules.go b/internal/modules/dnd/register/modules.go index 3562c23..960a802 100644 --- a/internal/modules/dnd/register/modules.go +++ b/internal/modules/dnd/register/modules.go @@ -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) }},