Organize D&D extensions by domain

This commit is contained in:
2026-07-17 05:05:48 +00:00
parent a81b9f1e1f
commit 15c369c509
64 changed files with 469 additions and 194 deletions

View File

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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
}
}
}
}
}
}
}
}
}

View File

@@ -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
}
}
}
}
}
}
}
}
}

View 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
}

View 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...)
}

View 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
}

View 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"`
}

View 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())
}
}

View 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",
})
}

View 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
}

View 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
)

View 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
}

View 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),
},
}
}