Strengthen validation retries and checkpoint safety

This commit is contained in:
2026-08-27 11:50:37 +00:00
parent b2e83bd6e7
commit e6b2ae88d2
73 changed files with 934 additions and 1560 deletions

View File

@@ -40,7 +40,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) {
if err := Validate(req.Source, req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return combat turns in transcript order, without duplicate turns, using consistent contextual combatant names and evidence for each turn."}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -100,5 +100,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete combat-turn list with every required field present, valid combatant names, and valid source references."}
}

View File

@@ -41,6 +41,9 @@ func TestValidateRejectsEveryOwnedShapeBoundary(t *testing.T) {
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("Validator result = %#v, %v; want shape rejection", result, err)
}
if err := contracts.ValidateValidationResult(result); err != nil {
t.Fatalf("ValidateValidationResult() error = %v for %#v", err, result)
}
})
}
}

View File

@@ -49,9 +49,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid combat turn source references", issues),
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid combat turn source references", issues),
CorrectionGuidance: "Return combat turns whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each turn.",
}, nil
}

View File

@@ -42,6 +42,9 @@ func TestValidatorRejectsInvalidSourceIdentityExistenceAndOrder(t *testing.T) {
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
t.Fatalf("Validate() = %#v, %v; want source-reference rejection containing %q", result, err, test.want)
}
if err := contracts.ValidateValidationResult(result); err != nil {
t.Fatalf("ValidateValidationResult() error = %v for %#v", err, result)
}
})
}
}

View File

@@ -0,0 +1,180 @@
package validate_test
import (
"context"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
combatsourcerelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
npcregistryvalidator "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcoccurrences/registry"
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcregistry/identity"
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
)
func TestRepresentativeProductionValidatorResultsSatisfyCorrectionContract(t *testing.T) {
document := &source.SourceDocument{
ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session",
Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "Mira Thorn waits while Aria acts."}},
}
npcReferences := npcRegistryReferences(t, "Mira Thorn")
npcRegistryValidator, err := npcregistryvalidator.New(npcregistryvalidator.Options{}, npcReferences)
if err != nil {
t.Fatal(err)
}
unknownNPCID := "npc:sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
tests := []struct {
name string
validate func() (contracts.ValidationResult, error)
wantApproved bool
wantWarningCount int
}{
{
name: "shape rejection",
validate: func() (contracts.ValidationResult, error) {
return combatshape.New(combatshape.Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{
Value: dnd.CombatTurnList{},
})
},
},
{
name: "source reference rejection",
validate: func() (contracts.ValidationResult, error) {
return combatsourcerefs.New(combatsourcerefs.Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{
Source: document,
Value: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn,
SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}},
}}},
})
},
},
{
name: "registry identity rejection",
validate: func() (contracts.ValidationResult, error) {
return npcidentity.New(npcidentity.Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCRegistry]{
Value: dnd.NPCRegistry{NPCs: []dnd.NPC{{
ID: "not-an-id", Name: "Mira Thorn",
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}},
})
},
},
{
name: "catalog membership rejection",
validate: func() (contracts.ValidationResult, error) {
validator, buildErr := spellcatalog.New(spellcatalog.Options{})
if buildErr != nil {
return contracts.ValidationResult{}, buildErr
}
return validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{
Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Caster: "Aria", Spell: "Definitely Not A Spell",
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}},
})
},
},
{
name: "registry membership rejection",
validate: func() (contracts.ValidationResult, error) {
return npcRegistryValidator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
References: npcReferences,
Value: dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
NPCID: unknownNPCID, Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}},
})
},
},
{
name: "source relatedness advisory",
validate: func() (contracts.ValidationResult, error) {
return combatsourcerelatedness.New(combatsourcerelatedness.Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{
Source: document,
Value: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
Actor: "Unmentioned Actor", TurnKind: dnd.CombatTurnKindTurn,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}},
})
},
wantApproved: true,
wantWarningCount: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := test.validate()
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
if result.Approved != test.wantApproved {
t.Fatalf("Validate() approved = %t, want %t: %#v", result.Approved, test.wantApproved, result)
}
if len(result.Warnings) != test.wantWarningCount {
t.Fatalf("Validate() warnings = %d, want %d: %#v", len(result.Warnings), test.wantWarningCount, result)
}
if err := contracts.ValidateValidationResult(result); err != nil {
t.Fatalf("ValidateValidationResult() error = %v for %#v", err, result)
}
if !result.Approved && strings.TrimSpace(result.CorrectionGuidance) == "" {
t.Fatalf("rejection lacks actionable correction guidance: %#v", result)
}
})
}
}
func TestRegistryCorrectionGuidanceUsesContextualNamesNotOpaqueIDs(t *testing.T) {
references := npcRegistryReferences(t, "Mira Thorn")
validator, err := npcregistryvalidator.New(npcregistryvalidator.Options{}, references)
if err != nil {
t.Fatal(err)
}
opaqueID := "npc:sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
References: references,
Value: dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
NPCID: opaqueID, Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}},
})
if err != nil || result.Approved {
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
}
if !strings.Contains(result.CorrectionGuidance, "contextual NPC names") {
t.Fatalf("correction guidance = %q, want contextual-name instruction", result.CorrectionGuidance)
}
if strings.Contains(result.CorrectionGuidance, opaqueID) || strings.Contains(result.CorrectionGuidance, "npc:sha256:") {
t.Fatalf("correction guidance leaked opaque durable ID: %q", result.CorrectionGuidance)
}
}
func npcRegistryReferences(t *testing.T, name string) contracts.ReferenceSet {
t.Helper()
content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{{
ID: identity.DeriveID(name), Name: name,
SourceRefs: []source.SourceRef{{SourceID: "prior-session", StartUnitID: 1, EndUnitID: 1}},
}}})
if err != nil {
t.Fatal(err)
}
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
npcregistry.ReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot},
Items: []contracts.ReferenceItem{{
SlotName: npcregistry.ReferenceSlot,
MediaType: npccodec.MediaType,
Content: content,
}},
},
}}
}

