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

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