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: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
|
||||||
{name: "spells prompt assets", register: func() error { return spellextract.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: "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: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
|
||||||
{name: "item events prompt assets", register: func() error { return itemeventextract.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) }},
|
{name: "npc interactions prompt assets", register: func() error { return interactionextract.RegisterPromptAssets(assets) }},
|
||||||
|
|||||||
Reference in New Issue
Block a user