Harden D&D scene description extraction
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"properties": {
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user