View File

@@ -54,6 +54,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
Message: diagnostics.Aggregate("duplicate enemy engagement", []string{
fmt.Sprintf("subject %s has more than one engagement in one combat scene", diagnostics.Quote(event.Name)),
}),
CorrectionGuidance: "Return at most one engagement event for each contextual enemy name within the same combat scene.",
}, nil
}
seen[identity] = struct{}{}

View File

@@ -90,13 +90,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
}
if !registry.Bound() {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid enemy event normalization: NPC registry reference is required"}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid enemy event normalization: NPC registry reference is required", CorrectionGuidance: "Return enemy events using contextual NPC names that match the supplied NPC registry."}, nil
}
issues := issuesFor(shared.NewSourceRefOrderFromIndex(index), req.Value, registry)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event normalization", issues)}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event normalization", issues), CorrectionGuidance: "Return enemy events in canonical transcript order, without duplicate events, using contextual NPC names that match the supplied registry and evidence."}, nil
}
func allSourceRefsValid(index source.DocumentIndex, value dnd.EnemyEventList) bool {

View File

@@ -36,7 +36,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete enemy-event list with every required field present, valid contextual NPC names, supported event values, and valid source references."}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -50,7 +50,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event source references", issues)}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event source references", issues), CorrectionGuidance: "Return enemy events whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each event."}, nil
}
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage *chunkCoverage, value dnd.EnemyEventList) []string {

View File

@@ -38,7 +38,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
if err := Validate(req.Source, req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return item occurrences in canonical transcript order, without duplicate occurrences, using consistent contextual item names and valid event transitions."}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -96,7 +96,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}
func rejection(issues []string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues)}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues), CorrectionGuidance: "Return item occurrences using contextual item names that match an item in the supplied registry; omit occurrences that cannot be matched unambiguously."}
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}

View File

@@ -36,7 +36,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete item-occurrence list with every required field present, valid contextual item names, supported event kinds and holder combinations, positive quantities, and valid source references."}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -61,7 +61,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence source references", issues)}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence source references", issues), CorrectionGuidance: "Return item occurrences whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each occurrence."}, nil
}
type chunkCoverage struct {

View File

@@ -46,7 +46,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
for index, issue := range identityIssues {
issues[index] = fmt.Sprintf("items[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item identity", issues)}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item identity", issues), CorrectionGuidance: "Return one canonical registry entry per distinct properly named item, combining duplicate mentions under the same transcript-supported name."}, nil
}
func Spec() pipeline.ValidatorSpec {

View File

@@ -93,5 +93,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete item registry containing only properly named items with valid source references."}
}

