Organize D&D extensions by domain
This commit is contained in:
6
internal/modules/dnd/chunk/scenes/assets.go
Normal file
6
internal/modules/dnd/chunk/scenes/assets.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package scenes
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed assets/schemas/*.json assets/prompts/*.yaml assets/prompts/*.md
|
||||
var embeddedAssets embed.FS
|
||||
@@ -0,0 +1,36 @@
|
||||
id: dnd.scenes
|
||||
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_scenes.v1.json
|
||||
repair_attempts: 0
|
||||
@@ -0,0 +1,52 @@
|
||||
Good reasons to start a new scene include:
|
||||
- the party moves to a new location;
|
||||
- a combat encounter begins or ends;
|
||||
- combat changes into a substantially different phase;
|
||||
- the party shifts between combat, exploration, social interaction, discussion,
|
||||
planning, travel, rest, or downtime;
|
||||
- a new NPC, faction, threat, or objective becomes central;
|
||||
- the party completes one immediate goal and begins another;
|
||||
- a major table-level rules discussion interrupts and materially changes play.
|
||||
|
||||
Do not start a new scene merely because:
|
||||
- the speaker changes;
|
||||
- a new combat round begins;
|
||||
- a player asks a brief rules question;
|
||||
- there is a joke, aside, or short table comment;
|
||||
- a character takes a routine turn;
|
||||
- the same encounter continues without a meaningful change in situation.
|
||||
|
||||
dnd/scenes boundary policy:
|
||||
- cover the full provided transcript from the first source unit to the last
|
||||
source unit;
|
||||
- return sequential scenes with no gaps;
|
||||
- do not overlap scenes;
|
||||
- preserve source-unit order;
|
||||
- use integer source-unit IDs from the transcript;
|
||||
- each scene must have start_unit_id and end_unit_id;
|
||||
- do not include final chunk IDs or chunk indexes.
|
||||
|
||||
For each scene:
|
||||
- short_title should be brief and factual;
|
||||
- primary_mode must be Recap, Discussion, Combat, or Narrative;
|
||||
- main_participants should include only principal characters, NPCs, factions, or
|
||||
groups involved;
|
||||
- summary should be factual and compact, usually one to three sentences;
|
||||
- boundary_note should explain why the scene begins at start_unit_id and ends at
|
||||
end_unit_id;
|
||||
- boundary_confidence must be High, Medium, or Low.
|
||||
|
||||
Primary mode guidance:
|
||||
- Use Recap for opening recap, initiative setup, session framing, or immediate
|
||||
continuation from prior events.
|
||||
- Use Discussion when the party is primarily discussing options or choosing a
|
||||
course of action.
|
||||
- Use Combat when active combat or combat-resolution mechanics dominate.
|
||||
- Use Narrative for all other non-combat gameplay, including exploration, social
|
||||
interactions, shopping, preparation, travel, rest, and downtime.
|
||||
|
||||
In boundary_caveats, list overall caveats about scene divisions. Include scenes
|
||||
that could reasonably be split differently, combat phases that were kept
|
||||
together, gradual transitions, or places where map context would have helped.
|
||||
|
||||
Return exactly one JSON object and no explanatory text.
|
||||
5
internal/modules/dnd/chunk/scenes/assets/prompts/task.md
Normal file
5
internal/modules/dnd/chunk/scenes/assets/prompts/task.md
Normal file
@@ -0,0 +1,5 @@
|
||||
Divide the provided transcript into coherent Dungeons & Dragons scenes for the
|
||||
dnd/scenes chunk module.
|
||||
|
||||
A scene is a coherent unit of play. Start a new scene when there is a meaningful
|
||||
change in location, objective, threat, activity, encounter, or mode of play.
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.scenes",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"scenes",
|
||||
"boundary_caveats"
|
||||
],
|
||||
"properties": {
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"start_unit_id",
|
||||
"end_unit_id",
|
||||
"short_title",
|
||||
"primary_mode",
|
||||
"main_participants",
|
||||
"summary",
|
||||
"boundary_note",
|
||||
"boundary_confidence"
|
||||
],
|
||||
"properties": {
|
||||
"start_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"end_unit_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"short_title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"primary_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Recap",
|
||||
"Discussion",
|
||||
"Combat",
|
||||
"Narrative"
|
||||
]
|
||||
},
|
||||
"main_participants": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"boundary_note": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"boundary_confidence": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"High",
|
||||
"Medium",
|
||||
"Low"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"boundary_caveats": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
354
internal/modules/dnd/chunk/scenes/chunker.go
Normal file
354
internal/modules/dnd/chunk/scenes/chunker.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package scenes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"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/scenes"
|
||||
|
||||
var requiredCapabilities = []string{
|
||||
"source.transcript",
|
||||
}
|
||||
|
||||
var providedCapabilities = []string{
|
||||
"chunks",
|
||||
"chunks.scenes",
|
||||
}
|
||||
|
||||
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
||||
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
|
||||
Party: "Optional party roster reference material used only for scene disambiguation.",
|
||||
Players: "Optional player list reference material used only for scene disambiguation.",
|
||||
Roster: "Deprecated alias for party roster reference material used only for scene disambiguation.",
|
||||
}
|
||||
|
||||
var _ contracts.Chunker = (*Chunker)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Chunker)(nil)
|
||||
|
||||
type Chunker struct{}
|
||||
|
||||
func New() *Chunker {
|
||||
return &Chunker{}
|
||||
}
|
||||
|
||||
func (c *Chunker) Key() string {
|
||||
return Key
|
||||
}
|
||||
|
||||
func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
}
|
||||
|
||||
func (c *Chunker) ManifestMetadata() map[string]any {
|
||||
promptSHA, err := scriptoriumPromptMetadata()
|
||||
if err != nil {
|
||||
promptSHA = ""
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": ResponseSchemaVersion,
|
||||
"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 (c *Chunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) {
|
||||
if c == nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("chunker must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("context must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("context error before chunking: %w", err)
|
||||
}
|
||||
if req.Source == nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("source must not be nil")
|
||||
}
|
||||
if len(req.Source.Units) == 0 {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("source units must not be empty")
|
||||
}
|
||||
if err := source.ValidateDocument(req.Source); err != nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("validate source document: %w", err)
|
||||
}
|
||||
if req.LLMClient == nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("LLM client must not be nil")
|
||||
}
|
||||
if len(req.Options) > 0 {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("options are not supported")
|
||||
}
|
||||
|
||||
var response chunkResponse
|
||||
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key,
|
||||
PromptID: PromptID,
|
||||
PromptVersion: ResponseSchemaVersion,
|
||||
ProfileID: req.LLMProfile,
|
||||
SessionID: req.SessionID,
|
||||
Inputs: shared.PromptInputs(req.SourceInput, req.References),
|
||||
}, &response); err != nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("complete structured output: %w", err)
|
||||
}
|
||||
|
||||
warnings, err := warningsFromCaveats(response.BoundaryCaveats)
|
||||
if err != nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("malformed structured output: %w", err)
|
||||
}
|
||||
chunks, err := chunksFromResponse(req.Source, response)
|
||||
if err != nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("malformed structured output: %w", err)
|
||||
}
|
||||
return contracts.ChunkResult{
|
||||
Chunks: chunks,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions),
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ChunkerRegistry) error {
|
||||
return registry.RegisterWithSpec(ModuleSpec(), func() (contracts.Chunker, error) {
|
||||
return New(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func chunksFromResponse(doc *source.SourceDocument, response chunkResponse) ([]contracts.SourceChunk, error) {
|
||||
if response.Scenes == nil {
|
||||
return nil, fmt.Errorf("scenes must be present")
|
||||
}
|
||||
if len(response.Scenes) == 0 {
|
||||
return nil, fmt.Errorf("scenes must not be empty")
|
||||
}
|
||||
|
||||
unitIndexes := make(map[int]int, len(doc.Units))
|
||||
for i, unit := range doc.Units {
|
||||
unitIndexes[unit.ID] = i
|
||||
}
|
||||
|
||||
chunks := make([]contracts.SourceChunk, 0, len(response.Scenes))
|
||||
previousEnd := -1
|
||||
for i, scene := range response.Scenes {
|
||||
normalized, err := normalizeScene(doc, i, scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startIndex, ok := unitIndexes[normalized.StartUnitID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, normalized.StartUnitID)
|
||||
}
|
||||
endIndex, ok := unitIndexes[normalized.EndUnitID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, normalized.EndUnitID)
|
||||
}
|
||||
if startIndex > endIndex {
|
||||
return nil, fmt.Errorf("scene[%d] start_unit_id %d appears after end_unit_id %d", i, normalized.StartUnitID, normalized.EndUnitID)
|
||||
}
|
||||
|
||||
if i == 0 && startIndex != 0 {
|
||||
return nil, fmt.Errorf("first scene must start at first source unit %d", doc.Units[0].ID)
|
||||
}
|
||||
if i > 0 {
|
||||
if startIndex <= previousEnd {
|
||||
return nil, fmt.Errorf("scene[%d] overlaps previous scene", i)
|
||||
}
|
||||
if startIndex > previousEnd+1 {
|
||||
return nil, fmt.Errorf("scene[%d] leaves a gap after previous scene", i)
|
||||
}
|
||||
}
|
||||
previousEnd = endIndex
|
||||
|
||||
units := cloneUnits(doc.Units[startIndex : endIndex+1])
|
||||
content, err := chunkContent(units)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chunks = append(chunks, contracts.SourceChunk{
|
||||
ID: fmt.Sprintf("scene-%06d", i+1),
|
||||
SourceID: doc.ID,
|
||||
Index: i,
|
||||
StartUnitID: units[0].ID,
|
||||
EndUnitID: units[len(units)-1].ID,
|
||||
Content: content,
|
||||
MediaType: "application/json",
|
||||
Units: units,
|
||||
Metadata: map[string]any{
|
||||
"scene_title": normalized.ShortTitle,
|
||||
"primary_mode": normalized.PrimaryMode,
|
||||
"main_participants": append([]string(nil), normalized.MainParticipants...),
|
||||
"summary": normalized.Summary,
|
||||
"boundary_note": normalized.BoundaryNote,
|
||||
"boundary_confidence": normalized.BoundaryConfidence,
|
||||
"start_unit_id": normalized.StartUnitID,
|
||||
"end_unit_id": normalized.EndUnitID,
|
||||
"unit_count": len(units),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if previousEnd != len(doc.Units)-1 {
|
||||
return nil, fmt.Errorf("final scene must end at final source unit %d", doc.Units[len(doc.Units)-1].ID)
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func chunkContent(units []source.SourceUnit) ([]byte, error) {
|
||||
content, err := json.Marshal(struct {
|
||||
Units []source.SourceUnit `json:"units"`
|
||||
}{
|
||||
Units: units,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode chunk content: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) {
|
||||
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
|
||||
if err != nil {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
|
||||
}
|
||||
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
|
||||
if err != nil {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
|
||||
}
|
||||
|
||||
out := normalizedScene{
|
||||
StartUnitID: startUnitID,
|
||||
EndUnitID: endUnitID,
|
||||
ShortTitle: strings.TrimSpace(scene.ShortTitle),
|
||||
PrimaryMode: strings.TrimSpace(scene.PrimaryMode),
|
||||
Summary: strings.TrimSpace(scene.Summary),
|
||||
BoundaryNote: strings.TrimSpace(scene.BoundaryNote),
|
||||
BoundaryConfidence: strings.TrimSpace(scene.BoundaryConfidence),
|
||||
}
|
||||
|
||||
requiredInts := map[string]int{
|
||||
"start_unit_id": out.StartUnitID,
|
||||
"end_unit_id": out.EndUnitID,
|
||||
}
|
||||
for field, value := range requiredInts {
|
||||
if value <= 0 {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %s must be positive", index, field)
|
||||
}
|
||||
}
|
||||
required := map[string]string{
|
||||
"short_title": out.ShortTitle,
|
||||
"primary_mode": out.PrimaryMode,
|
||||
"summary": out.Summary,
|
||||
"boundary_note": out.BoundaryNote,
|
||||
"boundary_confidence": out.BoundaryConfidence,
|
||||
}
|
||||
for field, value := range required {
|
||||
if value == "" {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] %s must not be empty", index, field)
|
||||
}
|
||||
}
|
||||
if !validPrimaryMode(out.PrimaryMode) {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] primary_mode %q is not supported", index, out.PrimaryMode)
|
||||
}
|
||||
if !validBoundaryConfidence(out.BoundaryConfidence) {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] boundary_confidence %q is not supported", index, out.BoundaryConfidence)
|
||||
}
|
||||
if len(scene.MainParticipants) == 0 {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants must not be empty", index)
|
||||
}
|
||||
out.MainParticipants = make([]string, 0, len(scene.MainParticipants))
|
||||
for participantIndex, participant := range scene.MainParticipants {
|
||||
trimmed := strings.TrimSpace(participant)
|
||||
if trimmed == "" {
|
||||
return normalizedScene{}, fmt.Errorf("scene[%d] main_participants[%d] must not be empty", index, participantIndex)
|
||||
}
|
||||
out.MainParticipants = append(out.MainParticipants, trimmed)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validPrimaryMode(value string) bool {
|
||||
switch value {
|
||||
case "Recap", "Discussion", "Combat", "Narrative":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validBoundaryConfidence(value string) bool {
|
||||
switch value {
|
||||
case "High", "Medium", "Low":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func warningsFromCaveats(caveats []string) ([]contracts.Warning, error) {
|
||||
if len(caveats) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
warnings := make([]contracts.Warning, 0, len(caveats))
|
||||
for i, caveat := range caveats {
|
||||
trimmed := strings.TrimSpace(caveat)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("boundary_caveats[%d] must not be empty after trimming", i)
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: Key,
|
||||
ReasonCode: "scene_boundary_caveat",
|
||||
Message: trimmed,
|
||||
})
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func cloneUnits(units []source.SourceUnit) []source.SourceUnit {
|
||||
out := make([]source.SourceUnit, 0, len(units))
|
||||
for _, unit := range units {
|
||||
out = append(out, source.SourceUnit{
|
||||
ID: unit.ID,
|
||||
Kind: unit.Kind,
|
||||
Text: unit.Text,
|
||||
Metadata: cloneMetadata(unit.Metadata),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMetadata(metadata map[string]any) map[string]any {
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(metadata))
|
||||
for key, value := range metadata {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func chunkerErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd scenes chunker: "+format, args...)
|
||||
}
|
||||
628
internal/modules/dnd/chunk/scenes/chunker_test.go
Normal file
628
internal/modules/dnd/chunk/scenes/chunker_test.go
Normal file
@@ -0,0 +1,628 @@
|
||||
package scenes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestNewModuleSpecAndRegister(t *testing.T) {
|
||||
chunker := New()
|
||||
if chunker == nil {
|
||||
t.Fatal("New() = nil, want chunker")
|
||||
}
|
||||
if chunker.Key() != Key {
|
||||
t.Fatalf("Key() = %q, want %q", chunker.Key(), Key)
|
||||
}
|
||||
|
||||
want := pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: []string{"source.transcript"},
|
||||
Provides: []string{"chunks", "chunks.scenes"},
|
||||
ReferenceSlots: wantReferenceSlots(),
|
||||
}
|
||||
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
|
||||
}
|
||||
got := ModuleSpec()
|
||||
got.Requires[0] = "changed"
|
||||
got.Provides[0] = "changed"
|
||||
got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed"
|
||||
if again := ModuleSpec(); !reflect.DeepEqual(again, want) {
|
||||
t.Fatalf("ModuleSpec() after caller mutation = %#v, want %#v", again, want)
|
||||
}
|
||||
|
||||
registry := pipeline.NewChunkerRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
}
|
||||
registered, ok := registry.Spec(Key)
|
||||
if !ok {
|
||||
t.Fatalf("Spec(%q) ok = false, want true", Key)
|
||||
}
|
||||
if !reflect.DeepEqual(registered, want) {
|
||||
t.Fatalf("registered spec = %#v, want %#v", registered, want)
|
||||
}
|
||||
built, err := registry.Build(Key)
|
||||
if err != nil {
|
||||
t.Fatalf("Build(%q) error = %v, want nil", Key, err)
|
||||
}
|
||||
if built.Key() != Key {
|
||||
t.Fatalf("built Key() = %q, want %q", built.Key(), Key)
|
||||
}
|
||||
if slots := built.ReferenceSlots(); !reflect.DeepEqual(slots, want.ReferenceSlots) {
|
||||
t.Fatalf("ReferenceSlots() = %#v, want %#v", slots, want.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(), "chunker registry") {
|
||||
t.Fatalf("Register(nil) error = %q, want registry context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func wantReferenceSlots() []contracts.ReferenceSlot {
|
||||
accepted := []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}
|
||||
return []contracts.ReferenceSlot{
|
||||
{
|
||||
Name: "glossary",
|
||||
Description: "Optional campaign glossary reference material used only for scene disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), accepted...),
|
||||
},
|
||||
{
|
||||
Name: "party",
|
||||
Description: "Optional party roster reference material used only for scene disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), accepted...),
|
||||
},
|
||||
{
|
||||
Name: "players",
|
||||
Description: "Optional player list reference material used only for scene disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), accepted...),
|
||||
},
|
||||
{
|
||||
Name: "roster",
|
||||
Description: "Deprecated alias for party roster reference material used only for scene disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), accepted...),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{
|
||||
response: chunkResponse{
|
||||
Scenes: []sceneResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(2),
|
||||
ShortTitle: " Goblin parley ",
|
||||
PrimaryMode: "Discussion",
|
||||
MainParticipants: []string{" Aria ", "Goblin scout"},
|
||||
Summary: " The party negotiates with a scout. ",
|
||||
BoundaryNote: " The scene covers the discussion before fighting starts. ",
|
||||
BoundaryConfidence: "High",
|
||||
},
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(3),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: "Ambush at the gate",
|
||||
PrimaryMode: "Combat",
|
||||
MainParticipants: []string{"Aria", "Goblin ambushers"},
|
||||
Summary: "The goblins attack at the gate.",
|
||||
BoundaryNote: "Combat begins and resolves the immediate threat.",
|
||||
BoundaryConfidence: "Medium",
|
||||
},
|
||||
},
|
||||
BoundaryCaveats: []string{" The transition into combat is gradual. "},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := New().Chunk(context.Background(), chunkRequestWithClient(client))
|
||||
if err != nil {
|
||||
t.Fatalf("Chunk() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
||||
}
|
||||
req := client.requests[0]
|
||||
if req.StageName != Key {
|
||||
t.Fatalf("StageName = %q, want %q", req.StageName, Key)
|
||||
}
|
||||
if req.PromptID != PromptID || req.PromptVersion != ResponseSchemaVersion {
|
||||
t.Fatalf("prompt = %q/%q, want %q/%q", req.PromptID, req.PromptVersion, PromptID, ResponseSchemaVersion)
|
||||
}
|
||||
if req.SessionID != "session-123" || req.ProfileID != "profile-scenes" {
|
||||
t.Fatalf("session/profile = %q/%q, want session-123/profile-scenes", req.SessionID, req.ProfileID)
|
||||
}
|
||||
transcript, ok := req.Inputs["transcript"]
|
||||
if !ok {
|
||||
t.Fatalf("transcript input missing from %#v", req.Inputs)
|
||||
}
|
||||
if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:transcript" || transcript.OriginURI != "file:///session-alpha.json" {
|
||||
t.Fatalf("transcript metadata = %#v", transcript)
|
||||
}
|
||||
if got := string(transcript.Content); got != sceneTranscriptJSON {
|
||||
t.Fatalf("transcript content = %q, want original source input", got)
|
||||
}
|
||||
if got := string(req.Inputs["players"].Content); got != " " {
|
||||
t.Fatalf("players input = %q, want empty reference placeholder", got)
|
||||
}
|
||||
if got := string(req.Inputs["party"].Content); got != " " {
|
||||
t.Fatalf("party input = %q, want empty reference placeholder", got)
|
||||
}
|
||||
if got := string(req.Inputs["glossary"].Content); got != " " {
|
||||
t.Fatalf("glossary input = %q, want empty reference placeholder", got)
|
||||
}
|
||||
|
||||
if got := chunkIDs(result.Chunks); !reflect.DeepEqual(got, []string{"scene-000001", "scene-000002"}) {
|
||||
t.Fatalf("chunk IDs = %#v, want deterministic scene IDs", got)
|
||||
}
|
||||
gotUnits := [][]int{unitIDs(result.Chunks[0].Units), unitIDs(result.Chunks[1].Units)}
|
||||
wantUnits := [][]int{{1, 2}, {3, 4}}
|
||||
if !reflect.DeepEqual(gotUnits, wantUnits) {
|
||||
t.Fatalf("chunk units = %#v, want %#v", gotUnits, wantUnits)
|
||||
}
|
||||
first := result.Chunks[0]
|
||||
if first.SourceID != "session-alpha" || first.Index != 0 {
|
||||
t.Fatalf("first chunk = %#v, want source and index fields", first)
|
||||
}
|
||||
if first.StartUnitID != 1 || first.EndUnitID != 2 {
|
||||
t.Fatalf("first boundaries = %d-%d, want 1-2", first.StartUnitID, first.EndUnitID)
|
||||
}
|
||||
if first.MediaType != "application/json" || len(first.Content) == 0 {
|
||||
t.Fatalf("first payload = media type %q length %d, want JSON content", first.MediaType, len(first.Content))
|
||||
}
|
||||
if first.Metadata["scene_title"] != "Goblin parley" ||
|
||||
first.Metadata["primary_mode"] != "Discussion" ||
|
||||
first.Metadata["summary"] != "The party negotiates with a scout." ||
|
||||
first.Metadata["boundary_note"] != "The scene covers the discussion before fighting starts." ||
|
||||
first.Metadata["boundary_confidence"] != "High" ||
|
||||
first.Metadata["start_unit_id"] != 1 ||
|
||||
first.Metadata["end_unit_id"] != 2 ||
|
||||
first.Metadata["unit_count"] != 2 {
|
||||
t.Fatalf("first metadata = %#v, want scene metadata", first.Metadata)
|
||||
}
|
||||
if got, ok := first.Metadata["main_participants"].([]string); !ok || !reflect.DeepEqual(got, []string{"Aria", "Goblin scout"}) {
|
||||
t.Fatalf("main_participants = %#v, want trimmed participant slice", first.Metadata["main_participants"])
|
||||
}
|
||||
if got := result.Warnings; len(got) != 1 ||
|
||||
got[0].Scope != Key ||
|
||||
got[0].ReasonCode != "scene_boundary_caveat" ||
|
||||
got[0].Message != "The transition into combat is gradual." {
|
||||
t.Fatalf("Warnings = %#v, want boundary caveat warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{response: chunkResponse{
|
||||
Scenes: []sceneResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: "Ambush",
|
||||
PrimaryMode: "Combat",
|
||||
MainParticipants: []string{"Aria"},
|
||||
Summary: "The party is ambushed.",
|
||||
BoundaryNote: "One scene covers the short fixture.",
|
||||
BoundaryConfidence: "High",
|
||||
},
|
||||
},
|
||||
}}
|
||||
req := chunkRequestWithClient(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")},
|
||||
},
|
||||
},
|
||||
"party": {
|
||||
Slot: contracts.ReferenceSlot{Name: "party"},
|
||||
Items: []contracts.ReferenceItem{
|
||||
{SlotName: "party", Content: []byte("Aria: cleric")},
|
||||
},
|
||||
},
|
||||
"glossary": {
|
||||
Slot: contracts.ReferenceSlot{Name: "glossary"},
|
||||
Items: []contracts.ReferenceItem{
|
||||
{SlotName: "glossary", Content: []byte("Brightmantle: local temple")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := New().Chunk(context.Background(), req); err != nil {
|
||||
t.Fatalf("Chunk() error = %v, want nil", err)
|
||||
}
|
||||
request := client.requests[0]
|
||||
if got := string(request.Inputs["players"].Content); got != "Alice: Aria" {
|
||||
t.Fatalf("players input = %q, want reference content", got)
|
||||
}
|
||||
if got := string(request.Inputs["party"].Content); got != "Aria: cleric" {
|
||||
t.Fatalf("party input = %q, want reference content", got)
|
||||
}
|
||||
if got := string(request.Inputs["glossary"].Content); got != "Brightmantle: local temple" {
|
||||
t.Fatalf("glossary input = %q, want reference content", got)
|
||||
}
|
||||
if strings.Contains(string(request.Inputs["transcript"].Content), "Aria: cleric") {
|
||||
t.Fatalf("transcript input contains reference content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
inputs := shared.PromptInputs(sceneSourceInput(), 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 TestChunkRejectsWhitespaceOnlyBoundaryCaveats(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{
|
||||
response: chunkResponse{
|
||||
Scenes: validSceneResponse().Scenes,
|
||||
BoundaryCaveats: []string{
|
||||
" ",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := New().Chunk(context.Background(), chunkRequestWithClient(client))
|
||||
if err == nil {
|
||||
t.Fatal("Chunk() error = nil, want malformed structured output error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dnd scenes chunker") || !strings.Contains(err.Error(), "malformed structured output") || !strings.Contains(err.Error(), "boundary_caveats[0]") {
|
||||
t.Fatalf("Chunk() error = %q, want malformed boundary caveat context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkDefensivelyCopiesSourceUnitsAndMetadata(t *testing.T) {
|
||||
doc := sceneSourceDocument()
|
||||
client := &fakeScenesLLMClient{response: validSceneResponse()}
|
||||
|
||||
result, err := New().Chunk(context.Background(), contracts.ChunkRequest{
|
||||
Source: doc,
|
||||
SourceInput: sceneSourceInput(),
|
||||
SessionID: "session-123",
|
||||
LLMProfile: "profile-scenes",
|
||||
LLMClient: client,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Chunk() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
doc.Units[0].ID = 99
|
||||
doc.Units[0].Metadata["speaker"] = "mutated"
|
||||
client.response.Scenes[0].MainParticipants[0] = "mutated"
|
||||
|
||||
if result.Chunks[0].Units[0].ID != 1 {
|
||||
t.Fatalf("chunk unit ID changed after source mutation: %#v", result.Chunks[0].Units[0])
|
||||
}
|
||||
if result.Chunks[0].Units[0].Metadata["speaker"] != "Alice" {
|
||||
t.Fatalf("chunk unit metadata changed after source mutation: %#v", result.Chunks[0].Units[0].Metadata)
|
||||
}
|
||||
participants, ok := result.Chunks[0].Metadata["main_participants"].([]string)
|
||||
if !ok || participants[0] != "Aria" {
|
||||
t.Fatalf("participants = %#v, want defensive copy", result.Chunks[0].Metadata["main_participants"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkerManifestMetadataIncludesPromptAndSchemaProvenance(t *testing.T) {
|
||||
metadata := New().ManifestMetadata()
|
||||
|
||||
tests := map[string]string{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": ResponseSchemaVersion,
|
||||
"response_schema_key": string(ResponseSchemaKey),
|
||||
"response_schema_id": ResponseSchemaID,
|
||||
"response_schema_name": ResponseSchemaName,
|
||||
"response_schema_version": ResponseSchemaVersion,
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"prompt", "schema", "source", "text"} {
|
||||
if _, ok := metadata[forbidden]; ok {
|
||||
t.Fatalf("metadata includes raw %q field: %#v", forbidden, metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkRejectsInvalidRequests(t *testing.T) {
|
||||
validClient := &fakeScenesLLMClient{response: validSceneResponse()}
|
||||
validReq := chunkRequestWithClient(validClient)
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
invalidDoc := sceneSourceDocument()
|
||||
invalidDoc.Units[0].ID = 0
|
||||
emptyDoc := sceneSourceDocument()
|
||||
emptyDoc.Units = nil
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chunker *Chunker
|
||||
ctx context.Context
|
||||
req contracts.ChunkRequest
|
||||
want string
|
||||
}{
|
||||
{name: "nil chunker", chunker: nil, ctx: context.Background(), req: validReq, want: "chunker"},
|
||||
{name: "nil context", chunker: New(), ctx: nil, req: validReq, want: "context"},
|
||||
{name: "canceled context", chunker: New(), ctx: canceledCtx, req: validReq, want: "context"},
|
||||
{name: "nil source", chunker: New(), ctx: context.Background(), req: contracts.ChunkRequest{LLMClient: validClient}, want: "source"},
|
||||
{name: "empty source units", chunker: New(), ctx: context.Background(), req: contracts.ChunkRequest{Source: emptyDoc, LLMClient: validClient}, want: "units"},
|
||||
{name: "invalid source", chunker: New(), ctx: context.Background(), req: contracts.ChunkRequest{Source: invalidDoc, LLMClient: validClient}, want: "validate source document"},
|
||||
{name: "nil LLM client", chunker: New(), ctx: context.Background(), req: contracts.ChunkRequest{Source: sceneSourceDocument()}, want: "LLM client"},
|
||||
{name: "unsupported options", chunker: New(), ctx: context.Background(), req: requestWithOptions(validReq), want: "options"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tt.chunker.Chunk(tt.ctx, tt.req)
|
||||
if err == nil {
|
||||
t.Fatal("Chunk() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dnd scenes") || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Chunk() error = %q, want module context and %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response chunkResponse
|
||||
want string
|
||||
}{
|
||||
{name: "missing scenes", response: chunkResponse{}, want: "scenes"},
|
||||
{name: "empty scenes", response: chunkResponse{Scenes: []sceneResponse{}}, want: "scenes"},
|
||||
{
|
||||
name: "unknown boundary id",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
scene(1, 999),
|
||||
}),
|
||||
want: "was not found",
|
||||
},
|
||||
{
|
||||
name: "out of order boundaries",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
scene(3, 2),
|
||||
}),
|
||||
want: "appears after",
|
||||
},
|
||||
{
|
||||
name: "gap",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
scene(1, 1),
|
||||
scene(3, 4),
|
||||
}),
|
||||
want: "gap",
|
||||
},
|
||||
{
|
||||
name: "overlap",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
scene(1, 2),
|
||||
scene(2, 4),
|
||||
}),
|
||||
want: "overlap",
|
||||
},
|
||||
{
|
||||
name: "incomplete coverage",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
scene(1, 3),
|
||||
}),
|
||||
want: "final scene",
|
||||
},
|
||||
{
|
||||
name: "empty metadata field",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: " ",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria"},
|
||||
Summary: "Summary.",
|
||||
BoundaryNote: "Note.",
|
||||
BoundaryConfidence: "High",
|
||||
},
|
||||
}),
|
||||
want: "short_title",
|
||||
},
|
||||
{
|
||||
name: "empty participant",
|
||||
response: replaceScenes(validSceneResponse(), []sceneResponse{
|
||||
{
|
||||
StartUnitID: shared.UnitRefFromInt(1),
|
||||
EndUnitID: shared.UnitRefFromInt(4),
|
||||
ShortTitle: "Title",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria", " "},
|
||||
Summary: "Summary.",
|
||||
BoundaryNote: "Note.",
|
||||
BoundaryConfidence: "High",
|
||||
},
|
||||
}),
|
||||
want: "main_participants",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{response: tt.response}
|
||||
_, err := New().Chunk(context.Background(), chunkRequestWithClient(client))
|
||||
if err == nil {
|
||||
t.Fatal("Chunk() error = nil, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dnd scenes") || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Chunk() error = %q, want module context and %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkWrapsLLMClientError(t *testing.T) {
|
||||
client := &fakeScenesLLMClient{err: errors.New("provider unavailable")}
|
||||
|
||||
_, err := New().Chunk(context.Background(), chunkRequestWithClient(client))
|
||||
if err == nil {
|
||||
t.Fatal("Chunk() error = nil, want LLM error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dnd scenes") || !strings.Contains(err.Error(), "provider unavailable") {
|
||||
t.Fatalf("Chunk() error = %q, want wrapped LLM context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func chunkRequestWithClient(client contracts.StructuredLLMClient) contracts.ChunkRequest {
|
||||
return contracts.ChunkRequest{
|
||||
Source: sceneSourceDocument(),
|
||||
SourceInput: sceneSourceInput(),
|
||||
SessionID: "session-123",
|
||||
LLMProfile: "profile-scenes",
|
||||
LLMClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
const sceneTranscriptJSON = `{"id":"session-alpha","segments":[{"id":1,"text":"Aria asks whether the goblin will parley."}]}`
|
||||
|
||||
func sceneSourceInput() contracts.LLMInputMaterial {
|
||||
return contracts.NewLLMInputMaterial("source", "application/json", []byte(sceneTranscriptJSON), "sha256:transcript", "file:///session-alpha.json")
|
||||
}
|
||||
|
||||
func requestWithOptions(req contracts.ChunkRequest) contracts.ChunkRequest {
|
||||
req.Options = map[string]any{"max_units": 2}
|
||||
return req
|
||||
}
|
||||
|
||||
func sceneSourceDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{
|
||||
ID: "session-alpha",
|
||||
Kind: "transcript",
|
||||
Format: "application/vnd.seriatim.minimal+json",
|
||||
Digest: "sha256:source",
|
||||
Units: []source.SourceUnit{
|
||||
{ID: 1, Kind: "transcript_segment", Text: "Aria asks whether the goblin will parley.", Metadata: map[string]any{"speaker": "Alice"}},
|
||||
{ID: 2, Kind: "transcript_segment", Text: "The goblin scout describes the gate guards."},
|
||||
{ID: 3, Kind: "transcript_segment", Text: "The guards rush out with blades drawn."},
|
||||
{ID: 4, Kind: "transcript_segment", Text: "The party defeats the ambushers."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validSceneResponse() chunkResponse {
|
||||
return chunkResponse{
|
||||
Scenes: []sceneResponse{
|
||||
scene(1, 4),
|
||||
},
|
||||
BoundaryCaveats: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func replaceScenes(response chunkResponse, scenes []sceneResponse) chunkResponse {
|
||||
response.Scenes = scenes
|
||||
return response
|
||||
}
|
||||
|
||||
func scene(startUnitID int, endUnitID int) sceneResponse {
|
||||
return sceneResponse{
|
||||
StartUnitID: shared.UnitRefFromInt(startUnitID),
|
||||
EndUnitID: shared.UnitRefFromInt(endUnitID),
|
||||
ShortTitle: "Scene title",
|
||||
PrimaryMode: "Narrative",
|
||||
MainParticipants: []string{"Aria"},
|
||||
Summary: "A compact summary.",
|
||||
BoundaryNote: "The source units form one coherent scene.",
|
||||
BoundaryConfidence: "High",
|
||||
}
|
||||
}
|
||||
|
||||
func chunkIDs(chunks []contracts.SourceChunk) []string {
|
||||
ids := make([]string, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
ids = append(ids, chunk.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func unitIDs(units []source.SourceUnit) []int {
|
||||
ids := make([]int, 0, len(units))
|
||||
for _, unit := range units {
|
||||
ids = append(ids, unit.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type fakeScenesLLMClient struct {
|
||||
response chunkResponse
|
||||
err error
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (client *fakeScenesLLMClient) 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.(*chunkResponse)
|
||||
if !ok {
|
||||
return contracts.StructuredCompletionResponse{}, errors.New("unexpected output target")
|
||||
}
|
||||
*target = client.response
|
||||
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
|
||||
}
|
||||
30
internal/modules/dnd/chunk/scenes/model.go
Normal file
30
internal/modules/dnd/chunk/scenes/model.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package scenes
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
|
||||
type chunkResponse struct {
|
||||
Scenes []sceneResponse `json:"scenes"`
|
||||
BoundaryCaveats []string `json:"boundary_caveats"`
|
||||
}
|
||||
|
||||
type sceneResponse struct {
|
||||
StartUnitID shared.UnitRef `json:"start_unit_id"`
|
||||
EndUnitID shared.UnitRef `json:"end_unit_id"`
|
||||
ShortTitle string `json:"short_title"`
|
||||
PrimaryMode string `json:"primary_mode"`
|
||||
MainParticipants []string `json:"main_participants"`
|
||||
Summary string `json:"summary"`
|
||||
BoundaryNote string `json:"boundary_note"`
|
||||
BoundaryConfidence string `json:"boundary_confidence"`
|
||||
}
|
||||
|
||||
type normalizedScene struct {
|
||||
StartUnitID int
|
||||
EndUnitID int
|
||||
ShortTitle string
|
||||
PrimaryMode string
|
||||
MainParticipants []string
|
||||
Summary string
|
||||
BoundaryNote string
|
||||
BoundaryConfidence string
|
||||
}
|
||||
21
internal/modules/dnd/chunk/scenes/schema.go
Normal file
21
internal/modules/dnd/chunk/scenes/schema.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package scenes
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
|
||||
const (
|
||||
PromptID = "dnd.scenes"
|
||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_scenes")
|
||||
ResponseSchemaID = "notarius.dnd.scenes"
|
||||
ResponseSchemaVersion = "v1"
|
||||
ResponseSchemaName = "notarius_dnd_scenes_v1"
|
||||
)
|
||||
|
||||
func loadResponseSchema() (llm.ResponseSchema, error) {
|
||||
return llm.LoadResponseSchema(embeddedAssets, llm.ResponseSchemaDefinition{
|
||||
Key: ResponseSchemaKey,
|
||||
ID: ResponseSchemaID,
|
||||
Version: ResponseSchemaVersion,
|
||||
Name: ResponseSchemaName,
|
||||
AssetPath: "assets/schemas/dnd_scenes.v1.json",
|
||||
})
|
||||
}
|
||||
151
internal/modules/dnd/chunk/scenes/schema_test.go
Normal file
151
internal/modules/dnd/chunk/scenes/schema_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package scenes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadResponseSchemaForScenes(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 != ResponseSchemaVersion {
|
||||
t.Fatalf("schema.Version = %q, want %q", schema.Version, ResponseSchemaVersion)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaShapeUsesSourceUnitBoundaries(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatalf("loadResponseSchema() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(schema.JSONSchema, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v, want nil", err)
|
||||
}
|
||||
if decoded["$id"] != ResponseSchemaID {
|
||||
t.Fatalf("$id = %#v, want %q", decoded["$id"], ResponseSchemaID)
|
||||
}
|
||||
if decoded["additionalProperties"] != false {
|
||||
t.Fatalf("additionalProperties = %#v, want false", decoded["additionalProperties"])
|
||||
}
|
||||
|
||||
properties := decoded["properties"].(map[string]any)
|
||||
if _, ok := properties["artifact_type"]; ok {
|
||||
t.Fatal("schema includes artifact_type, want only scene response fields")
|
||||
}
|
||||
if _, ok := properties["session_scope"]; ok {
|
||||
t.Fatal("schema includes session_scope, want no session wrapper")
|
||||
}
|
||||
|
||||
sceneProperties := properties["scenes"].(map[string]any)["items"].(map[string]any)["properties"].(map[string]any)
|
||||
for _, field := range []string{"scene_id", "start_segment_id", "end_segment_id"} {
|
||||
if _, ok := sceneProperties[field]; ok {
|
||||
t.Fatalf("scene schema includes old field %q", field)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"start_unit_id", "end_unit_id"} {
|
||||
property := sceneProperties[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"])
|
||||
}
|
||||
}
|
||||
|
||||
modeEnum := sceneProperties["primary_mode"].(map[string]any)["enum"].([]any)
|
||||
if !sameStrings(modeEnum, []string{"Recap", "Discussion", "Combat", "Narrative"}) {
|
||||
t.Fatalf("primary_mode enum = %#v, want Recap/Discussion/Combat/Narrative", modeEnum)
|
||||
}
|
||||
confidenceEnum := sceneProperties["boundary_confidence"].(map[string]any)["enum"].([]any)
|
||||
if !sameStrings(confidenceEnum, []string{"High", "Medium", "Low"}) {
|
||||
t.Fatalf("boundary_confidence enum = %#v, want High/Medium/Low", confidenceEnum)
|
||||
}
|
||||
|
||||
boundaryCaveatItems := decoded["properties"].(map[string]any)["boundary_caveats"].(map[string]any)["items"].(map[string]any)
|
||||
if boundaryCaveatItems["type"] != "string" {
|
||||
t.Fatalf("boundary_caveats.items.type = %#v, want string", boundaryCaveatItems["type"])
|
||||
}
|
||||
if boundaryCaveatItems["minLength"] != float64(1) {
|
||||
t.Fatalf("boundary_caveats.items.minLength = %#v, want 1", boundaryCaveatItems["minLength"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseStructAcceptsIntegerBoundaries(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"scenes": [
|
||||
{
|
||||
"start_unit_id": 1,
|
||||
"end_unit_id": 3,
|
||||
"short_title": "Ambush",
|
||||
"primary_mode": "Combat",
|
||||
"main_participants": ["Aria"],
|
||||
"summary": "The party fights.",
|
||||
"boundary_note": "Combat starts and resolves.",
|
||||
"boundary_confidence": "High"
|
||||
}
|
||||
],
|
||||
"boundary_caveats": []
|
||||
}`)
|
||||
|
||||
var response chunkResponse
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v, want nil", err)
|
||||
}
|
||||
if got := response.Scenes[0].StartUnitID.String(); got != "1" {
|
||||
t.Fatalf("StartUnitID = %q, want 1", got)
|
||||
}
|
||||
if got := response.Scenes[0].EndUnitID.String(); got != "3" {
|
||||
t.Fatalf("EndUnitID = %q, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
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 sameStrings(got []any, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
45
internal/modules/dnd/chunk/scenes/scriptorium_assets.go
Normal file
45
internal/modules/dnd/chunk/scenes/scriptorium_assets.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package scenes
|
||||
|
||||
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.scenes", embeddedAssets, []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare scene 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.scenes.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
|
||||
)
|
||||
129
internal/modules/dnd/chunk/scenes/scriptorium_assets_test.go
Normal file
129
internal/modules/dnd/chunk/scenes/scriptorium_assets_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package scenes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/scriptorium"
|
||||
)
|
||||
|
||||
func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) {
|
||||
transcript := []byte(`{"id":"session-1","segments":[{"id":"u1","text":"We enter the crypt."}]}`)
|
||||
prepared := prepareScenesPrompt(t, transcript, "Alice: Aria", "Aria: cleric", "Brightmantle: temple")
|
||||
|
||||
if prepared.PromptID != PromptID {
|
||||
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
|
||||
}
|
||||
if got := len(prepared.Messages); got != 5 {
|
||||
t.Fatalf("message count = %d, want 5", got)
|
||||
}
|
||||
if prepared.Messages[1].Role != "user" || prepared.Messages[1].CacheControl == nil {
|
||||
t.Fatalf("transcript message did not render as cacheable user message: %#v", prepared.Messages[1])
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[1].Content, string(transcript)) {
|
||||
t.Fatalf("transcript message did not include source input")
|
||||
}
|
||||
if prepared.Messages[2].CacheControl == nil {
|
||||
t.Fatalf("reference message did not render as cacheable user message: %#v", prepared.Messages[2])
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Alice: Aria") {
|
||||
t.Fatalf("reference message missing player content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Aria: cleric") {
|
||||
t.Fatalf("reference message missing party content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Brightmantle: temple") {
|
||||
t.Fatalf("reference message missing glossary content")
|
||||
}
|
||||
if strings.Contains(prepared.Messages[3].Content, string(transcript)) {
|
||||
t.Fatalf("task message leaked transcript bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
||||
transcript := []byte(`{"secret":"source text"}`)
|
||||
prepared := prepareScenesPrompt(t, transcript, "private player note", "private party note", "private glossary note")
|
||||
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",
|
||||
"private party note",
|
||||
"private glossary note",
|
||||
`"properties"`,
|
||||
"start_unit_id",
|
||||
} {
|
||||
if strings.Contains(diagnostics, forbidden) {
|
||||
t.Fatalf("diagnostics leaked %q: %s", forbidden, diagnostics)
|
||||
}
|
||||
}
|
||||
if metadata["prompt_id"] != PromptID || metadata["prompt_version"] != ResponseSchemaVersion {
|
||||
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 prepareScenesPrompt(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 scene prompt assets: %v", err)
|
||||
}
|
||||
engine := newScenesScriptoriumEngine(t, registry)
|
||||
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
|
||||
PromptID: PromptID,
|
||||
PromptVersion: ResponseSchemaVersion,
|
||||
ProfileID: "scene-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
|
||||
}
|
||||
|
||||
func newScenesScriptoriumEngine(t *testing.T, registry *llm.AssetRegistry) *scriptorium.Engine {
|
||||
t.Helper()
|
||||
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: "scene-test-profile",
|
||||
Endpoint: "http://127.0.0.1:1/v1",
|
||||
Model: "scene-test-model",
|
||||
})))
|
||||
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine() error = %v, want nil", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
Reference in New Issue
Block a user