Harden D&D scene description extraction

This commit is contained in:
2026-07-23 23:03:37 +00:00
parent 9614eb540d
commit e1c2f3c202
8 changed files with 118 additions and 763 deletions

View File

@@ -7,6 +7,7 @@
"properties": {
"scenes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,

View File

@@ -97,6 +97,9 @@ func validate(value dnd.SceneDescriptionList) error {
if value.Scenes == nil {
return fmt.Errorf("scenes must be present")
}
if len(value.Scenes) == 0 {
return fmt.Errorf("scenes must not be empty")
}
for index, scene := range value.Scenes {
prefix := fmt.Sprintf("scenes[%d]", index)
if strings.TrimSpace(scene.ID) == "" {

View File

@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
@@ -86,6 +88,40 @@ func TestCodecOwnsDurableSchemaAndMetadata(t *testing.T) {
}
}
func TestDurableSchemaRequiresAtLeastOneScene(t *testing.T) {
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(New().Schema().JSONSchema))
if err != nil {
t.Fatal(err)
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", schemaDocument); err != nil {
t.Fatal(err)
}
compiled, err := compiler.Compile("schema.json")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
content []byte
valid bool
}{
{name: "one scene", content: mustEncode(t, validList()), valid: true},
{name: "empty scenes", content: []byte(`{"scenes":[]}`)},
} {
t.Run(test.name, func(t *testing.T) {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(test.content))
if err != nil {
t.Fatal(err)
}
err = compiled.Validate(instance)
if (err == nil) != test.valid {
t.Fatalf("Validate() error = %v, want valid=%t", err, test.valid)
}
})
}
}
func TestCodecStrictlyRejectsInvalidDurableBoundaries(t *testing.T) {
validJSON := `{"scenes":[{"id":"chunk-000001","source_ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1},"kind":"narrative","title":"Arrival","summary":"The party arrives."}]}`
tests := []struct{ name, raw, want string }{
@@ -95,6 +131,7 @@ func TestCodecStrictlyRejectsInvalidDurableBoundaries(t *testing.T) {
{"unknown source field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
{"trailing", `{"scenes":[]} {}`, "multiple JSON values"},
{"missing scenes", `{}`, "scenes must be present"},
{"empty scenes", `{"scenes":[]}`, "scenes must not be empty"},
{"invalid kind", strings.Replace(validJSON, `"kind":"narrative"`, `"kind":"other"`, 1), "kind must be supported"},
{"blank title", strings.Replace(validJSON, `"title":"Arrival"`, `"title":" "`, 1), "title must not be empty"},
{"blank summary", strings.Replace(validJSON, `"summary":"The party arrives."`, `"summary":" "`, 1), "summary must not be empty"},
@@ -138,6 +175,7 @@ func TestCodecRejectsRequiredApprovedValues(t *testing.T) {
want string
}{
{"nil scenes", dnd.SceneDescriptionList{}, "scenes must be present"},
{"empty scenes", dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}, "scenes must not be empty"},
{"empty ID", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].ID = " " }), "id must not be empty"},
{"unsupported kind", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].Kind = "unsupported" }), "kind must be supported"},
{"empty title", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].Title = " " }), "title must not be empty"},
@@ -155,6 +193,15 @@ func TestCodecRejectsRequiredApprovedValues(t *testing.T) {
}
}
func mustEncode(t *testing.T, value dnd.SceneDescriptionList) []byte {
t.Helper()
content, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return content
}
func mutate(value dnd.SceneDescriptionList, change func(*dnd.SceneDescriptionList)) dnd.SceneDescriptionList {
change(&value)
return value

View File

@@ -1,9 +1,44 @@
Choose exactly one kind: combat, narrative, recap, or meta. When a chunk is
mixed, use this precedence: combat, then recap, then meta, then narrative.
Choose exactly one kind:
The title should identify the central event or subject. The summary should
describe only what the accepted chunk establishes. Campaign references may
disambiguate names but never add events or lore.
- combat: active combat materially organizes the scene, including
initiative-like exchanges or sustained hostile action. Planning a fight or
discussing a completed fight is not combat by itself.
- narrative: current-session in-world play that is not principally active
combat, a prior-session recap, or sustained out-of-character session
discussion. This includes exploration, travel, dialogue, investigation,
in-character planning, and aftermath.
- recap: the scene's organizing purpose is to recount events from a previous
session for the table. An in-world character recounting history during
current play remains narrative.
- meta: the scene's organizing purpose is sustained out-of-character
discussion about the game or session rather than advancing current in-world
play.
Narrative is the default for actual current-session gameplay that does not meet
another definition. When the accepted chunk is mixed:
1. use combat when active combat is a substantive central activity, even with
brief setup, rules clarification, or immediate aftermath;
2. otherwise use recap when recounting a previous session is the chunk's
primary table purpose;
3. otherwise use meta when sustained out-of-character session discussion is
primary and in-world progression is no more than incidental; and
4. use narrative for all remaining current-session in-world play.
Brief table talk, dice resolution, rules clarification, jokes, or
administrative comments do not make a gameplay scene meta. A short recollection
used to orient current action does not make a scene recap.
The title must be a short, distinguishing phrase rather than a sentence,
chapter number, or generic label such as "Scene." It may use names and places
established by the transcript or disambiguated by campaign references, but it
must not invent a proper noun.
The summary must briefly state the main activity and material transition or
outcome established within the accepted chunk. Do not add analysis, inferred
motives, hidden state, future consequences, relationship claims, or facts from
outside the chunk. Campaign references may disambiguate names but never add
events or lore.
Do not return identifiers, source identifiers, source ranges, unit identifiers,
participants, confidence, or any fields besides kind, title, and summary.

View File

@@ -78,6 +78,10 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must not be empty")
}
unitPositions := make(map[int]int, len(doc.Units))
for index, unit := range doc.Units {
unitPositions[unit.ID] = index
}
output := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, len(input.Scenes))}
for index, scene := range input.Scenes {
scene.Title = strings.TrimSpace(scene.Title)
@@ -92,8 +96,8 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
}
sort.SliceStable(output.Scenes, func(left, right int) bool {
leftStart, _ := source.UnitIndex(doc, output.Scenes[left].SourceRef.StartUnitID)
rightStart, _ := source.UnitIndex(doc, output.Scenes[right].SourceRef.StartUnitID)
leftStart := unitPositions[output.Scenes[left].SourceRef.StartUnitID]
rightStart := unitPositions[output.Scenes[right].SourceRef.StartUnitID]
if leftStart != rightStart {
return leftStart < rightStart
}
@@ -103,6 +107,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
unique := make([]dnd.SceneDescription, 0, len(output.Scenes))
byID := make(map[string]dnd.SceneDescription, len(output.Scenes))
byRange := make(map[source.SourceRef]dnd.SceneDescription, len(output.Scenes))
seen := make(map[dnd.SceneDescription]struct{}, len(output.Scenes))
for _, scene := range output.Scenes {
if previous, ok := byID[scene.ID]; ok && !identical(previous, scene) {
return dnd.SceneDescriptionList{}, fmt.Errorf("scene ID %s has conflicting records", diagnostics.Quote(scene.ID))
@@ -110,11 +115,12 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
if previous, ok := byRange[scene.SourceRef]; ok && !sameModelContent(previous, scene) {
return dnd.SceneDescriptionList{}, fmt.Errorf("source range %s has conflicting records", sourceRefLabel(scene.SourceRef))
}
if containsIdentical(unique, scene) {
if _, ok := seen[scene]; ok {
continue
}
byID[scene.ID] = scene
byRange[scene.SourceRef] = scene
seen[scene] = struct{}{}
unique = append(unique, scene)
}
output.Scenes = unique
@@ -129,15 +135,6 @@ func sameModelContent(left, right dnd.SceneDescription) bool {
return left.Kind == right.Kind && left.Title == right.Title && left.Summary == right.Summary
}
func containsIdentical(scenes []dnd.SceneDescription, target dnd.SceneDescription) bool {
for _, scene := range scenes {
if identical(scene, target) {
return true
}
}
return false
}
func sourceRefLabel(ref source.SourceRef) string {
return fmt.Sprintf("%s:%d-%d", diagnostics.Quote(ref.SourceID), ref.StartUnitID, ref.EndUnitID)
}

View File

@@ -65,6 +65,24 @@ func TestNormalizeRejectsInvalidCandidatesAndConflicts(t *testing.T) {
}
}
func TestNormalizeUsesDocumentOrderRatherThanNumericUnitID(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 20, Text: "The party arrives."},
{ID: 10, Text: "The party departs."},
}}
input := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{
scene("later", 10, 10, dnd.SceneKindNarrative, "Departure", "The party departs."),
scene("first", 20, 20, dnd.SceneKindNarrative, "Arrival", "The party arrives."),
}}
result, err := New(Options{}).Normalize(context.Background(), normalizeRequest(input, doc))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
if got, want := []string{result.Value.Scenes[0].ID, result.Value.Scenes[1].ID}, []string{"first", "later"}; !reflect.DeepEqual(got, want) {
t.Fatalf("scene order = %#v, want %#v", got, want)
}
}
func TestNormalizerContractAndCancellation(t *testing.T) {
if _, err := DecodeOptions(nil); err != nil {
t.Fatalf("DecodeOptions(nil) error = %v", err)