View File

@@ -97,5 +97,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return registry items whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each named item."}
}

View File

@@ -50,7 +50,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence normalization", issues)}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence normalization", issues), CorrectionGuidance: "Return location occurrences in canonical transcript order, without duplicates, using consistent proper location names and supported occurrence kinds."}, nil
}
func allSourceRefsValid(index source.DocumentIndex, value dnd.LocationOccurrenceList) bool {

View File

@@ -96,7 +96,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}
func rejection(issues []string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence registry", issues)}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence registry", issues), CorrectionGuidance: "Return location occurrences using proper contextual location names that match a location in the supplied registry; omit occurrences that cannot be matched unambiguously."}
}
func Spec() pipeline.ValidatorSpec {

View File

@@ -35,7 +35,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete location-occurrence list with every required field present, valid contextual location names, supported occurrence kinds, and valid source references."}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -57,7 +57,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence source references", issues)}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence source references", issues), CorrectionGuidance: "Return location occurrences whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each occurrence."}, nil
}
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {

View File

@@ -47,8 +47,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
issues[index] = fmt.Sprintf("locations[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
}
return contracts.ValidationResult{
Approved: false, ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid location identity", issues),
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid location identity", issues),
CorrectionGuidance: "Return one canonical registry entry per distinct proper location name, combining duplicate mentions of the same location.",
}, nil
}

View File

@@ -93,5 +93,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete location registry containing only properly named locations with valid source references."}
}

View File

@@ -97,5 +97,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return registry locations whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each proper location name."}
}

View File

@@ -92,16 +92,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
}
if !npcRegistry.Bound() {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid NPC occurrence normalization: NPC registry reference is required"}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid NPC occurrence normalization: NPC registry reference is required", CorrectionGuidance: "Return NPC occurrences using contextual NPC names that match the supplied NPC registry."}, nil
}
issues := issuesFor(shared.NewSourceRefOrderFromIndex(index), req.Value, npcRegistry)
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC occurrence normalization", issues),
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC occurrence normalization", issues),
CorrectionGuidance: "Return NPC occurrences in canonical transcript order, without duplicates, using contextual NPC names that match the supplied registry and supported interaction kinds.",
}, nil
}

View File

@@ -105,9 +105,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
func rejection(issues []string) contracts.ValidationResult {
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC occurrence registry", issues),
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC occurrence registry", issues),
CorrectionGuidance: "Return NPC occurrences using contextual NPC names that match an NPC in the supplied registry; omit occurrences that cannot be matched unambiguously.",
}
}

View File

@@ -29,6 +29,12 @@ func TestValidatorRecognizesExactRegistryPairsAndRejectsUnknownIDs(t *testing.T)
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].npc_id") {
t.Fatalf("unknown result = %#v, %v", result, err)
}
if err := contracts.ValidateValidationResult(result); err != nil {
t.Fatalf("ValidateValidationResult() error = %v for %#v", err, result)
}
if !strings.Contains(result.CorrectionGuidance, "contextual NPC names") || strings.Contains(result.CorrectionGuidance, value.Occurrences[0].NPCID) {
t.Fatalf("correction guidance = %q, want contextual names without opaque IDs", result.CorrectionGuidance)
}
value = validList("Mira Thorn")
value.Occurrences[0].Name = "Hooded Guard"
result, err = validator.Validate(context.Background(), request(references, value))

View File

@@ -35,7 +35,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete NPC-occurrence list with every required field present, valid contextual NPC names, supported interaction kinds, and valid source references."}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -61,9 +61,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC occurrence source references", issues),
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC occurrence source references", issues),
CorrectionGuidance: "Return NPC occurrences whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each occurrence.",
}, nil
}

View File

@@ -51,9 +51,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value))
}
return contracts.ValidationResult{
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC identity", issues),
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid NPC identity", issues),
CorrectionGuidance: "Return one canonical registry entry per distinct properly named NPC, combining duplicate mentions under the same transcript-supported name.",
}, nil
}

View File

