Share D&D cited source traversal
This commit is contained in:
@@ -5,10 +5,10 @@ import (
|
||||
"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/pipeline"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -29,46 +29,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if err := spellshape.Validate(req.Value); err != 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
|
||||
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))})
|
||||
}
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||
}
|
||||
func spellAppearsInCitedText(doc *source.SourceDocument, spell dnd.SpellCast) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(spell.Spell))
|
||||
func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool {
|
||||
name := strings.TrimSpace(spell.Spell)
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
for _, ref := range spell.SourceRefs {
|
||||
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
|
||||
return shared.ContainsTokenSequence(citedText, name)
|
||||
}
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
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) {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user