Add D&D scene chunking assets
This commit is contained in:
6
internal/modules/chunk/dnd/scenes/assets.go
Normal file
6
internal/modules/chunk/dnd/scenes/assets.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package scenes
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed assets/prompts/*.md assets/schemas/*.json
|
||||
var embeddedAssets embed.FS
|
||||
@@ -1,5 +1,9 @@
|
||||
You are an expert in Dungeons & Dragons gameplay, writing, and analysis. You will be provided with an audio transcript of a D&D gameplay session. Then, the user will ask you to perform a task based upon the transcript.
|
||||
You identify coherent scenes in Dungeons & Dragons session source units.
|
||||
|
||||
Note that the transcript may contain transcription errors, repeated lines, incomplete sentences, and occasional misheard proper nouns. You may assume that speaker identity is accurate.
|
||||
{{ hardening }}
|
||||
|
||||
Pay careful attention to the task assigned to you by the user, and follow the provided instructions precisely.
|
||||
Use only the provided source units. Source text may contain transcription
|
||||
errors, repeated lines, incomplete sentences, and misheard proper nouns. Speaker
|
||||
metadata, when present, may be treated as accurate.
|
||||
|
||||
Return only valid JSON matching the provided response schema.
|
||||
|
||||
@@ -1,85 +1,71 @@
|
||||
TASK: Generate a structured JSON scene map for the provided D&D session transcript.
|
||||
Source document ID: {{ .SourceID }}
|
||||
|
||||
This is an intermediate artifact for an automated analysis pipeline. Your task is to divide the transcript into sequential, contiguous, non-overlapping scenes.
|
||||
Ordered source units:
|
||||
{{ range .Units }}
|
||||
- Unit ID: {{ .ID }}
|
||||
Text: {{ .Text }}
|
||||
{{ if .Metadata }}
|
||||
Metadata:
|
||||
{{ range .Metadata }}
|
||||
- {{ .Key }}: {{ .Value }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
Return only valid JSON matching the provided JSON Schema.
|
||||
Divide these source units into D&D scenes for the dnd/scenes chunk module.
|
||||
|
||||
Next, you will be provided with detailed instructions with respect to preparation of the strucured JSON scene map.
|
||||
|
||||
INSTRUCTIONS: Your task is to divide the provided transcript into sequential, contiguous, non-overlapping scenes.
|
||||
|
||||
A scene is a coherent unit of play. A new scene should usually begin when there is a meaningful change in location, objective, threat, activity, encounter, or mode of play.
|
||||
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.
|
||||
|
||||
Good reasons to start a new scene include:
|
||||
- the party moves to a new location
|
||||
- a combat encounter begins or ends
|
||||
- the combat changes into a substantially different phase
|
||||
- the party shifts from combat to exploration, social interaction, planning, travel, or rest
|
||||
- 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
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
Scene boundary requirements:
|
||||
|
||||
1. Cover the transcript from the first relevant segment to the last relevant segment.
|
||||
2. Do not leave gaps between scenes.
|
||||
3. Do not overlap scenes.
|
||||
4. Preserve transcript order.
|
||||
5. Use exact segment IDs from the transcript.
|
||||
6. Each scene must have a start_segment_id and end_segment_id.
|
||||
7. Scene IDs must be sequential: 1, 2, 3, and so on.
|
||||
8. Prefer coherent scenes over excessive fragmentation.
|
||||
9. Do not merge unrelated scenes just to keep the scene count low.
|
||||
10. If a boundary is uncertain, choose the most practical boundary and explain the uncertainty in boundary_note.
|
||||
dnd/scenes boundary policy:
|
||||
- cover the full provided source document 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 exact source-unit IDs from the ordered source units;
|
||||
- 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 use one of the allowed schema values.
|
||||
- main_participants should include only the 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 and ends at those segment IDs.
|
||||
- boundary_confidence should be High, Medium, or Low.
|
||||
- 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 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 types of non-combat gameplay, including exploration, social interactions, shopping, preparing for battle, downtime, and so on.
|
||||
- Use Narrative for all other non-combat gameplay, including exploration, social
|
||||
interactions, shopping, preparation, travel, rest, and downtime.
|
||||
|
||||
Session scope requirements:
|
||||
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.
|
||||
|
||||
- In session_scope.source_description, briefly describe the transcript reviewed.
|
||||
- In session_scope.first_segment_id, provide the first segment ID covered by the scene map.
|
||||
- In session_scope.last_segment_id, provide the last segment ID covered by the scene map.
|
||||
|
||||
Boundary caveats:
|
||||
|
||||
In boundary_caveats, list any overall caveats about the 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.
|
||||
|
||||
Important restrictions:
|
||||
|
||||
- Do not invent missing events.
|
||||
- Do not include comments.
|
||||
- Do not include explanatory text outside the JSON object.
|
||||
- Do not wrap the JSON in a code block.
|
||||
- Return exactly one JSON object.
|
||||
|
||||
Before finalizing, check:
|
||||
|
||||
- The first scene starts at the first covered transcript segment.
|
||||
- The final scene ends at the last covered transcript segment.
|
||||
- Scene ranges are sequential and non-overlapping.
|
||||
- Every scene has a start_segment_id and end_segment_id.
|
||||
- Every summary is factual and compact.
|
||||
- Boundary uncertainty is noted rather than hidden.
|
||||
- The output is valid JSON and conforms to the schema.
|
||||
Return exactly one JSON object and no explanatory text.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"$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": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"end_unit_id": {
|
||||
"type": "string",
|
||||
"minLength": 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "D&D Session Scene Map",
|
||||
"description": "Structured scene segmentation for a D&D session transcript.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"artifact_type",
|
||||
"session_scope",
|
||||
"scenes",
|
||||
"boundary_caveats"
|
||||
],
|
||||
"properties": {
|
||||
"artifact_type": {
|
||||
"type": "string",
|
||||
"const": "current_session_scene_map"
|
||||
},
|
||||
"session_scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"source_description",
|
||||
"first_segment_id",
|
||||
"last_segment_id"
|
||||
],
|
||||
"properties": {
|
||||
"source_description": {
|
||||
"type": "string",
|
||||
"description": "Brief description of the transcript or source material reviewed."
|
||||
},
|
||||
"first_segment_id": {
|
||||
"type": "integer",
|
||||
"description": "The first transcript segment covered by the scene map."
|
||||
},
|
||||
"last_segment_id": {
|
||||
"type": "integer",
|
||||
"description": "The final transcript segment covered by the scene map."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"description": "Sequential, contiguous, non-overlapping scene divisions covering the transcript.",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"scene_id",
|
||||
"start_segment_id",
|
||||
"end_segment_id",
|
||||
"short_title",
|
||||
"primary_mode",
|
||||
"main_participants",
|
||||
"summary",
|
||||
"boundary_note",
|
||||
"boundary_confidence"
|
||||
],
|
||||
"properties": {
|
||||
"scene_id": {
|
||||
"type": "string",
|
||||
"description": "Stable sequential scene identifier.",
|
||||
"pattern": "[0-9]$"
|
||||
},
|
||||
"start_segment_id": {
|
||||
"type": "integer",
|
||||
"description": "Exact segment ID where this scene begins."
|
||||
},
|
||||
"end_segment_id": {
|
||||
"type": "integer",
|
||||
"description": "Exact segment ID where this scene ends."
|
||||
},
|
||||
"short_title": {
|
||||
"type": "string",
|
||||
"description": "Compact descriptive title for the scene."
|
||||
},
|
||||
"primary_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Recap",
|
||||
"Planning",
|
||||
"Combat",
|
||||
"Narrative"
|
||||
],
|
||||
"description": "The dominant mode of play in the scene."
|
||||
},
|
||||
"main_participants": {
|
||||
"type": "array",
|
||||
"description": "Principal player characters, NPCs, factions, or groups involved in the scene.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "One- to three-sentence factual summary of what happened in the scene."
|
||||
},
|
||||
"boundary_note": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why the scene starts and ends at these segment boundaries."
|
||||
},
|
||||
"boundary_confidence": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"High",
|
||||
"Medium",
|
||||
"Low"
|
||||
],
|
||||
"description": "Confidence that the chosen scene boundaries are natural and useful."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"boundary_caveats": {
|
||||
"type": "array",
|
||||
"description": "Overall caveats about scene segmentation, ambiguous transitions, map-dependent context, or alternative reasonable divisions.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
internal/modules/chunk/dnd/scenes/model.go
Normal file
17
internal/modules/chunk/dnd/scenes/model.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package scenes
|
||||
|
||||
type chunkResponse struct {
|
||||
Scenes []sceneResponse `json:"scenes"`
|
||||
BoundaryCaveats []string `json:"boundary_caveats"`
|
||||
}
|
||||
|
||||
type sceneResponse struct {
|
||||
StartUnitID string `json:"start_unit_id"`
|
||||
EndUnitID string `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"`
|
||||
}
|
||||
97
internal/modules/chunk/dnd/scenes/prompt.go
Normal file
97
internal/modules/chunk/dnd/scenes/prompt.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package scenes
|
||||
|
||||
import (
|
||||
"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/prompt"
|
||||
)
|
||||
|
||||
type promptData struct {
|
||||
SourceID string
|
||||
Units []promptUnit
|
||||
}
|
||||
|
||||
type promptUnit struct {
|
||||
ID string
|
||||
Text string
|
||||
Metadata []promptMetadata
|
||||
}
|
||||
|
||||
type promptMetadata struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
var scenesPromptBundle = mustLoadPromptBundle()
|
||||
|
||||
func mustLoadPromptBundle() *prompt.Bundle {
|
||||
bundle, err := prompt.LoadBundle(embeddedAssets, prompt.Definition{
|
||||
PromptID: PromptID,
|
||||
Version: ResponseSchemaVersion,
|
||||
EmbeddedPath: "assets/prompts",
|
||||
SystemPath: "assets/prompts/system.md",
|
||||
UserPath: "assets/prompts/user.md",
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
|
||||
func buildPromptData(req contracts.ChunkRequest) (promptData, error) {
|
||||
if req.Source == nil {
|
||||
return promptData{}, fmt.Errorf("dnd scenes prompt: source must not be nil")
|
||||
}
|
||||
|
||||
data := promptData{
|
||||
SourceID: req.Source.ID,
|
||||
Units: make([]promptUnit, 0, len(req.Source.Units)),
|
||||
}
|
||||
for _, unit := range req.Source.Units {
|
||||
data.Units = append(data.Units, promptUnit{
|
||||
ID: unit.ID,
|
||||
Text: unit.Text,
|
||||
Metadata: selectedMetadata(unit),
|
||||
})
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func renderPrompt(req contracts.ChunkRequest) (system string, user string, metadata prompt.Metadata, err error) {
|
||||
data, err := buildPromptData(req)
|
||||
if err != nil {
|
||||
return "", "", prompt.Metadata{}, err
|
||||
}
|
||||
system, user, metadata, err = scenesPromptBundle.RenderUserSystem(data)
|
||||
if err != nil {
|
||||
return "", "", prompt.Metadata{}, fmt.Errorf("dnd scenes prompt: %w", err)
|
||||
}
|
||||
return system, user, metadata, nil
|
||||
}
|
||||
|
||||
func selectedMetadata(unit source.SourceUnit) []promptMetadata {
|
||||
if len(unit.Metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := []string{"speaker", "start", "end"}
|
||||
metadata := make([]promptMetadata, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
value, ok := unit.Metadata[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rendered := strings.TrimSpace(fmt.Sprint(value))
|
||||
if rendered == "" {
|
||||
continue
|
||||
}
|
||||
metadata = append(metadata, promptMetadata{
|
||||
Key: key,
|
||||
Value: rendered,
|
||||
})
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
155
internal/modules/chunk/dnd/scenes/prompt_test.go
Normal file
155
internal/modules/chunk/dnd/scenes/prompt_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package scenes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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/prompt"
|
||||
)
|
||||
|
||||
func TestBuildPromptDataFromSourceDocument(t *testing.T) {
|
||||
req := promptChunkRequest()
|
||||
|
||||
data, err := buildPromptData(req)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPromptData() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if data.SourceID != "session-alpha" {
|
||||
t.Fatalf("SourceID = %q, want session-alpha", data.SourceID)
|
||||
}
|
||||
if len(data.Units) != 2 {
|
||||
t.Fatalf("len(Units) = %d, want 2", len(data.Units))
|
||||
}
|
||||
first := data.Units[0]
|
||||
if first.ID != "seg-001" || first.Text != "Aria and Bram discuss whether to enter the ruins." {
|
||||
t.Fatalf("first unit = %#v, want source unit data", first)
|
||||
}
|
||||
wantMetadata := []promptMetadata{
|
||||
{Key: "speaker", Value: "Alice"},
|
||||
{Key: "start", Value: "1.25"},
|
||||
{Key: "end", Value: "3.5"},
|
||||
}
|
||||
if !reflect.DeepEqual(first.Metadata, wantMetadata) {
|
||||
t.Fatalf("first.Metadata = %#v, want %#v", first.Metadata, wantMetadata)
|
||||
}
|
||||
if len(data.Units[1].Metadata) != 0 {
|
||||
t.Fatalf("second.Metadata = %#v, want no selected metadata", data.Units[1].Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptDataDoesNotMutateRequest(t *testing.T) {
|
||||
req := promptChunkRequest()
|
||||
beforeSource := mustJSON(t, req.Source)
|
||||
beforeRequest := mustJSON(t, req)
|
||||
|
||||
if _, err := buildPromptData(req); err != nil {
|
||||
t.Fatalf("buildPromptData() error = %v, want nil", err)
|
||||
}
|
||||
afterSource := mustJSON(t, req.Source)
|
||||
afterRequest := mustJSON(t, req)
|
||||
if beforeSource != afterSource || beforeRequest != afterRequest {
|
||||
t.Fatalf(
|
||||
"request mutated:\nsource before: %s\nsource after: %s\nrequest before: %s\nrequest after: %s",
|
||||
beforeSource,
|
||||
afterSource,
|
||||
beforeRequest,
|
||||
afterRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPromptIncludesSourceUnitsAndMetadata(t *testing.T) {
|
||||
system, user, metadata, err := renderPrompt(promptChunkRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("renderPrompt() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(system, prompt.HardeningText()) {
|
||||
t.Fatalf("system prompt = %q, want hardening text", system)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"session-alpha",
|
||||
"seg-001",
|
||||
"seg-002",
|
||||
"Aria and Bram discuss whether to enter the ruins.",
|
||||
"The goblins rush out and initiative begins.",
|
||||
"speaker: Alice",
|
||||
"start: 1.25",
|
||||
"end: 3.5",
|
||||
"start_unit_id",
|
||||
"end_unit_id",
|
||||
"primary_mode",
|
||||
"boundary_confidence",
|
||||
"Recap, Discussion, Combat, or Narrative",
|
||||
"High, Medium, or Low",
|
||||
"no gaps",
|
||||
"do not overlap",
|
||||
} {
|
||||
if !strings.Contains(user, want) {
|
||||
t.Fatalf("user prompt = %q, want substring %q", user, want)
|
||||
}
|
||||
}
|
||||
if metadata.PromptID != PromptID {
|
||||
t.Fatalf("metadata.PromptID = %q, want %q", metadata.PromptID, PromptID)
|
||||
}
|
||||
if metadata.PromptVersion != ResponseSchemaVersion {
|
||||
t.Fatalf("metadata.PromptVersion = %q, want %q", metadata.PromptVersion, ResponseSchemaVersion)
|
||||
}
|
||||
if metadata.EmbeddedPath != "assets/prompts" {
|
||||
t.Fatalf("metadata.EmbeddedPath = %q, want assets/prompts", metadata.EmbeddedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptDataRejectsMissingSourceContext(t *testing.T) {
|
||||
if _, err := buildPromptData(contracts.ChunkRequest{}); err == nil || !strings.Contains(err.Error(), "source") {
|
||||
t.Fatalf("buildPromptData() error = %v, want source error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func promptChunkRequest() contracts.ChunkRequest {
|
||||
return contracts.ChunkRequest{
|
||||
Source: promptSourceDocument(),
|
||||
}
|
||||
}
|
||||
|
||||
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: "seg-001",
|
||||
Kind: "transcript_segment",
|
||||
Text: "Aria and Bram discuss whether to enter the ruins.",
|
||||
Metadata: map[string]any{
|
||||
"speaker": "Alice",
|
||||
"start": json.Number("1.25"),
|
||||
"end": json.Number("3.5"),
|
||||
"ignored": "not rendered",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "seg-002",
|
||||
Kind: "transcript_segment",
|
||||
Text: "The goblins rush out and initiative begins.",
|
||||
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)
|
||||
}
|
||||
21
internal/modules/chunk/dnd/scenes/schema.go
Normal file
21
internal/modules/chunk/dnd/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",
|
||||
})
|
||||
}
|
||||
138
internal/modules/chunk/dnd/scenes/schema_test.go
Normal file
138
internal/modules/chunk/dnd/scenes/schema_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
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"] != "string" {
|
||||
t.Fatalf("%s type = %#v, want string", field, property["type"])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseStructRejectsIntegerBoundaries(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
|
||||
err := json.Unmarshal(raw, &response)
|
||||
if err == nil {
|
||||
t.Fatalf("Unmarshal() error = nil, want integer boundary type error: %#v", response)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "string") {
|
||||
t.Fatalf("Unmarshal() error = %v, want string type error", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user