Share D&D cited source traversal

This commit is contained in:
2026-07-21 14:37:12 +00:00
parent 732b13669f
commit 07460341e3
11 changed files with 322 additions and 129 deletions

View File

@@ -46,7 +46,7 @@ that agreement. Runtime delivery uses the corresponding stage request's
LLM-backed extensions own their prompt definitions and response schemas under LLM-backed extensions own their prompt definitions and response schemas under
package-local embedded assets. Shared filesystem composition belongs in package-local embedded assets. Shared filesystem composition belongs in
`internal/framework/promptfs`; reusable D&D prompt fragments, reference `internal/framework/promptfs`; reusable D&D prompt fragments, reference
declarations, prompt-input assembly, and source-unit helpers belong in declarations, prompt-input assembly, and source-unit/citation helpers belong in
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The `internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The
spell, NPC, and combat-turn extractors use ordered package-local prompt spell, NPC, and combat-turn extractors use ordered package-local prompt
manifests for both rendering and prompt fingerprinting, so only the shared manifests for both rendering and prompt fingerprinting, so only the shared
@@ -359,8 +359,11 @@ against the immutable effective SRD and overlay catalog. It accepts normalized
canonical names and aliases without rewriting the artifact; unknown names canonical names and aliases without rewriting the artifact; unknown names
reject the complete result with bounded, stable index/name diagnostics. The reject the complete result with bounded, stable index/name diagnostics. The
source-reference validator applies generic source-reference validation to every source-reference validator applies generic source-reference validation to every
cited range. The relatedness validator warns when a case-insensitive spell name cited range. The relatedness validator resolves all cited ranges through the
is absent from all cited source text. shared document-order traversal, then warns when a case-insensitive spell name
is absent from the cited source text. Invalid shape or cited ranges produce no
relatedness warnings; the shape and source-reference validators own those
defects.
These validators are deterministic. Their selectable keys and production order These validators are deterministic. Their selectable keys and production order
are defined in are defined in
@@ -372,21 +375,24 @@ payload rules are defined in the
NPC shape validation checks required strings, arrays, and source-reference NPC shape validation checks required strings, arrays, and source-reference
shape. The source-reference validator checks current-document identity, unit shape. The source-reference validator checks current-document identity, unit
existence, and range ordering; source relatedness emits at most one bounded existence, and range ordering; source relatedness uses the shared document-order
warning per record when neither the canonical name nor an alias occurs near traversal and normalized consecutive-token matching, emitting at most one
its cited text. Normalize identity validation checks deterministic IDs, bounded warning per record when neither the canonical name nor an alias occurs
canonical names, aliases, and cross-record ownership or canonical collisions. near its cited text. Invalid shape or cited ranges produce no relatedness
All are deterministic and expose the policy fingerprints used by the warnings. Normalize identity validation checks deterministic IDs, canonical
production chains. names, aliases, and cross-record ownership or canonical collisions. All are
deterministic and expose the policy fingerprints used by the production chains.
## D&D Combat Validators ## D&D Combat Validators
Combat shape validation owns required arrays, strings, nullable values, positive Combat shape validation owns required arrays, strings, nullable values, positive
rounds, and supported enums. Combat source-reference validation defers invalid rounds, and supported enums. Combat source-reference validation defers invalid
shape and checks source identity, unit existence, and range order. Combat shape and checks source identity, unit existence, and range order. Combat
source-relatedness defers invalid shape or ranges, combines overlapping cited source-relatedness defers invalid shape or ranges, uses the shared traversal to
units in document order, and emits at most one bounded advisory warning per combine overlapping cited units in document order, and emits at most one
turn for unrelated actor or declaration text. The normalized-invariants bounded advisory warning per turn for unrelated actors or declaration text.
Actors use normalized consecutive-token matching; declarations retain the
minimum four-rune token heuristic. The normalized-invariants
validator owns display normalization, comparison-unique targets, canonical validator owns display normalization, comparison-unique targets, canonical
source-reference order, chronology, and exact duplicate identity; it defers source-reference order, chronology, and exact duplicate identity; it defers
shape and source-reference failures. All four validators are deterministic and shape and source-reference failures. All four validators are deterministic and

View File

@@ -0,0 +1,36 @@
package shared
import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
// CitedText resolves cited source ranges in document order, including each
// source unit once, and joins the resulting text with newlines.
func CitedText(doc *source.SourceDocument, refs []source.SourceRef) (string, error) {
if doc == nil {
return "", fmt.Errorf("source document must not be nil")
}
included := make([]bool, len(doc.Units))
for _, ref := range refs {
if err := source.ValidateRef(doc, ref); err != nil {
return "", fmt.Errorf("resolve cited source range: %w", err)
}
start, _ := source.UnitIndex(doc, ref.StartUnitID)
end, _ := source.UnitIndex(doc, ref.EndUnitID)
for index := start; index <= end; index++ {
included[index] = true
}
}
parts := make([]string, 0, len(doc.Units))
for index, unit := range doc.Units {
if included[index] {
parts = append(parts, unit.Text)
}
}
return strings.Join(parts, "\n"), nil
}

View File

@@ -0,0 +1,87 @@
package shared
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
func TestCitedText(t *testing.T) {
doc := citationDocument()
tests := []struct {
name string
refs []source.SourceRef
want string
wantErr bool
}{
{name: "empty references", refs: nil, want: ""},
{name: "invalid source id", refs: []source.SourceRef{{SourceID: "other", StartUnitID: 10, EndUnitID: 10}}, wantErr: true},
{name: "unknown start unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 10}}, wantErr: true},
{name: "unknown end unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 99}}, wantErr: true},
{name: "reversed document range", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 30, EndUnitID: 10}}, wantErr: true},
{
name: "disjoint ranges supplied out of order",
refs: []source.SourceRef{
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
},
want: "alpha\ngamma",
},
{
name: "adjacent ranges",
refs: []source.SourceRef{
{SourceID: "session", StartUnitID: 10, EndUnitID: 20},
{SourceID: "session", StartUnitID: 30, EndUnitID: 40},
},
want: "alpha\nbeta\ngamma\ndelta",
},
{
name: "overlapping and duplicate ranges",
refs: []source.SourceRef{
{SourceID: "session", StartUnitID: 10, EndUnitID: 30},
{SourceID: "session", StartUnitID: 20, EndUnitID: 40},
{SourceID: "session", StartUnitID: 10, EndUnitID: 30},
},
want: "alpha\nbeta\ngamma\ndelta",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
beforeUnits := append([]source.SourceUnit(nil), doc.Units...)
refs := append([]source.SourceRef(nil), test.refs...)
got, err := CitedText(doc, refs)
if (err != nil) != test.wantErr {
t.Fatalf("CitedText() error = %v, want error = %t", err, test.wantErr)
}
if err == nil && got != test.want {
t.Fatalf("CitedText() = %q, want %q", got, test.want)
}
if !reflect.DeepEqual(doc.Units, beforeUnits) || !reflect.DeepEqual(refs, test.refs) {
t.Fatalf("CitedText() mutated document or references")
}
})
}
}
func TestCitedTextRejectsNilDocument(t *testing.T) {
if _, err := CitedText(nil, nil); err == nil {
t.Fatal("CitedText() error = nil, want nil-document error")
}
}
func citationDocument() *source.SourceDocument {
return &source.SourceDocument{
ID: "session",
Kind: "transcript",
Format: "application/json",
Digest: "sha256:session",
Units: []source.SourceUnit{
{ID: 10, Kind: "message", Text: "alpha"},
{ID: 20, Kind: "message", Text: "beta"},
{ID: 30, Kind: "message", Text: "gamma"},
{ID: 40, Kind: "message", Text: "delta"},
},
}
}

View File

@@ -0,0 +1,43 @@
package shared
import (
"strings"
"unicode"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
// NormalizedTokens returns identity-normalized alphanumeric tokens from a
// D&D value.
func NormalizedTokens(value string) []string {
value = identity.ComparisonKey(value)
if value == "" {
return nil
}
return strings.FieldsFunc(value, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// ContainsTokenSequence reports whether value contains the complete normalized
// query token sequence as consecutive tokens.
func ContainsTokenSequence(value string, query string) bool {
valueTokens := NormalizedTokens(value)
queryTokens := NormalizedTokens(query)
if len(queryTokens) == 0 || len(queryTokens) > len(valueTokens) {
return false
}
for start := 0; start <= len(valueTokens)-len(queryTokens); start++ {
matches := true
for offset, token := range queryTokens {
if valueTokens[start+offset] != token {
matches = false
break
}
}
if matches {
return true
}
}
return false
}

View File

@@ -0,0 +1,25 @@
package shared
import "testing"
func TestContainsTokenSequence(t *testing.T) {
tests := []struct {
name string
value string
query string
want bool
}{
{name: "unicode apostrophe and case", value: "OrIn\u2003ThOrN advances", query: "o'rin thorn", want: true},
{name: "multiword sequence", value: "Mira Thorn watches", query: "mira thorn", want: true},
{name: "nonconsecutive words", value: "Mira watches Thorn", query: "mira thorn", want: false},
{name: "short name is not substring", value: "A cart rolls past", query: "art", want: false},
{name: "empty query", value: "anything", query: " ", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := ContainsTokenSequence(test.value, test.query); got != test.want {
t.Fatalf("ContainsTokenSequence(%q, %q) = %t, want %t", test.value, test.query, got, test.want)
}
})
}
}

View File

@@ -3,15 +3,12 @@ package sourcerelatedness
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"unicode"
"unicode/utf8" "unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape" combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
) )
@@ -38,13 +35,21 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
} }
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) {
if combatshape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) { if combatshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
citedTexts := make([]string, len(req.Value.CombatTurns))
for turnIndex, turn := range req.Value.CombatTurns {
citedText, err := shared.CitedText(req.Source, turn.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts[turnIndex] = citedText
}
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for turnIndex, turn := range req.Value.CombatTurns { for turnIndex, turn := range req.Value.CombatTurns {
citedText := citedTextKey(req.Source, turn.SourceRefs) citedText := citedTexts[turnIndex]
issues := make([]string, 0) issues := make([]string, 0)
if !actorAppearsInCitedText(citedText, turn.Actor) { if !actorAppearsInCitedText(citedText, turn.Actor) {
issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor))) issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)))
@@ -65,47 +70,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
} }
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
} }
func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool {
for _, turn := range value.CombatTurns {
for _, ref := range turn.SourceRefs {
if source.ValidateRef(doc, ref) != nil {
return false
}
}
}
return true
}
func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string {
if doc == nil {
return ""
}
included := make([]bool, len(doc.Units))
for _, ref := range refs {
start, _ := source.UnitIndex(doc, ref.StartUnitID)
end, _ := source.UnitIndex(doc, ref.EndUnitID)
for index := start; index <= end && index < len(included); index++ {
included[index] = true
}
}
parts := make([]string, 0)
for index, unit := range doc.Units {
if included[index] {
parts = append(parts, unit.Text)
}
}
return identity.ComparisonKey(strings.Join(parts, " "))
}
func actorAppearsInCitedText(citedText string, actor string) bool { func actorAppearsInCitedText(citedText string, actor string) bool {
key := identity.ComparisonKey(actor) return shared.ContainsTokenSequence(citedText, actor)
return key != "" && strings.Contains(citedText, key)
} }
func declarationAppearsInCitedText(citedText string, declaration string) bool { func declarationAppearsInCitedText(citedText string, declaration string) bool {
citedTokens := tokenSet(citedText) citedTokens := tokenSet(citedText)
for _, token := range comparisonTokens(declaration) { for _, token := range shared.NormalizedTokens(declaration) {
if utf8.RuneCountInString(token) >= 4 { if utf8.RuneCountInString(token) >= 4 {
if _, ok := citedTokens[token]; ok { if _, ok := citedTokens[token]; ok {
return true return true
@@ -115,16 +86,8 @@ func declarationAppearsInCitedText(citedText string, declaration string) bool {
return false return false
} }
func comparisonTokens(value string) []string {
value = identity.ComparisonKey(value)
if value == "" {
return nil
}
return strings.FieldsFunc(value, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) })
}
func tokenSet(value string) map[string]struct{} { func tokenSet(value string) map[string]struct{} {
tokens := comparisonTokens(value) tokens := shared.NormalizedTokens(value)
set := make(map[string]struct{}, len(tokens)) set := make(map[string]struct{}, len(tokens))
for _, token := range tokens { for _, token := range tokens {
set[token] = struct{}{} set[token] = struct{}{}

View File

@@ -51,6 +51,20 @@ func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) {
} }
} }
func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) {
resolution := "The cart is struck."
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Art", TurnKind: dnd.CombatTurnKindTurn,
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}},
Summary: "The cart attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The cart attacks."}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0].Message, "actor") {
t.Fatalf("Validate() = %#v, %v; want short-actor boundary warning", result, err)
}
}
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}} invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape})

View File

@@ -3,13 +3,11 @@ package sourcerelatedness
import ( import (
"context" "context"
"fmt" "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/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
) )
@@ -39,9 +37,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err := npcshape.Validate(req.Value); err != nil { if err := npcshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
citedTexts := make([]string, len(req.Value.NPCs))
for npcIndex, npc := range req.Value.NPCs {
citedText, err := shared.CitedText(req.Source, npc.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts[npcIndex] = citedText
}
var warnings []contracts.Warning var warnings []contracts.Warning
for npcIndex, npc := range req.Value.NPCs { for npcIndex, npc := range req.Value.NPCs {
if npcAppearsInCitedText(req.Source, npc) { if npcAppearsInCitedText(citedTexts[npcIndex], npc) {
continue continue
} }
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
@@ -53,43 +59,18 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
} }
func npcAppearsInCitedText(doc *source.SourceDocument, npc dnd.NPC) bool { func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
cited := citedTextKey(doc, npc.SourceRefs) if shared.ContainsTokenSequence(citedText, npc.Name) {
if cited == "" {
return false
}
if strings.Contains(cited, identity.ComparisonKey(npc.Name)) {
return true return true
} }
for _, alias := range npc.Aliases { for _, alias := range npc.Aliases {
if strings.Contains(cited, identity.ComparisonKey(alias)) { if shared.ContainsTokenSequence(citedText, alias) {
return true return true
} }
} }
return false return false
} }
func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string {
if doc == nil {
return ""
}
var builder strings.Builder
for _, ref := range refs {
if err := source.ValidateRef(doc, ref); err != nil {
continue
}
start, _ := source.UnitIndex(doc, ref.StartUnitID)
end, _ := source.UnitIndex(doc, ref.EndUnitID)
for index := start; index <= end; index++ {
if builder.Len() > 0 {
builder.WriteByte(' ')
}
builder.WriteString(doc.Units[index].Text)
}
}
return identity.ComparisonKey(builder.String())
}
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
} }

View File

@@ -14,7 +14,11 @@ import (
func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) { func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{ value := dnd.NPCList{NPCs: []dnd.NPC{
{ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, {ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
}},
{ID: "two", Name: "Missing Name", Aliases: []string{"The Greencloak"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, {ID: "two", Name: "Missing Name", Aliases: []string{"The Greencloak"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
}} }}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{ doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{
@@ -41,6 +45,17 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) {
} }
} }
func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) {
value := dnd.NPCList{NPCs: []dnd.NPC{{
ID: "one", Name: "Art", Aliases: []string{}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "A cart rolls past."}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err)
}
}
func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) { func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}} invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidShape}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidShape})
@@ -49,8 +64,8 @@ func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
} }
invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}} invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange}) result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange})
if err != nil || !result.Approved || len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != WarningReasonCode { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("invalid-range relatedness = %#v, %v; want one warning", result, err) t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err)
} }
} }

View File

@@ -5,10 +5,10 @@ import (
"fmt" "fmt"
"strings" "strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
) )
@@ -29,46 +29,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err := spellshape.Validate(req.Value); err != nil { if err := spellshape.Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
citedTexts := make([]string, len(req.Value.SpellCasts))
for spellIndex, spell := range req.Value.SpellCasts {
citedText, err := shared.CitedText(req.Source, spell.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts[spellIndex] = citedText
}
var warnings []contracts.Warning var warnings []contracts.Warning
for spellIndex, spell := range req.Value.SpellCasts { for spellIndex, spell := range req.Value.SpellCasts {
if !spellAppearsInCitedText(req.Source, spell) { if !spellAppearsInCitedText(citedTexts[spellIndex], spell) {
warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: WarningReasonCode, Message: fmt.Sprintf("spell %q was not found in cited source text", strings.TrimSpace(spell.Spell))}) warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: WarningReasonCode, Message: fmt.Sprintf("spell %q was not found in cited source text", strings.TrimSpace(spell.Spell))})
} }
} }
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
} }
func spellAppearsInCitedText(doc *source.SourceDocument, spell dnd.SpellCast) bool { func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool {
name := strings.ToLower(strings.TrimSpace(spell.Spell)) name := strings.TrimSpace(spell.Spell)
if name == "" { if name == "" {
return true return true
} }
for _, ref := range spell.SourceRefs { return shared.ContainsTokenSequence(citedText, name)
if text, ok := citedText(doc, ref); ok && strings.Contains(strings.ToLower(text), name) {
return true
}
}
return false
}
func citedText(doc *source.SourceDocument, ref source.SourceRef) (string, bool) {
if doc == nil {
return "", false
}
start, ok := source.UnitIndex(doc, ref.StartUnitID)
if !ok {
return "", false
}
end, ok := source.UnitIndex(doc, ref.EndUnitID)
if !ok || start > end {
return "", false
}
var b strings.Builder
for i := start; i <= end; i++ {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(doc.Units[i].Text)
}
return b.String(), true
} }
func Spec() pipeline.ValidatorSpec { func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}

View File

@@ -39,6 +39,47 @@ func TestValidatorWarnsWhenSpellDoesNotAppearInCitedText(t *testing.T) {
} }
} }
func TestValidatorMatchesCaseInsensitiveUnicodeMultiwordSpellAcrossCitations(t *testing.T) {
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria", Spell: "Tasha's Hideous Laughter", Effect: "effect", NarrativeDescription: "description",
SourceRefs: []source.SourceRef{
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
},
}}}
doc := &source.SourceDocument{
ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session",
Units: []source.SourceUnit{
{ID: 1, Kind: "message", Text: "Tashas"},
{ID: 2, Kind: "message", Text: "hideous laughter fills the room."},
},
}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: doc, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("Validate() = %#v, %v; want normalized multiword spell approval", result, err)
}
}
func TestValidatorDoesNotMatchShortSpellNameSubstring(t *testing.T) {
result, err := New(Options{}).Validate(context.Background(), requestWithSpell(&source.SourceDocument{
ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session",
Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party said nothing."}},
}, "Aid", 1))
if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err)
}
}
func TestValidatorIgnoresInvalidCitations(t *testing.T) {
request := requestWithSpell(validDocument(), "Cure Wounds", 2)
request.Value.SpellCasts[0].SourceRefs[0].StartUnitID = 99
result, err := New(Options{}).Validate(context.Background(), request)
if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("Validate() = %#v, %v; want approval without relatedness warning", result, err)
}
}
func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) { func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) {
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}})
if err != nil { if err != nil {