package sourcerelatedness import ( "context" "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/npcs/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape" ) const ( Key = "extract/dnd/npcs/source_relatedness" WarningReasonCode = "npc_not_near_source" policy = "dnd.npcs.validator.source_relatedness.v1" ) type Options struct{} type Validator struct{} var _ contracts.TypedValidator[dnd.NPCList] = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) func New(Options) *Validator { return &Validator{} } func (v *Validator) Name() string { return Key } func (v *Validator) ExecutionClass() contracts.ExecutionClass { return contracts.ExecutionClassDeterministic } func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint { return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} } func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCList]) (contracts.ValidationResult, error) { if err := npcshape.Validate(req.Value); err != nil { return contracts.ValidationResult{Approved: true}, nil } var warnings []contracts.Warning for npcIndex, npc := range req.Value.NPCs { if npcAppearsInCitedText(req.Source, npc) { continue } warnings = append(warnings, contracts.Warning{ Scope: fmt.Sprintf("npcs[%d]", npcIndex), ReasonCode: WarningReasonCode, Message: fmt.Sprintf("NPC %s was not found in cited source text", diagnostics.Quote(npc.Name)), }) } return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil } func npcAppearsInCitedText(doc *source.SourceDocument, npc dnd.NPC) bool { cited := citedTextKey(doc, npc.SourceRefs) if cited == "" { return false } if strings.Contains(cited, identity.ComparisonKey(npc.Name)) { return true } for _, alias := range npc.Aliases { if strings.Contains(cited, identity.ComparisonKey(alias)) { return true } } 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 { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} } func Register(registry *pipeline.ValidatorRegistry) error { return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return New(options), nil }) } func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, err } return Options{}, nil } func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }