169 lines
4.9 KiB
Go
169 lines
4.9 KiB
Go
package spells
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/validate"
|
|
)
|
|
|
|
const (
|
|
shapeValidatorName = "dnd/spells/shape"
|
|
sourceRefValidatorName = "dnd/spells/source_refs"
|
|
|
|
reasonInvalidPayload = "invalid_payload"
|
|
reasonMissingRequiredField = "missing_required_field"
|
|
reasonMissingSourceRef = "missing_source_ref"
|
|
reasonInvalidSourceRef = "invalid_source_ref"
|
|
reasonSpellNotNearSource = "spell_not_near_source"
|
|
)
|
|
|
|
var _ contracts.Validator = ShapeValidator{}
|
|
var _ contracts.Validator = SourceRefValidator{}
|
|
|
|
type ShapeValidator struct{}
|
|
|
|
func (validator ShapeValidator) Name() string {
|
|
return shapeValidatorName
|
|
}
|
|
|
|
func (validator ShapeValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
|
decisions := make([]contracts.ValidationDecision, 0, len(req.Candidates))
|
|
for _, candidate := range req.Candidates {
|
|
decisions = append(decisions, validateShape(candidate))
|
|
}
|
|
return contracts.ValidationResult{
|
|
ValidatorName: validator.Name(),
|
|
Decisions: decisions,
|
|
}, nil
|
|
}
|
|
|
|
type SourceRefValidator struct{}
|
|
|
|
func (validator SourceRefValidator) Name() string {
|
|
return sourceRefValidatorName
|
|
}
|
|
|
|
func (validator SourceRefValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) {
|
|
if req.Source == nil {
|
|
return contracts.ValidationResult{}, fmt.Errorf("dnd spells source refs validator: source must not be nil")
|
|
}
|
|
|
|
decisions := make([]contracts.ValidationDecision, 0, len(req.Candidates))
|
|
var warnings []contracts.Warning
|
|
for _, candidate := range req.Candidates {
|
|
decisions = append(decisions, validateSourceRefs(req.Source, candidate))
|
|
warnings = append(warnings, sourceRelatednessWarnings(req.Source, candidate)...)
|
|
}
|
|
return contracts.ValidationResult{
|
|
ValidatorName: validator.Name(),
|
|
Decisions: decisions,
|
|
Warnings: warnings,
|
|
}, nil
|
|
}
|
|
|
|
func validateShape(candidate artifacts.ArtifactCandidate) contracts.ValidationDecision {
|
|
var payload SpellCast
|
|
if err := json.Unmarshal(candidate.Payload, &payload); err != nil {
|
|
return validate.Rejected(candidate.Index, reasonInvalidPayload, fmt.Sprintf("invalid spell cast payload: %v", err))
|
|
}
|
|
for _, field := range requiredSpellCastFields(payload) {
|
|
if strings.TrimSpace(field.value) == "" {
|
|
return validate.Rejected(candidate.Index, reasonMissingRequiredField, fmt.Sprintf("missing required field %q", field.name))
|
|
}
|
|
}
|
|
return validate.Approved(candidate.Index)
|
|
}
|
|
|
|
func validateSourceRefs(doc *source.SourceDocument, candidate artifacts.ArtifactCandidate) contracts.ValidationDecision {
|
|
if len(candidate.SourceRefs) == 0 {
|
|
return validate.Rejected(candidate.Index, reasonMissingSourceRef, "spell cast candidate must include at least one source ref")
|
|
}
|
|
for _, ref := range candidate.SourceRefs {
|
|
if err := source.ValidateRef(doc, ref); err != nil {
|
|
return validate.Rejected(candidate.Index, reasonInvalidSourceRef, err.Error())
|
|
}
|
|
}
|
|
return validate.Approved(candidate.Index)
|
|
}
|
|
|
|
func sourceRelatednessWarnings(doc *source.SourceDocument, candidate artifacts.ArtifactCandidate) []contracts.Warning {
|
|
if doc == nil || len(candidate.SourceRefs) == 0 {
|
|
return nil
|
|
}
|
|
var payload SpellCast
|
|
if err := json.Unmarshal(candidate.Payload, &payload); err != nil {
|
|
return nil
|
|
}
|
|
spell := strings.TrimSpace(payload.Spell)
|
|
if spell == "" {
|
|
return nil
|
|
}
|
|
|
|
needle := strings.ToLower(spell)
|
|
for _, ref := range candidate.SourceRefs {
|
|
text, ok := sourceRefText(doc, ref)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if strings.Contains(strings.ToLower(text), needle) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return []contracts.Warning{
|
|
{
|
|
Scope: fmt.Sprintf("candidate.%d", candidate.Index),
|
|
ReasonCode: reasonSpellNotNearSource,
|
|
Message: fmt.Sprintf("spell %q was not found in the cited source text", spell),
|
|
},
|
|
}
|
|
}
|
|
|
|
func sourceRefText(doc *source.SourceDocument, ref source.SourceRef) (string, bool) {
|
|
if err := source.ValidateRef(doc, ref); err != nil {
|
|
return "", false
|
|
}
|
|
start := -1
|
|
end := -1
|
|
for i, unit := range doc.Units {
|
|
if unit.ID == ref.StartUnitID {
|
|
start = i
|
|
}
|
|
if unit.ID == ref.EndUnitID {
|
|
end = i
|
|
}
|
|
}
|
|
if start < 0 || end < start {
|
|
return "", false
|
|
}
|
|
var b strings.Builder
|
|
for i := start; i <= end; i++ {
|
|
if b.Len() > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(doc.Units[i].Text)
|
|
}
|
|
return b.String(), true
|
|
}
|
|
|
|
func requiredSpellCastFields(payload SpellCast) []struct {
|
|
name string
|
|
value string
|
|
} {
|
|
return []struct {
|
|
name string
|
|
value string
|
|
}{
|
|
{name: "caster", value: payload.Caster},
|
|
{name: "spell", value: payload.Spell},
|
|
{name: "effect", value: payload.Effect},
|
|
{name: "narrative_description", value: payload.NarrativeDescription},
|
|
}
|
|
}
|