diff --git a/docs/operations.md b/docs/operations.md index 5629a74..1641ec2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -110,9 +110,8 @@ stderr, and writes warnings to durable output and diagnostics when retained. The run manifest `validation_status` indicates whether raw outputs were approved or rejected after validation. -Reference-related warnings include empty bound reference files and D&D spell -relatedness warnings such as `spell_not_near_source`. Empty references are still -passed to extractors so optional slots can be intentionally blank. +Reference-related warnings include empty bound reference files. Empty references +are still passed to extractors so optional slots can be intentionally blank. ## Cleanup diff --git a/internal/core/artifacts/artifacts.go b/internal/core/artifacts/artifacts.go index d5f0597..8a87d15 100644 --- a/internal/core/artifacts/artifacts.go +++ b/internal/core/artifacts/artifacts.go @@ -1,38 +1,9 @@ package artifacts import ( - "encoding/json" "time" - - "gitea.maximumdirect.net/eric/notarius/internal/core/source" ) -type ArtifactCandidate struct { - Index int `json:"index"` - ExtractorKey string `json:"extractor_key"` - ArtifactType string `json:"artifact_type"` - SchemaVersion string `json:"schema_version"` - Payload json.RawMessage `json:"payload"` - SourceRefs []source.SourceRef `json:"source_refs,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type Artifact struct { - ExtractorKey string `json:"extractor_key"` - ArtifactType string `json:"artifact_type"` - SchemaVersion string `json:"schema_version"` - Payload json.RawMessage `json:"payload"` - SourceRefs []source.SourceRef `json:"source_refs,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type RejectedArtifact struct { - Candidate ArtifactCandidate `json:"candidate"` - ValidatorName string `json:"validator_name"` - ReasonCode string `json:"reason_code"` - Message string `json:"message"` -} - type ArtifactLaneManifest struct { ID string `json:"id"` Extractor string `json:"extractor"` @@ -122,26 +93,3 @@ type RunManifest struct { StartedAt *time.Time `json:"started_at,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` } - -func ArtifactFromCandidate(candidate ArtifactCandidate) Artifact { - return Artifact{ - ExtractorKey: candidate.ExtractorKey, - ArtifactType: candidate.ArtifactType, - SchemaVersion: candidate.SchemaVersion, - Payload: append(json.RawMessage(nil), candidate.Payload...), - SourceRefs: append([]source.SourceRef(nil), candidate.SourceRefs...), - Metadata: copyMetadata(candidate.Metadata), - } -} - -func copyMetadata(metadata map[string]any) map[string]any { - if len(metadata) == 0 { - return nil - } - - copied := make(map[string]any, len(metadata)) - for key, value := range metadata { - copied[key] = value - } - return copied -} diff --git a/internal/core/artifacts/artifacts_test.go b/internal/core/artifacts/artifacts_test.go index 10eb90b..48c51e0 100644 --- a/internal/core/artifacts/artifacts_test.go +++ b/internal/core/artifacts/artifacts_test.go @@ -2,116 +2,9 @@ package artifacts import ( "encoding/json" - "reflect" "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/core/source" ) -func TestArtifactFromCandidatePreservesCandidateFields(t *testing.T) { - candidate := ArtifactCandidate{ - Index: 7, - ExtractorKey: "generic-extractor", - ArtifactType: "generic-artifact", - SchemaVersion: "v1", - Payload: json.RawMessage(`{"name":"example"}`), - SourceRefs: []source.SourceRef{ - {SourceID: "source-1", StartUnitID: 1, EndUnitID: 2}, - }, - Metadata: map[string]any{ - "confidence": 0.75, - }, - } - - artifact := ArtifactFromCandidate(candidate) - - if artifact.ExtractorKey != candidate.ExtractorKey { - t.Fatalf("ExtractorKey = %q, want %q", artifact.ExtractorKey, candidate.ExtractorKey) - } - if artifact.ArtifactType != candidate.ArtifactType { - t.Fatalf("ArtifactType = %q, want %q", artifact.ArtifactType, candidate.ArtifactType) - } - if artifact.SchemaVersion != candidate.SchemaVersion { - t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, candidate.SchemaVersion) - } - if string(artifact.Payload) != string(candidate.Payload) { - t.Fatalf("Payload = %s, want %s", artifact.Payload, candidate.Payload) - } - if !reflect.DeepEqual(artifact.SourceRefs, candidate.SourceRefs) { - t.Fatalf("SourceRefs = %#v, want %#v", artifact.SourceRefs, candidate.SourceRefs) - } - if !reflect.DeepEqual(artifact.Metadata, candidate.Metadata) { - t.Fatalf("Metadata = %#v, want %#v", artifact.Metadata, candidate.Metadata) - } - - candidate.Payload[0] = '[' - candidate.SourceRefs[0].StartUnitID = 99 - candidate.Metadata["confidence"] = 0.5 - - if string(artifact.Payload) != `{"name":"example"}` { - t.Fatalf("Payload changed after candidate mutation: %s", artifact.Payload) - } - if artifact.SourceRefs[0].StartUnitID != 1 { - t.Fatalf("SourceRefs changed after candidate mutation: %#v", artifact.SourceRefs) - } - if artifact.Metadata["confidence"] != 0.75 { - t.Fatalf("Metadata changed after candidate mutation: %#v", artifact.Metadata) - } -} - -func TestJSONMarshalUsesExpectedFieldNames(t *testing.T) { - candidate := ArtifactCandidate{ - Index: 1, - ExtractorKey: "generic-extractor", - ArtifactType: "generic-artifact", - SchemaVersion: "v1", - Payload: json.RawMessage(`{"value":true}`), - SourceRefs: []source.SourceRef{ - {SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, - }, - Metadata: map[string]any{ - "reviewed": true, - }, - } - rejected := RejectedArtifact{ - Candidate: candidate, - ValidatorName: "generic-validator", - ReasonCode: "invalid", - Message: "candidate was not accepted", - } - - gotJSON, err := json.Marshal(rejected) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - - var got map[string]any - if err := json.Unmarshal(gotJSON, &got); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - - assertHasKeys(t, got, "candidate", "validator_name", "reason_code", "message") - - gotCandidate, ok := got["candidate"].(map[string]any) - if !ok { - t.Fatalf("candidate = %#v, want object", got["candidate"]) - } - assertHasKeys(t, gotCandidate, "index", "extractor_key", "artifact_type", "schema_version", "payload", "source_refs", "metadata") - - gotRefs, ok := gotCandidate["source_refs"].([]any) - if !ok { - t.Fatalf("source_refs = %#v, want array", gotCandidate["source_refs"]) - } - if len(gotRefs) != 1 { - t.Fatalf("len(source_refs) = %d, want 1", len(gotRefs)) - } - gotRef, ok := gotRefs[0].(map[string]any) - if !ok { - t.Fatalf("source_refs[0] = %#v, want object", gotRefs[0]) - } - assertHasKeys(t, gotRef, "source_id", "start_unit_id", "end_unit_id") -} - func TestRunManifestOmitsEmptyOptionalFields(t *testing.T) { gotJSON, err := json.Marshal(RunManifest{}) if err != nil { diff --git a/internal/validators/extract/dnd/spells/shape/validator.go b/internal/validators/extract/dnd/spells/shape/validator.go new file mode 100644 index 0000000..41e20c3 --- /dev/null +++ b/internal/validators/extract/dnd/spells/shape/validator.go @@ -0,0 +1,60 @@ +package shape + +import ( + "context" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/spellpayload" +) + +const Key = "extract/dnd/spells/shape" +const ReasonCode = "invalid_spell_shape" + +var _ contracts.Validator = (*Validator)(nil) + +type Validator struct{} + +func New() *Validator { + return &Validator{} +} + +func (v *Validator) Name() string { + return Key +} + +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} + +func (v *Validator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { + payload, err := spellpayload.ValidationRequestPayload(req) + if err != nil { + return rejection(err.Error()), nil + } + if err := spellpayload.ValidateShape(payload); err != nil { + return rejection(err.Error()), nil + } + return contracts.ValidationResult{Approved: true}, nil +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{ + Key: Key, + ExecutionClass: contracts.ExecutionClassDeterministic, + } +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return registry.RegisterWithSpec(Spec(), func() (contracts.Validator, error) { + return New(), nil + }) +} + +func rejection(message string) contracts.ValidationResult { + return contracts.ValidationResult{ + Approved: false, + ReasonCode: ReasonCode, + Message: message, + } +} diff --git a/internal/validators/extract/dnd/spells/shape/validator_test.go b/internal/validators/extract/dnd/spells/shape/validator_test.go new file mode 100644 index 0000000..7383991 --- /dev/null +++ b/internal/validators/extract/dnd/spells/shape/validator_test.go @@ -0,0 +1,68 @@ +package shape + +import ( + "context" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" +) + +func TestValidatorApprovesWellFormedSpellPayload(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria heals Borin.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if !result.Approved { + t.Fatalf("Validate() = %#v, want approved", result) + } +} + +func TestValidatorRejectsMalformedPayload(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(`{"spell_casts":`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if result.Approved { + t.Fatalf("Approved = true, want false") + } + if result.ReasonCode != ReasonCode { + t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode) + } +} + +func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(`{"spell_casts":[{"caster":"Aria","effect":"heals","narrative_description":"Aria heals Borin.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if result.Approved { + t.Fatalf("Approved = true, want false") + } + if result.ReasonCode != ReasonCode { + t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode) + } +} + +func TestSpecAndRegister(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + validator, err := registry.Build(Key) + if err != nil { + t.Fatalf("Build(%q) error = %v, want nil", Key, err) + } + if validator.Name() != Key || validator.ExecutionClass() != contracts.ExecutionClassDeterministic { + t.Fatalf("validator = %q/%q, want key and deterministic execution", validator.Name(), validator.ExecutionClass()) + } +} + +func requestWithPayload(payload string) contracts.ValidationRequest { + return contracts.ValidationRequest{ + Payload: contracts.RawPayload{ + Content: []byte(payload), + MediaType: "application/json", + }, + } +} diff --git a/internal/validators/extract/dnd/spells/source_refs/validator.go b/internal/validators/extract/dnd/spells/source_refs/validator.go new file mode 100644 index 0000000..8d2c8d5 --- /dev/null +++ b/internal/validators/extract/dnd/spells/source_refs/validator.go @@ -0,0 +1,69 @@ +package sourcerefs + +import ( + "context" + "fmt" + + "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/validators/extract/dnd/spells/spellpayload" +) + +const Key = "extract/dnd/spells/source_refs" +const ReasonCode = "invalid_source_refs" + +var _ contracts.Validator = (*Validator)(nil) + +type Validator struct{} + +func New() *Validator { + return &Validator{} +} + +func (v *Validator) Name() string { + return Key +} + +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} + +func (v *Validator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { + payload, err := spellpayload.ValidationRequestPayload(req) + if err != nil { + return rejection(err.Error()), nil + } + if err := spellpayload.ValidateShape(payload); err != nil { + return rejection(err.Error()), nil + } + for spellIndex, spell := range payload.SpellCasts { + for refIndex, ref := range spellpayload.SourceRefCandidates(req.Source, spell) { + if err := source.ValidateRef(req.Source, ref); err != nil { + return rejection(fmt.Sprintf("spell_casts[%d].source_refs[%d]: %v", spellIndex, refIndex, err)), nil + } + } + } + return contracts.ValidationResult{Approved: true}, nil +} + +func Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{ + Key: Key, + ExecutionClass: contracts.ExecutionClassDeterministic, + } +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return registry.RegisterWithSpec(Spec(), func() (contracts.Validator, error) { + return New(), nil + }) +} + +func rejection(message string) contracts.ValidationResult { + return contracts.ValidationResult{ + Approved: false, + ReasonCode: ReasonCode, + Message: message, + } +} diff --git a/internal/validators/extract/dnd/spells/source_refs/validator_test.go b/internal/validators/extract/dnd/spells/source_refs/validator_test.go new file mode 100644 index 0000000..7b4fd97 --- /dev/null +++ b/internal/validators/extract/dnd/spells/source_refs/validator_test.go @@ -0,0 +1,83 @@ +package sourcerefs + +import ( + "context" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" +) + +func TestValidatorApprovesValidSourceRefs(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":2}]}]}`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if !result.Approved { + t.Fatalf("Validate() = %#v, want approved", result) + } +} + +func TestValidatorRejectsInvalidSourceRefs(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"source_id":"session","start_unit_id":99,"end_unit_id":99}]}]}`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if result.Approved { + t.Fatalf("Approved = true, want false") + } + if result.ReasonCode != ReasonCode { + t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode) + } +} + +func TestValidatorRejectsMissingSourceDocument(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(nil, `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if result.Approved { + t.Fatalf("Approved = true, want false") + } + if result.ReasonCode != ReasonCode { + t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode) + } +} + +func TestSpecAndRegister(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + validator, err := registry.Build(Key) + if err != nil { + t.Fatalf("Build(%q) error = %v, want nil", Key, err) + } + if validator.Name() != Key || validator.ExecutionClass() != contracts.ExecutionClassDeterministic { + t.Fatalf("validator = %q/%q, want key and deterministic execution", validator.Name(), validator.ExecutionClass()) + } +} + +func requestWithPayload(doc *source.SourceDocument, payload string) contracts.ValidationRequest { + return contracts.ValidationRequest{ + Source: doc, + Payload: contracts.RawPayload{ + Content: []byte(payload), + MediaType: "application/json", + }, + } +} + +func validDocument() *source.SourceDocument { + return &source.SourceDocument{ + ID: "session", + Kind: "transcript", + Format: "application/json", + Digest: "sha256:session", + Units: []source.SourceUnit{ + {ID: 1, Kind: "message", Text: "Aria raises her holy symbol."}, + {ID: 2, Kind: "message", Text: "Aria casts Cure Wounds on Borin."}, + }, + } +} diff --git a/internal/validators/extract/dnd/spells/source_relatedness/validator.go b/internal/validators/extract/dnd/spells/source_relatedness/validator.go new file mode 100644 index 0000000..b3b227a --- /dev/null +++ b/internal/validators/extract/dnd/spells/source_relatedness/validator.go @@ -0,0 +1,83 @@ +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/validators/extract/dnd/spells/spellpayload" +) + +const Key = "extract/dnd/spells/source_relatedness" +const WarningReasonCode = "spell_not_near_source" + +var _ contracts.Validator = (*Validator)(nil) + +type Validator struct{} + +func New() *Validator { + return &Validator{} +} + +func (v *Validator) Name() string { + return Key +} + +func (v *Validator) ExecutionClass() contracts.ExecutionClass { + return contracts.ExecutionClassDeterministic +} + +func (v *Validator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { + payload, err := spellpayload.ValidationRequestPayload(req) + if err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + if err := spellpayload.ValidateShape(payload); err != nil { + return contracts.ValidationResult{Approved: true}, nil + } + + var warnings []contracts.Warning + for spellIndex, spell := range payload.SpellCasts { + if !spellAppearsInCitedText(req.Source, 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 Spec() pipeline.ValidatorSpec { + return pipeline.ValidatorSpec{ + Key: Key, + ExecutionClass: contracts.ExecutionClassDeterministic, + } +} + +func Register(registry *pipeline.ValidatorRegistry) error { + return registry.RegisterWithSpec(Spec(), func() (contracts.Validator, error) { + return New(), nil + }) +} + +func spellAppearsInCitedText(doc *source.SourceDocument, spell spellpayload.SpellCast) bool { + name := strings.ToLower(strings.TrimSpace(spell.Spell)) + if name == "" { + return true + } + for _, ref := range spellpayload.SourceRefCandidates(doc, spell) { + text, ok := spellpayload.CitedText(doc, ref) + if !ok { + continue + } + if strings.Contains(strings.ToLower(text), name) { + return true + } + } + return false +} diff --git a/internal/validators/extract/dnd/spells/source_relatedness/validator_test.go b/internal/validators/extract/dnd/spells/source_relatedness/validator_test.go new file mode 100644 index 0000000..b0a2023 --- /dev/null +++ b/internal/validators/extract/dnd/spells/source_relatedness/validator_test.go @@ -0,0 +1,86 @@ +package sourcerelatedness + +import ( + "context" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" +) + +func TestValidatorApprovesWithoutWarningWhenSpellAppearsInCitedText(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"heals","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"source_id":"session","start_unit_id":2,"end_unit_id":2}]}]}`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if !result.Approved { + t.Fatalf("Approved = false, want true") + } + if len(result.Warnings) != 0 { + t.Fatalf("Warnings = %#v, want none", result.Warnings) + } +} + +func TestValidatorWarnsWhenSpellDoesNotAppearInCitedText(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":[{"caster":"Borin","spell":"Fire Bolt","effect":"scorches","narrative_description":"Borin casts Fire Bolt.","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if !result.Approved { + t.Fatalf("Approved = false, want true") + } + if len(result.Warnings) != 1 { + t.Fatalf("Warnings = %#v, want one warning", result.Warnings) + } + if result.Warnings[0].ReasonCode != WarningReasonCode { + t.Fatalf("ReasonCode = %q, want %q", result.Warnings[0].ReasonCode, WarningReasonCode) + } +} + +func TestValidatorApprovesMalformedPayloadWithoutWarning(t *testing.T) { + result, err := New().Validate(context.Background(), requestWithPayload(validDocument(), `{"spell_casts":`)) + if err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if !result.Approved { + t.Fatalf("Approved = false, want true") + } +} + +func TestSpecAndRegister(t *testing.T) { + registry := pipeline.NewValidatorRegistry() + if err := Register(registry); err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + validator, err := registry.Build(Key) + if err != nil { + t.Fatalf("Build(%q) error = %v, want nil", Key, err) + } + if validator.Name() != Key || validator.ExecutionClass() != contracts.ExecutionClassDeterministic { + t.Fatalf("validator = %q/%q, want key and deterministic execution", validator.Name(), validator.ExecutionClass()) + } +} + +func requestWithPayload(doc *source.SourceDocument, payload string) contracts.ValidationRequest { + return contracts.ValidationRequest{ + Source: doc, + Payload: contracts.RawPayload{ + Content: []byte(payload), + MediaType: "application/json", + }, + } +} + +func validDocument() *source.SourceDocument { + return &source.SourceDocument{ + ID: "session", + Kind: "transcript", + Format: "application/json", + Digest: "sha256:session", + Units: []source.SourceUnit{ + {ID: 1, Kind: "message", Text: "Borin draws his dagger."}, + {ID: 2, Kind: "message", Text: "Aria casts Cure Wounds on Borin."}, + }, + } +} diff --git a/internal/validators/extract/dnd/spells/spellpayload/payload.go b/internal/validators/extract/dnd/spells/spellpayload/payload.go new file mode 100644 index 0000000..904e1e8 --- /dev/null +++ b/internal/validators/extract/dnd/spells/spellpayload/payload.go @@ -0,0 +1,98 @@ +package spellpayload + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" +) + +type Payload struct { + SpellCasts []SpellCast `json:"spell_casts"` +} + +type SpellCast struct { + Caster string `json:"caster"` + Spell string `json:"spell"` + Effect string `json:"effect"` + NarrativeDescription string `json:"narrative_description"` + SourceRefs []dnd.SourceRefResponse `json:"source_refs"` +} + +func Parse(raw []byte) (Payload, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + + var payload Payload + if err := decoder.Decode(&payload); err != nil { + return Payload{}, fmt.Errorf("parse spell payload: %w", err) + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return Payload{}, fmt.Errorf("parse spell payload: multiple JSON values") + } + return payload, nil +} + +func ValidateShape(payload Payload) error { + if payload.SpellCasts == nil { + return fmt.Errorf("spell_casts must be present") + } + for index, spell := range payload.SpellCasts { + if strings.TrimSpace(spell.Caster) == "" { + return fmt.Errorf("spell_casts[%d].caster must not be empty", index) + } + if strings.TrimSpace(spell.Spell) == "" { + return fmt.Errorf("spell_casts[%d].spell must not be empty", index) + } + if strings.TrimSpace(spell.Effect) == "" { + return fmt.Errorf("spell_casts[%d].effect must not be empty", index) + } + if strings.TrimSpace(spell.NarrativeDescription) == "" { + return fmt.Errorf("spell_casts[%d].narrative_description must not be empty", index) + } + if len(spell.SourceRefs) == 0 { + return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index) + } + } + return nil +} + +func SourceRefCandidates(doc *source.SourceDocument, spell SpellCast) []source.SourceRef { + refs := make([]source.SourceRef, 0, len(spell.SourceRefs)) + for _, ref := range spell.SourceRefs { + refs = append(refs, dnd.SourceRefCandidate(doc, ref)) + } + return refs +} + +func CitedText(doc *source.SourceDocument, ref source.SourceRef) (string, bool) { + if doc == nil { + return "", false + } + startIndex, ok := source.UnitIndex(doc, ref.StartUnitID) + if !ok { + return "", false + } + endIndex, ok := source.UnitIndex(doc, ref.EndUnitID) + if !ok || startIndex > endIndex { + return "", false + } + var b strings.Builder + for i := startIndex; i <= endIndex; i++ { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(doc.Units[i].Text) + } + return b.String(), true +} + +func ValidationRequestPayload(req contracts.ValidationRequest) (Payload, error) { + return Parse(req.Payload.Content) +}