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}, } }