@@ -57,6 +57,9 @@ func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) {
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("identity result = %#v, error = %v, want rejection", result, err)
}
if err := contracts.ValidateValidationResult(result); err != nil {
t.Fatalf("ValidateValidationResult() error = %v for %#v", err, result)
}
for _, want := range []string{"invalid_id", "duplicate_canonical_identity", "duplicate_id", "npcs[0]", "npcs[1]"} {
if !strings.Contains(result.Message, want) {
t.Fatalf("identity message %q missing %q", result.Message, want)

View File

@@ -92,5 +92,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete NPC registry containing only properly named NPCs with valid source references."}
}

View File

@@ -117,5 +117,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return registry NPCs whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each proper NPC name."}
}

View File

@@ -48,8 +48,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{
Approved: false, ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid scene description normalization", issues),
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid scene description normalization", issues),
CorrectionGuidance: "Return scene descriptions in canonical source order, with one unique scene per source range and no duplicate scene identity.",
}, nil
}

View File

@@ -35,7 +35,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
if err := ValidateForStage(req.Value, req.Stage); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete scene-description list with the required number of scenes, supported scene kinds, nonblank titles and summaries, and valid source references."}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -62,8 +62,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return contracts.ValidationResult{Approved: true}, nil
}
return contracts.ValidationResult{
Approved: false, ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid scene description source references", issues),
Approved: false,
ReasonCode: ReasonCode,
Message: diagnostics.Aggregate("invalid scene description source references", issues),
CorrectionGuidance: "Return exactly one scene description for the supplied chunk, using that chunk's scene identity and complete source range.",
}, nil
}

View File

@@ -98,7 +98,7 @@ func rejection(unknown []unknownSpell) contracts.ValidationResult {
issues = append(issues, issue)
}
message := rejectionMessage(issues, len(unknown)-len(issues))
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Use recognized D&D spell names supported by the supplied transcript evidence, and omit any candidate that is not a spell cast."}
}
func truncateDisplayedName(name string) string {

View File

@@ -54,6 +54,9 @@ func TestValidatorRejectsMultipleUnknownCastsInStableOrder(t *testing.T) {
if result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("Validate() = %#v, want unknown-spell rejection", result)
}
if err := contracts.ValidateValidationResult(result); err != nil {
t.Fatalf("ValidateValidationResult() error = %v for %#v", err, result)
}
if want := `spell_casts[0].spell "Unknown First", spell_casts[2].spell "Unknown Second"`; !strings.Contains(result.Message, want) {
t.Fatalf("message = %q, want %q", result.Message, want)
}

View File

@@ -75,5 +75,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete spell-cast list with a nonblank spell name and caster plus valid source references for every cast."}
}

View File

@@ -111,5 +111,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
func rejection(message string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return spell casts whose source references identify valid transcript ranges within the supplied extraction chunk and directly support the named spell and caster."}
}

View File

@@ -18,7 +18,7 @@ func NewChunk(Options) *ChunkValidator { return &ChunkValidator{} }
func NewTyped[T any](Options) *TypedValidator[T] { return &TypedValidator[T]{} }
func rejection() contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "output rejected by always-reject validator"}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "output rejected by always-reject validator", CorrectionGuidance: "Return one complete replacement response that satisfies the configured validation requirements."}
}
func (v *ChunkValidator) Name() string { return Key }
func (v *ChunkValidator) ExecutionClass() contracts.ExecutionClass {

View File

@@ -31,7 +31,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.SerializedValidati
func validate(content []byte) contracts.ValidationResult {
if !json.Valid(content) {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeInvalidJSON, Message: "payload is not valid JSON"}
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeInvalidJSON, Message: "payload is not valid JSON", CorrectionGuidance: "Return one complete replacement response encoded as valid JSON."}
}
return contracts.ValidationResult{Approved: true}
}

View File

@@ -56,14 +56,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.SerializedValidati
}
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(req.Content))
if err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeInvalidJSON, Message: "payload is not valid JSON"}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeInvalidJSON, Message: "payload is not valid JSON", CorrectionGuidance: "Return one complete replacement response encoded as valid JSON."}, nil
}
schema, err := v.compiledSchema(req.Schema)
if err != nil {
return contracts.ValidationResult{}, err
}
if err := schema.Validate(instance); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeSchemaInvalid, Message: "payload does not conform to response schema"}, nil
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeSchemaInvalid, Message: "payload does not conform to response schema", CorrectionGuidance: "Return one complete JSON replacement that conforms exactly to the supplied response schema."}, nil
}
return contracts.ValidationResult{Approved: true}, nil
}