Add NPC normalization prompt context
This commit is contained in:
6
internal/modules/dnd/normalize/npcs/assets.go
Normal file
6
internal/modules/dnd/normalize/npcs/assets.go
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
The supplied NPC candidates are below. Use only these display names in the
|
||||
response.
|
||||
|
||||
{{ input "candidates" }}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
Identify only supplied NPC display names that clearly refer to the same
|
||||
individual in the supplied transcript context.
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
210
internal/modules/dnd/normalize/npcs/context_material.go
Normal file
210
internal/modules/dnd/normalize/npcs/context_material.go
Normal 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
|
||||
}
|
||||
132
internal/modules/dnd/normalize/npcs/context_material_test.go
Normal file
132
internal/modules/dnd/normalize/npcs/context_material_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
21
internal/modules/dnd/normalize/npcs/schema.go
Normal file
21
internal/modules/dnd/normalize/npcs/schema.go
Normal 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",
|
||||
})
|
||||
}
|
||||
65
internal/modules/dnd/normalize/npcs/schema_test.go
Normal file
65
internal/modules/dnd/normalize/npcs/schema_test.go
Normal 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)
|
||||
}
|
||||
51
internal/modules/dnd/normalize/npcs/scriptorium_assets.go
Normal file
51
internal/modules/dnd/normalize/npcs/scriptorium_assets.go
Normal 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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }},
|
||||
|
||||
Reference in New Issue
Block a user