Share D&D cited source traversal
This commit is contained in:
@@ -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
|
||||||
|
|||||||
36
internal/modules/dnd/shared/citations.go
Normal file
36
internal/modules/dnd/shared/citations.go
Normal 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
|
||||||
|
}
|
||||||
87
internal/modules/dnd/shared/citations_test.go
Normal file
87
internal/modules/dnd/shared/citations_test.go
Normal 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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/modules/dnd/shared/matching.go
Normal file
43
internal/modules/dnd/shared/matching.go
Normal 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
|
||||||
|
}
|
||||||
25
internal/modules/dnd/shared/matching_test.go
Normal file
25
internal/modules/dnd/shared/matching_test.go
Normal 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: "O’rIn\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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{}{}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: "Tasha’s"},
|
||||||
|
{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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user