Organize D&D extensions by domain
This commit is contained in:
6
internal/modules/dnd/extract/spells/assets.go
Normal file
6
internal/modules/dnd/extract/spells/assets.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package spells
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed assets/schemas/*.json assets/prompts/*.yaml assets/prompts/*.md
|
||||
var embeddedAssets embed.FS
|
||||
@@ -0,0 +1,36 @@
|
||||
id: dnd.spells
|
||||
version: "v1"
|
||||
default_profile: gemini-2-flash
|
||||
inputs:
|
||||
- name: transcript
|
||||
required: true
|
||||
content_type: application/json
|
||||
- name: players
|
||||
required: false
|
||||
content_type: text/plain
|
||||
- name: party
|
||||
required: false
|
||||
content_type: text/plain
|
||||
- name: glossary
|
||||
required: false
|
||||
content_type: text/plain
|
||||
messages:
|
||||
- role: system
|
||||
content_file: ./sharedassets/common-dnd-system.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-transcript.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-references.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./task.md
|
||||
- role: user
|
||||
content_file: ./instructions.md
|
||||
output:
|
||||
format: json
|
||||
validation_mode: json_schema
|
||||
schema_path: dnd_spells_llm.v1.json
|
||||
repair_attempts: 0
|
||||
@@ -0,0 +1,12 @@
|
||||
Source references must use integer source-unit IDs from the transcript. Provide
|
||||
start_unit_id and end_unit_id for each source reference; the extractor assigns
|
||||
source_id automatically.
|
||||
|
||||
Return only D&D spell-cast artifacts. For each spell cast, identify the in-world
|
||||
caster, spell name, effect, narrative description, and source references.
|
||||
|
||||
Use player, party, and glossary reference material only to clarify source text.
|
||||
Do not return spells, casters, or effects that are mentioned only in reference
|
||||
material.
|
||||
|
||||
Return exactly one JSON object and no explanatory text.
|
||||
@@ -0,0 +1,5 @@
|
||||
Extract Dungeons & Dragons spell-cast artifacts from the provided transcript.
|
||||
|
||||
Extract only spell casts that are supported by the transcript. Do not infer
|
||||
spells from general D&D knowledge or from table chatter that does not identify a
|
||||
spell being cast.
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.spells",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["spell_casts"],
|
||||
"properties": {
|
||||
"spell_casts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"caster",
|
||||
"spell",
|
||||
"effect",
|
||||
"narrative_description",
|
||||
"source_refs"
|
||||
],
|
||||
"properties": {
|
||||
"caster": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"spell": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"narrative_description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["source_id", "start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"source_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"start_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"end_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.spells.llm",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["spell_casts"],
|
||||
"properties": {
|
||||
"spell_casts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"caster",
|
||||
"spell",
|
||||
"effect",
|
||||
"narrative_description",
|
||||
"source_refs"
|
||||
],
|
||||
"properties": {
|
||||
"caster": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"spell": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"narrative_description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"source_refs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["start_unit_id", "end_unit_id"],
|
||||
"properties": {
|
||||
"start_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"end_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
internal/modules/dnd/extract/spells/canonicalize.go
Normal file
92
internal/modules/dnd/extract/spells/canonicalize.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func canonicalizeResponse(response *extractionResponse, sourceID string) {
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
for index := range response.SpellCasts {
|
||||
canonicalizeSpellCast(&response.SpellCasts[index], sourceID)
|
||||
}
|
||||
sort.SliceStable(response.SpellCasts, func(i, j int) bool {
|
||||
left, leftOK := earliestSourceUnit(response.SpellCasts[i])
|
||||
right, rightOK := earliestSourceUnit(response.SpellCasts[j])
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
if !leftOK {
|
||||
return false
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
}
|
||||
|
||||
func canonicalizeSpellCast(spell *spellCastResponse, sourceID string) {
|
||||
for index := range spell.SourceRefs {
|
||||
spell.SourceRefs[index].SourceID = sourceID
|
||||
spell.SourceRefs[index].StartUnitID = canonicalUnitRef(spell.SourceRefs[index].StartUnitID)
|
||||
spell.SourceRefs[index].EndUnitID = canonicalUnitRef(spell.SourceRefs[index].EndUnitID)
|
||||
}
|
||||
sort.SliceStable(spell.SourceRefs, func(i, j int) bool {
|
||||
left := spell.SourceRefs[i]
|
||||
right := spell.SourceRefs[j]
|
||||
if left.StartUnitID.Int() != right.StartUnitID.Int() {
|
||||
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
||||
}
|
||||
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
||||
})
|
||||
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
|
||||
}
|
||||
|
||||
func canonicalUnitRef(ref shared.UnitRef) shared.UnitRef {
|
||||
value := ref.Int()
|
||||
if value <= 0 {
|
||||
return ref
|
||||
}
|
||||
return shared.UnitRefFromInt(value)
|
||||
}
|
||||
|
||||
func dedupeSourceRefs(refs []shared.SourceRefResponse) []shared.SourceRefResponse {
|
||||
if len(refs) < 2 {
|
||||
return refs
|
||||
}
|
||||
out := refs[:0]
|
||||
var previous shared.SourceRefResponse
|
||||
for index, ref := range refs {
|
||||
if index > 0 && sameSourceRef(previous, ref) {
|
||||
continue
|
||||
}
|
||||
out = append(out, ref)
|
||||
previous = ref
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameSourceRef(left shared.SourceRefResponse, right shared.SourceRefResponse) bool {
|
||||
return left.SourceID == right.SourceID &&
|
||||
left.StartUnitID.Int() == right.StartUnitID.Int() &&
|
||||
left.EndUnitID.Int() == right.EndUnitID.Int()
|
||||
}
|
||||
|
||||
func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
||||
for _, ref := range spell.SourceRefs {
|
||||
start := ref.StartUnitID.Int()
|
||||
if start > 0 {
|
||||
return start, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func unitSortValue(ref shared.UnitRef) int {
|
||||
value := ref.Int()
|
||||
if value <= 0 {
|
||||
return int(^uint(0) >> 1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
174
internal/modules/dnd/extract/spells/extractor.go
Normal file
174
internal/modules/dnd/extract/spells/extractor.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
const Key = "dnd/spells"
|
||||
const ArtifactType = "dnd.spell_cast"
|
||||
const SchemaVersion = "v1"
|
||||
|
||||
var requiredCapabilities = []string{
|
||||
"chunks",
|
||||
"source.transcript",
|
||||
}
|
||||
|
||||
var providedCapabilities = []string{
|
||||
"dnd.spell_casts",
|
||||
}
|
||||
|
||||
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
||||
Glossary: "Optional campaign glossary reference material used only for disambiguation.",
|
||||
Party: "Optional party roster reference material used only for disambiguation.",
|
||||
Players: "Optional player list reference material used only for disambiguation.",
|
||||
Roster: "Deprecated alias for party roster reference material used only for disambiguation.",
|
||||
}
|
||||
|
||||
var _ contracts.Extractor = (*Extractor)(nil)
|
||||
|
||||
type Extractor struct{}
|
||||
|
||||
func New() *Extractor {
|
||||
return &Extractor{}
|
||||
}
|
||||
|
||||
func (e *Extractor) Key() string {
|
||||
return Key
|
||||
}
|
||||
|
||||
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
}
|
||||
|
||||
func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
promptSHA, err := scriptoriumPromptMetadata()
|
||||
if err != nil {
|
||||
promptSHA = ""
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": SchemaVersion,
|
||||
"prompt_sha256": promptSHA,
|
||||
"response_schema_key": string(ResponseSchemaKey),
|
||||
"response_schema_id": ResponseSchemaID,
|
||||
"response_schema_name": ResponseSchemaName,
|
||||
}
|
||||
if schema, err := loadResponseSchema(); err == nil {
|
||||
metadata["response_schema_version"] = schema.Version
|
||||
metadata["response_schema_sha256"] = schema.SHA256
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) {
|
||||
if e == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("extractor must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("context must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("context error before extraction: %w", err)
|
||||
}
|
||||
if req.Source == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("source must not be nil")
|
||||
}
|
||||
if req.Chunk == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("chunk must not be nil")
|
||||
}
|
||||
if len(req.Chunk.Units) == 0 {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
||||
}
|
||||
if req.LLMClient == nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
sourceInput, err := chunkSourceInput(req)
|
||||
if err != nil {
|
||||
return contracts.ExtractionResult{}, err
|
||||
}
|
||||
|
||||
var response extractionResponse
|
||||
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key,
|
||||
PromptID: PromptID,
|
||||
PromptVersion: SchemaVersion,
|
||||
ProfileID: req.LLMProfile,
|
||||
SessionID: req.SessionID,
|
||||
Inputs: shared.PromptInputs(sourceInput, req.References),
|
||||
}, &response); err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err)
|
||||
}
|
||||
canonicalizeResponse(&response, req.Source.ID)
|
||||
content, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("marshal canonical output: %w", err)
|
||||
}
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("load response schema: %w", err)
|
||||
}
|
||||
return contracts.ExtractionResult{
|
||||
Output: contracts.ExtractOutput{
|
||||
Schema: contracts.ResponseSchema{
|
||||
ID: ResponseSchemaID,
|
||||
Name: ResponseSchemaName,
|
||||
Version: SchemaVersion,
|
||||
JSONSchema: append([]byte(nil), schema.JSONSchema...),
|
||||
},
|
||||
Payload: contracts.RawPayload{
|
||||
Content: content,
|
||||
MediaType: "application/json",
|
||||
Metadata: map[string]any{
|
||||
"spell_cast_count": len(response.SpellCasts),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func chunkSourceInput(req contracts.ExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||
material := req.SourceInput.Clone()
|
||||
if len(material.Content) == 0 {
|
||||
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||
}
|
||||
if !bytes.Equal(material.Content, req.Chunk.Content) {
|
||||
return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID)
|
||||
}
|
||||
if material.Name == "" {
|
||||
material.Name = "source"
|
||||
}
|
||||
if material.MediaType == "" {
|
||||
material.MediaType = req.Chunk.MediaType
|
||||
}
|
||||
if material.SizeBytes == 0 {
|
||||
material.SizeBytes = int64(len(material.Content))
|
||||
}
|
||||
return material, nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions),
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ExtractorRegistry) error {
|
||||
return registry.RegisterWithSpec(ModuleSpec(), func() (contracts.Extractor, error) {
|
||||
return New(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func extractorErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd spells extractor: "+format, args...)
|
||||
}
|
||||
477
internal/modules/dnd/extract/spells/extractor_test.go
Normal file
477
internal/modules/dnd/extract/spells/extractor_test.go
Normal file
@@ -0,0 +1,477 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func TestExtractReturnsCanonicalOutputFromStructuredResponse(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{
|
||||
response: extractionResponse{
|
||||
SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: " Aria ",
|
||||
Spell: " Cure Wounds ",
|
||||
Effect: " Heals an injured ally. ",
|
||||
NarrativeDescription: " Aria restores the fighter after the fight. ",
|
||||
SourceRefs: responseSourceRefsInt("transcript", 1, 2),
|
||||
},
|
||||
},
|
||||
},
|
||||
content: []byte(`{"spell_casts":[{"caster":" Aria ","spell":" Cure Wounds ","effect":" Heals an injured ally. ","narrative_description":" Aria restores the fighter after the fight. ","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":2}]}],"raw_marker":true}`),
|
||||
}
|
||||
|
||||
extractReq := extractionRequestWithClient(client)
|
||||
result, err := New().Extract(context.Background(), extractReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
||||
}
|
||||
llmReq := client.requests[0]
|
||||
if llmReq.StageName != Key {
|
||||
t.Fatalf("StageName = %q, want %q", llmReq.StageName, Key)
|
||||
}
|
||||
if llmReq.PromptID != PromptID || llmReq.PromptVersion != SchemaVersion {
|
||||
t.Fatalf("prompt = %q/%q, want %q/%q", llmReq.PromptID, llmReq.PromptVersion, PromptID, SchemaVersion)
|
||||
}
|
||||
if llmReq.SessionID != "session-123" || llmReq.ProfileID != "profile-spells" {
|
||||
t.Fatalf("session/profile = %q/%q, want session-123/profile-spells", llmReq.SessionID, llmReq.ProfileID)
|
||||
}
|
||||
transcript := llmReq.Inputs["transcript"]
|
||||
if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" {
|
||||
t.Fatalf("transcript metadata = %#v", transcript)
|
||||
}
|
||||
if got := string(transcript.Content); got != string(extractReq.Chunk.Content) {
|
||||
t.Fatalf("transcript content = %q, want chunk content %q", got, extractReq.Chunk.Content)
|
||||
}
|
||||
|
||||
if result.Output.Payload.MediaType != "application/json" {
|
||||
t.Fatalf("MediaType = %q, want application/json", result.Output.Payload.MediaType)
|
||||
}
|
||||
if result.Output.Schema.ID != ResponseSchemaID || result.Output.Schema.Name != ResponseSchemaName || result.Output.Schema.Version != SchemaVersion {
|
||||
t.Fatalf("schema = %#v, want response schema provenance", result.Output.Schema)
|
||||
}
|
||||
if !json.Valid(result.Output.Schema.JSONSchema) {
|
||||
t.Fatalf("schema JSON is invalid or missing: %s", result.Output.Schema.JSONSchema)
|
||||
}
|
||||
if strings.Contains(string(result.Output.Payload.Content), "raw_marker") {
|
||||
t.Fatalf("content = %q, want canonical payload without raw completion marker", result.Output.Payload.Content)
|
||||
}
|
||||
|
||||
var payload extractionResponse
|
||||
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||
}
|
||||
if len(payload.SpellCasts) != 1 || payload.SpellCasts[0].Spell != " Cure Wounds " {
|
||||
t.Fatalf("payload = %#v, want structured response fields", payload)
|
||||
}
|
||||
if got := payload.SpellCasts[0].SourceRefs[0].SourceID; got != "session-alpha" {
|
||||
t.Fatalf("source_id = %q, want canonical source document ID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorManifestMetadataIncludesPromptAndSchemaProvenance(t *testing.T) {
|
||||
metadata := New().ManifestMetadata()
|
||||
|
||||
tests := map[string]string{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": SchemaVersion,
|
||||
"response_schema_key": string(ResponseSchemaKey),
|
||||
"response_schema_id": ResponseSchemaID,
|
||||
"response_schema_name": ResponseSchemaName,
|
||||
"response_schema_version": SchemaVersion,
|
||||
}
|
||||
for key, want := range tests {
|
||||
if metadata[key] != want {
|
||||
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"prompt_sha256", "response_schema_sha256"} {
|
||||
value, ok := metadata[key].(string)
|
||||
if !ok || !strings.HasPrefix(value, "sha256:") {
|
||||
t.Fatalf("metadata[%q] = %#v, want sha256 value", key, metadata[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
req := extractionRequestWithClient(client)
|
||||
req.References = contracts.ReferenceSet{
|
||||
Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"players": {
|
||||
Slot: contracts.ReferenceSlot{Name: "players"},
|
||||
Items: []contracts.ReferenceItem{
|
||||
{SlotName: "players", Content: []byte("Alice: Aria Brightmantle")},
|
||||
},
|
||||
},
|
||||
"party": {
|
||||
Slot: contracts.ReferenceSlot{Name: "party"},
|
||||
Items: []contracts.ReferenceItem{
|
||||
{SlotName: "party", Content: []byte("Aria Brightmantle: party cleric")},
|
||||
},
|
||||
},
|
||||
"glossary": {
|
||||
Slot: contracts.ReferenceSlot{Name: "glossary"},
|
||||
Items: []contracts.ReferenceItem{
|
||||
{SlotName: "glossary", Content: []byte("Brightmantle: local temple name")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := New().Extract(context.Background(), req); err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
||||
}
|
||||
request := client.requests[0]
|
||||
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
|
||||
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion)
|
||||
}
|
||||
if got := string(request.Inputs["players"].Content); got != "Alice: Aria Brightmantle" {
|
||||
t.Fatalf("players input = %q, want reference content", got)
|
||||
}
|
||||
if got := string(request.Inputs["party"].Content); got != "Aria Brightmantle: party cleric" {
|
||||
t.Fatalf("party input = %q, want reference content", got)
|
||||
}
|
||||
if got := string(request.Inputs["glossary"].Content); got != "Brightmantle: local temple name" {
|
||||
t.Fatalf("glossary input = %q, want reference content", got)
|
||||
}
|
||||
if strings.Contains(string(request.Inputs["transcript"].Content), "Aria Brightmantle: party cleric") {
|
||||
t.Fatalf("transcript input contains reference content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
inputs := shared.PromptInputs(spellSourceInput(), contracts.ReferenceSet{
|
||||
Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"roster": {
|
||||
Slot: contracts.ReferenceSlot{Name: "roster"},
|
||||
Items: []contracts.ReferenceItem{
|
||||
{SlotName: "roster", Content: []byte("Legacy roster text")},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if got := string(inputs["party"].Content); got != "Legacy roster text" {
|
||||
t.Fatalf("party input = %q, want legacy roster content", got)
|
||||
}
|
||||
if _, ok := inputs["roster"]; ok {
|
||||
t.Fatalf("roster prompt input was present; want only party input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReturnsRawOutputForEmptyResponse(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
|
||||
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
var payload extractionResponse
|
||||
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||
}
|
||||
if len(payload.SpellCasts) != 0 {
|
||||
t.Fatalf("SpellCasts = %#v, want none", payload.SpellCasts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReturnsCanonicalOutputForMalformedStructuredResponse(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{}}
|
||||
|
||||
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
if string(result.Output.Payload.Content) != `{"spell_casts":null}` {
|
||||
t.Fatalf("content = %s, want canonical structured output", result.Output.Payload.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWrapsLLMClientError(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{err: errors.New("provider unavailable")}
|
||||
|
||||
_, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||
if err == nil {
|
||||
t.Fatal("Extract() error = nil, want LLM error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dnd spells") || !strings.Contains(err.Error(), "provider unavailable") {
|
||||
t.Fatalf("Extract() error = %q, want wrapped LLM context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsInvalidRequests(t *testing.T) {
|
||||
validClient := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
validReq := extractionRequestWithClient(validClient)
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extractor *Extractor
|
||||
ctx context.Context
|
||||
req contracts.ExtractionRequest
|
||||
want string
|
||||
}{
|
||||
{name: "nil extractor", extractor: nil, ctx: context.Background(), req: validReq, want: "extractor"},
|
||||
{name: "nil context", extractor: New(), ctx: nil, req: validReq, want: "context"},
|
||||
{name: "canceled context", extractor: New(), ctx: canceledCtx, req: validReq, want: "context"},
|
||||
{name: "nil source", extractor: New(), ctx: context.Background(), req: contracts.ExtractionRequest{Chunk: validReq.Chunk, LLMClient: validReq.LLMClient}, want: "source"},
|
||||
{name: "nil chunk", extractor: New(), ctx: context.Background(), req: contracts.ExtractionRequest{Source: validReq.Source, LLMClient: validReq.LLMClient}, want: "chunk"},
|
||||
{name: "empty chunk units", extractor: New(), ctx: context.Background(), req: emptyChunkRequest(validReq), want: "units"},
|
||||
{name: "nil LLM client", extractor: New(), ctx: context.Background(), req: contracts.ExtractionRequest{Source: validReq.Source, Chunk: validReq.Chunk}, want: "LLM client"},
|
||||
{name: "source input mismatches chunk", extractor: New(), ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tt.extractor.Extract(tt.ctx, tt.req)
|
||||
if err == nil {
|
||||
t.Fatal("Extract() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dnd spells") || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Extract() error = %q, want %q context", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractOrdersSpellCastsByEarliestSourceUnit(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{
|
||||
response: extractionResponse{
|
||||
SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: "Bandit Shaman",
|
||||
Spell: "Fire Bolt",
|
||||
Effect: "Burns.",
|
||||
NarrativeDescription: "Second spell.",
|
||||
SourceRefs: responseSourceRefs("session-alpha", 2, 2),
|
||||
},
|
||||
{
|
||||
Caster: "Aria",
|
||||
Spell: "Cure Wounds",
|
||||
Effect: "Heals.",
|
||||
NarrativeDescription: "First spell.",
|
||||
SourceRefs: responseSourceRefs("session-alpha", 1, 1),
|
||||
},
|
||||
{
|
||||
Caster: "Narrator",
|
||||
Spell: "Unknown Spell",
|
||||
Effect: "No cited range.",
|
||||
NarrativeDescription: "This should sort after cited spell casts.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
var payload extractionResponse
|
||||
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||
}
|
||||
if len(payload.SpellCasts) != 3 ||
|
||||
payload.SpellCasts[0].Spell != "Cure Wounds" ||
|
||||
payload.SpellCasts[1].Spell != "Fire Bolt" ||
|
||||
payload.SpellCasts[2].Spell != "Unknown Spell" {
|
||||
t.Fatalf("spell order = %#v, want earliest source-unit order with uncited spell last", payload.SpellCasts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCanonicalizesSourceRefs(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{
|
||||
response: extractionResponse{
|
||||
SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: "Aria",
|
||||
Spell: "Cure Wounds",
|
||||
Effect: "Heals.",
|
||||
NarrativeDescription: "Aria heals.",
|
||||
SourceRefs: []shared.SourceRefResponse{
|
||||
{SourceID: "gameplay_transcript", StartUnitID: shared.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2)},
|
||||
{SourceID: "", StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)},
|
||||
{SourceID: "transcript", StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
var payload extractionResponse
|
||||
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||
}
|
||||
refs := payload.SpellCasts[0].SourceRefs
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("source refs = %#v, want duplicate collapsed", refs)
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if ref.SourceID != "session-alpha" {
|
||||
t.Fatalf("source ref = %#v, want canonical source_id", ref)
|
||||
}
|
||||
}
|
||||
if refs[0].StartUnitID.Int() != 1 || refs[0].EndUnitID.Int() != 2 ||
|
||||
refs[1].StartUnitID.Int() != 2 || refs[1].EndUnitID.Int() != 2 {
|
||||
t.Fatalf("source refs = %#v, want sorted unit ranges", refs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPreservesInvalidSourceRefsForValidators(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{
|
||||
response: extractionResponse{
|
||||
SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: "Aria",
|
||||
Spell: "Cure Wounds",
|
||||
Effect: "Heals.",
|
||||
NarrativeDescription: "Aria heals.",
|
||||
SourceRefs: []shared.SourceRefResponse{
|
||||
{SourceID: "transcript", StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("missing")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
var payload map[string][]map[string]any
|
||||
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||
}
|
||||
ref := payload["spell_casts"][0]["source_refs"].([]any)[0].(map[string]any)
|
||||
if ref["source_id"] != "session-alpha" || ref["start_unit_id"] != float64(99) || ref["end_unit_id"] != "" {
|
||||
t.Fatalf("source ref = %#v, want source_id canonicalized without unit repair", ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDefensivelyCopiesRawContent(t *testing.T) {
|
||||
client := &fakeSpellsLLMClient{
|
||||
response: extractionResponse{
|
||||
SpellCasts: []spellCastResponse{
|
||||
{
|
||||
Caster: "Aria",
|
||||
Spell: "Cure Wounds",
|
||||
Effect: "Heals.",
|
||||
NarrativeDescription: "Aria heals.",
|
||||
SourceRefs: responseSourceRefs("session-alpha", 1, 2),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := New().Extract(context.Background(), extractionRequestWithClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
client.response.SpellCasts[0].SourceRefs[0].StartUnitID = shared.UnitRefFromInt(99)
|
||||
|
||||
var payload extractionResponse
|
||||
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {
|
||||
t.Fatalf("Unmarshal(Content) error = %v, want nil", err)
|
||||
}
|
||||
if got := payload.SpellCasts[0].SourceRefs[0].StartUnitID.String(); got != "1" {
|
||||
t.Fatalf("source ref start = %q, want copied 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func extractionRequestWithClient(client contracts.StructuredLLMClient) contracts.ExtractionRequest {
|
||||
req := promptExtractionRequest()
|
||||
req.LLMClient = client
|
||||
req.SourceInput = spellChunkInput(req.Chunk)
|
||||
req.SessionID = "session-123"
|
||||
req.LLMProfile = "profile-spells"
|
||||
return req
|
||||
}
|
||||
|
||||
const spellTranscriptJSON = `{"id":"session-alpha","segments":[{"id":1,"text":"Aria raises her hand and casts Cure Wounds."}]}`
|
||||
|
||||
func spellSourceInput() contracts.LLMInputMaterial {
|
||||
return contracts.NewLLMInputMaterial("source", "application/json", []byte(spellTranscriptJSON), "sha256:transcript", "file:///session-alpha.json")
|
||||
}
|
||||
|
||||
func spellChunkInput(chunk *contracts.SourceChunk) contracts.LLMInputMaterial {
|
||||
return contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:chunk", "file:///session-alpha.json")
|
||||
}
|
||||
|
||||
func emptyChunkRequest(req contracts.ExtractionRequest) contracts.ExtractionRequest {
|
||||
req.Chunk = &contracts.SourceChunk{
|
||||
ID: req.Chunk.ID,
|
||||
SourceID: req.Chunk.SourceID,
|
||||
Index: req.Chunk.Index,
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func mismatchedSourceInputRequest(req contracts.ExtractionRequest) contracts.ExtractionRequest {
|
||||
req.SourceInput = spellSourceInput()
|
||||
return req
|
||||
}
|
||||
|
||||
type fakeSpellsLLMClient struct {
|
||||
response extractionResponse
|
||||
content []byte
|
||||
err error
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (client *fakeSpellsLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
|
||||
if client.err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, client.err
|
||||
}
|
||||
|
||||
target, ok := out.(*extractionResponse)
|
||||
if !ok {
|
||||
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
||||
}
|
||||
*target = client.response
|
||||
content := append([]byte(nil), client.content...)
|
||||
if len(content) == 0 {
|
||||
var err error
|
||||
content, err = json.Marshal(client.response)
|
||||
if err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||
}
|
||||
|
||||
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
|
||||
req.Inputs = req.Inputs.Clone()
|
||||
req.Vars = cloneVars(req.Vars)
|
||||
return req
|
||||
}
|
||||
|
||||
func cloneVars(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
22
internal/modules/dnd/extract/spells/model.go
Normal file
22
internal/modules/dnd/extract/spells/model.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package spells
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
|
||||
type SpellCast struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
}
|
||||
|
||||
type extractionResponse struct {
|
||||
SpellCasts []spellCastResponse `json:"spell_casts"`
|
||||
}
|
||||
|
||||
type spellCastResponse struct {
|
||||
Caster string `json:"caster"`
|
||||
Spell string `json:"spell"`
|
||||
Effect string `json:"effect"`
|
||||
NarrativeDescription string `json:"narrative_description"`
|
||||
SourceRefs []shared.SourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
120
internal/modules/dnd/extract/spells/registry_test.go
Normal file
120
internal/modules/dnd/extract/spells/registry_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestNewReturnsExtractorWithMetadata(t *testing.T) {
|
||||
extractor := New()
|
||||
if extractor == nil {
|
||||
t.Fatal("New() = nil, want extractor")
|
||||
}
|
||||
if extractor.Key() != Key {
|
||||
t.Fatalf("extractor.Key() = %q, want %q", extractor.Key(), Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleSpec(t *testing.T) {
|
||||
got := ModuleSpec()
|
||||
want := pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: []string{
|
||||
"chunks",
|
||||
"source.transcript",
|
||||
},
|
||||
Provides: []string{
|
||||
"dnd.spell_casts",
|
||||
},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{
|
||||
Name: "glossary",
|
||||
Description: "Optional campaign glossary reference material used only for disambiguation.",
|
||||
AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"},
|
||||
},
|
||||
{
|
||||
Name: "party",
|
||||
Description: "Optional party roster reference material used only for disambiguation.",
|
||||
AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"},
|
||||
},
|
||||
{
|
||||
Name: "players",
|
||||
Description: "Optional player list reference material used only for disambiguation.",
|
||||
AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"},
|
||||
},
|
||||
{
|
||||
Name: "roster",
|
||||
Description: "Deprecated alias for party roster reference material used only for disambiguation.",
|
||||
AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
got.Requires[0] = "changed"
|
||||
got.Provides[0] = "changed"
|
||||
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
|
||||
again := ModuleSpec()
|
||||
if !reflect.DeepEqual(again, want) {
|
||||
t.Fatalf("ModuleSpec() after caller mutation = %#v, want %#v", again, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMakesExtractorBuildable(t *testing.T) {
|
||||
registry := pipeline.NewExtractorRegistry()
|
||||
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
extractor, err := registry.Build(Key)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v, want nil", err)
|
||||
}
|
||||
if extractor.Key() != Key {
|
||||
t.Fatalf("extractor.Key() = %q, want %q", extractor.Key(), Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStoresModuleSpec(t *testing.T) {
|
||||
registry := pipeline.NewExtractorRegistry()
|
||||
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
got, ok := registry.Spec(Key)
|
||||
if !ok {
|
||||
t.Fatal("Spec() ok = false, want true")
|
||||
}
|
||||
want := ModuleSpec()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Spec() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeReferenceSlotsMatchModuleSpec(t *testing.T) {
|
||||
extractor := New()
|
||||
spec := ModuleSpec()
|
||||
|
||||
if !reflect.DeepEqual(extractor.ReferenceSlots(), spec.ReferenceSlots) {
|
||||
t.Fatalf("ReferenceSlots() = %#v, want spec slots %#v", extractor.ReferenceSlots(), spec.ReferenceSlots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterNilRegistryReturnsError(t *testing.T) {
|
||||
err := Register(nil)
|
||||
if err == nil {
|
||||
t.Fatal("Register(nil) error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "extractor registry") {
|
||||
t.Fatalf("Register(nil) error = %q, want registry context", err.Error())
|
||||
}
|
||||
}
|
||||
20
internal/modules/dnd/extract/spells/schema.go
Normal file
20
internal/modules/dnd/extract/spells/schema.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package spells
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
|
||||
const (
|
||||
PromptID = "dnd.spells"
|
||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_spells")
|
||||
ResponseSchemaID = "notarius.dnd.spells"
|
||||
ResponseSchemaName = "notarius_dnd_spells_v1"
|
||||
)
|
||||
|
||||
func loadResponseSchema() (llm.ResponseSchema, error) {
|
||||
return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{
|
||||
Key: ResponseSchemaKey,
|
||||
ID: ResponseSchemaID,
|
||||
Version: SchemaVersion,
|
||||
Name: ResponseSchemaName,
|
||||
AssetPath: "assets/schemas/dnd_spells.v1.json",
|
||||
})
|
||||
}
|
||||
130
internal/modules/dnd/extract/spells/schema_test.go
Normal file
130
internal/modules/dnd/extract/spells/schema_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadResponseSchemaForSpells(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
|
||||
}
|
||||
if schema.Key != ResponseSchemaKey {
|
||||
t.Fatalf("schema.Key = %q, want %q", schema.Key, ResponseSchemaKey)
|
||||
}
|
||||
if schema.ID != ResponseSchemaID {
|
||||
t.Fatalf("schema.ID = %q, want %q", schema.ID, ResponseSchemaID)
|
||||
}
|
||||
if schema.Version != SchemaVersion {
|
||||
t.Fatalf("schema.Version = %q, want %q", schema.Version, SchemaVersion)
|
||||
}
|
||||
if schema.Name != ResponseSchemaName {
|
||||
t.Fatalf("schema.Name = %q, want %q", schema.Name, ResponseSchemaName)
|
||||
}
|
||||
if !strings.HasPrefix(schema.SHA256, "sha256:") {
|
||||
t.Fatalf("schema.SHA256 = %q, want sha256 prefix", schema.SHA256)
|
||||
}
|
||||
if !json.Valid(schema.JSONSchema) {
|
||||
t.Fatalf("schema.JSONSchema is invalid JSON: %s", schema.JSONSchema)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(schema.JSONSchema, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal(schema.JSONSchema) error = %v, want nil", err)
|
||||
}
|
||||
properties := decoded["properties"].(map[string]any)
|
||||
spellCastProperties := properties["spell_casts"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
|
||||
sourceRefProperties := spellCastProperties["source_refs"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
|
||||
sourceRefRequired := spellCastProperties["source_refs"].(map[string]any)["items"].(map[string]any)["required"].([]any)
|
||||
if !containsJSONField(sourceRefRequired, "source_id") {
|
||||
t.Fatalf("canonical source refs required = %#v, want source_id", sourceRefRequired)
|
||||
}
|
||||
for _, field := range []string{"start_unit_id", "end_unit_id"} {
|
||||
property := sourceRefProperties[field].(map[string]any)
|
||||
if property["type"] != "integer" {
|
||||
t.Fatalf("%s type = %#v, want integer", field, property["type"])
|
||||
}
|
||||
if property["minimum"] != float64(1) {
|
||||
t.Fatalf("%s minimum = %#v, want 1", field, property["minimum"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMResponseSchemaOmitsSourceID(t *testing.T) {
|
||||
raw, err := embeddedAssets.ReadFile("assets/schemas/dnd_spells_llm.v1.json")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(LLM schema) error = %v, want nil", err)
|
||||
}
|
||||
if !json.Valid(raw) {
|
||||
t.Fatalf("LLM schema is invalid JSON: %s", raw)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal(LLM schema) error = %v, want nil", err)
|
||||
}
|
||||
properties := decoded["properties"].(map[string]any)
|
||||
spellCastProperties := properties["spell_casts"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
|
||||
sourceRefItems := spellCastProperties["source_refs"].(map[string]any)["items"].(map[string]any)
|
||||
sourceRefProperties := sourceRefItems["properties"].(map[string]any)
|
||||
sourceRefRequired := sourceRefItems["required"].([]any)
|
||||
if _, ok := sourceRefProperties["source_id"]; ok {
|
||||
t.Fatalf("LLM source ref schema contains source_id property: %#v", sourceRefProperties)
|
||||
}
|
||||
if containsJSONField(sourceRefRequired, "source_id") {
|
||||
t.Fatalf("LLM source refs required = %#v, want no source_id", sourceRefRequired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
||||
first, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
|
||||
}
|
||||
first.JSONSchema[0] = '['
|
||||
|
||||
second, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
|
||||
}
|
||||
if !json.Valid(second.JSONSchema) {
|
||||
t.Fatalf("schema JSON was mutated: %s", second.JSONSchema)
|
||||
}
|
||||
if len(second.JSONSchema) > 0 && second.JSONSchema[0] == '[' {
|
||||
t.Fatalf("schema JSON did not use defensive copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaDiagnosticsOmitRawSchema(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
|
||||
}
|
||||
diagnostics := schema.DiagnosticsMap()
|
||||
|
||||
if diagnostics["key"] != ResponseSchemaKey {
|
||||
t.Fatalf("diagnostics[key] = %#v, want %q", diagnostics["key"], ResponseSchemaKey)
|
||||
}
|
||||
for _, key := range []string{"id", "version", "name", "sha256"} {
|
||||
if diagnostics[key] == "" {
|
||||
t.Fatalf("diagnostics[%q] = %#v, want value", key, diagnostics[key])
|
||||
}
|
||||
}
|
||||
if _, ok := diagnostics["json_schema"]; ok {
|
||||
t.Fatalf("diagnostics should omit raw schema content: %#v", diagnostics)
|
||||
}
|
||||
if _, ok := diagnostics["JSONSchema"]; ok {
|
||||
t.Fatalf("diagnostics should omit raw schema content: %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func containsJSONField(fields []any, want string) bool {
|
||||
for _, field := range fields {
|
||||
if field == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
45
internal/modules/dnd/extract/spells/scriptorium_assets.go
Normal file
45
internal/modules/dnd/extract/spells/scriptorium_assets.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package spells
|
||||
|
||||
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"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare spell 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() {
|
||||
parts := append([]llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
}
|
||||
|
||||
var (
|
||||
scriptoriumPromptHashOnce sync.Once
|
||||
scriptoriumPromptHash string
|
||||
scriptoriumPromptHashErr error
|
||||
)
|
||||
132
internal/modules/dnd/extract/spells/scriptorium_assets_test.go
Normal file
132
internal/modules/dnd/extract/spells/scriptorium_assets_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/scriptorium"
|
||||
)
|
||||
|
||||
func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing.T) {
|
||||
transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"Mira casts shield."}]}`)
|
||||
prepared := prepareSpellsPrompt(t, transcript, "Dana: Mira", "Mira: wizard", "Shield: abjuration")
|
||||
|
||||
if prepared.PromptID != PromptID {
|
||||
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
|
||||
}
|
||||
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
|
||||
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
|
||||
}
|
||||
if got := len(prepared.Messages); got != 5 {
|
||||
t.Fatalf("message count = %d, want 5", got)
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[1].Content, string(transcript)) {
|
||||
t.Fatalf("transcript message did not include source input")
|
||||
}
|
||||
if prepared.Messages[1].CacheControl == nil || prepared.Messages[2].CacheControl == nil {
|
||||
t.Fatalf("expected transcript and reference messages to be cacheable: %#v", prepared.Messages)
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Dana: Mira") {
|
||||
t.Fatalf("reference message missing player content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Mira: wizard") {
|
||||
t.Fatalf("reference message missing party content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Shield: abjuration") {
|
||||
t.Fatalf("reference message missing glossary content")
|
||||
}
|
||||
if strings.Contains(prepared.Messages[3].Content, string(transcript)) {
|
||||
t.Fatalf("task message leaked transcript bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) {
|
||||
transcript := []byte(`{"id":"session-1","segments":[]}`)
|
||||
prepared := prepareSpellsPrompt(t, transcript, " ", " ", " ")
|
||||
|
||||
if !strings.Contains(prepared.Messages[2].Content, " ") {
|
||||
t.Fatalf("reference message did not include empty optional reference placeholders")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
||||
transcript := []byte(`{"secret":"source text"}`)
|
||||
reference := "private party note"
|
||||
prepared := prepareSpellsPrompt(t, transcript, "private player note", reference, " ")
|
||||
metadata := New().ManifestMetadata()
|
||||
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"prepared": map[string]any{
|
||||
"prompt_id": prepared.PromptID,
|
||||
"prompt_version": prepared.PromptVersion,
|
||||
"prompt_hash": prepared.PromptHash,
|
||||
"rendered_prompt_hash": prepared.RenderedPromptHash,
|
||||
"selected_profile_id": prepared.SelectedProfileID,
|
||||
"output_contract": prepared.OutputContract,
|
||||
"input_hashes": prepared.InputHashes,
|
||||
"effective_model_params": prepared.EffectiveModelParams,
|
||||
},
|
||||
"manifest": metadata,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal diagnostics: %v", err)
|
||||
}
|
||||
diagnostics := string(payload)
|
||||
for _, forbidden := range []string{
|
||||
"source text",
|
||||
"private player note",
|
||||
reference,
|
||||
`"properties"`,
|
||||
"spell_casts",
|
||||
} {
|
||||
if strings.Contains(diagnostics, forbidden) {
|
||||
t.Fatalf("diagnostics leaked %q: %s", forbidden, diagnostics)
|
||||
}
|
||||
}
|
||||
if metadata["prompt_id"] != PromptID || metadata["prompt_version"] != SchemaVersion {
|
||||
t.Fatalf("manifest prompt metadata = %#v", metadata)
|
||||
}
|
||||
if !strings.HasPrefix(metadata["prompt_sha256"].(string), "sha256:") {
|
||||
t.Fatalf("manifest prompt hash = %#v, want sha256-prefixed", metadata["prompt_sha256"])
|
||||
}
|
||||
}
|
||||
|
||||
func prepareSpellsPrompt(t *testing.T, transcript []byte, players string, party string, glossary string) *scriptorium.PreparedRun {
|
||||
t.Helper()
|
||||
registry := llm.NewAssetRegistry()
|
||||
if err := RegisterPromptAssets(registry); err != nil {
|
||||
t.Fatalf("register spell prompt assets: %v", err)
|
||||
}
|
||||
options, err := registry.ScriptoriumOptions()
|
||||
if err != nil {
|
||||
t.Fatalf("ScriptoriumOptions() error = %v, want nil", err)
|
||||
}
|
||||
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
|
||||
ID: "spell-test-profile",
|
||||
Endpoint: "http://127.0.0.1:1/v1",
|
||||
Model: "spell-test-model",
|
||||
})))
|
||||
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine() error = %v, want nil", err)
|
||||
}
|
||||
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
|
||||
PromptID: PromptID,
|
||||
PromptVersion: SchemaVersion,
|
||||
ProfileID: "spell-test-profile",
|
||||
Inputs: map[string]scriptorium.ArtifactRef{
|
||||
"transcript": scriptorium.InlineWithURI("file:///session.json", string(transcript)),
|
||||
"players": scriptorium.Inline(players),
|
||||
"party": scriptorium.Inline(party),
|
||||
"glossary": scriptorium.Inline(glossary),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
86
internal/modules/dnd/extract/spells/test_helpers_test.go
Normal file
86
internal/modules/dnd/extract/spells/test_helpers_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
func promptExtractionRequest() contracts.ExtractionRequest {
|
||||
doc := promptSourceDocument()
|
||||
chunk := &contracts.SourceChunk{
|
||||
ID: "session-alpha:chunk:0",
|
||||
SourceID: doc.ID,
|
||||
Index: 0,
|
||||
StartUnitID: doc.Units[0].ID,
|
||||
EndUnitID: doc.Units[len(doc.Units)-1].ID,
|
||||
Content: []byte(`{"units":[1,2]}`),
|
||||
MediaType: "application/json",
|
||||
Units: append([]source.SourceUnit(nil), doc.Units...),
|
||||
Metadata: map[string]any{"ignored": "chunk metadata"},
|
||||
}
|
||||
return contracts.ExtractionRequest{
|
||||
Source: doc,
|
||||
Chunk: chunk,
|
||||
}
|
||||
}
|
||||
|
||||
func promptSourceDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{
|
||||
ID: "session-alpha",
|
||||
Kind: "transcript",
|
||||
Format: "application/vnd.seriatim.minimal+json",
|
||||
Digest: "sha256:test",
|
||||
Units: []source.SourceUnit{
|
||||
{
|
||||
ID: 1,
|
||||
Kind: "transcript_segment",
|
||||
Text: "Aria raises her hand and casts Cure Wounds.",
|
||||
Metadata: map[string]any{
|
||||
"speaker": "Alice",
|
||||
"start": json.Number("1.25"),
|
||||
"end": json.Number("3.5"),
|
||||
"ignored": "not rendered",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Kind: "transcript_segment",
|
||||
Text: "The fighter's wounds begin to close.",
|
||||
Metadata: map[string]any{"ignored": "not rendered"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v, want nil", err)
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
|
||||
return []shared.SourceRefResponse{
|
||||
{
|
||||
SourceID: sourceID,
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func responseSourceRefsInt(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
|
||||
return []shared.SourceRefResponse{
|
||||
{
|
||||
SourceID: sourceID,
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user