Improve D&D validation reliability
This commit is contained in:
@@ -16,7 +16,8 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"turn_kind": {
|
"turn_kind": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"enum": ["turn", "reaction", "legendary_action", "lair_action", "other"]
|
||||||
},
|
},
|
||||||
"source_refs": {
|
"source_refs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
"required": ["name", "kind", "source_refs"],
|
"required": ["name", "kind", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"kind": {"type": "string"},
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["engaged", "killed", "fled", "captured", "incapacitated"]
|
||||||
|
},
|
||||||
"source_refs": {
|
"source_refs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"enum": ["mentioned", "noncombat_presence", "dialogue", "combat_ally", "combat_opponent", "other"]
|
||||||
},
|
},
|
||||||
"source_refs": {
|
"source_refs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"required": ["kind", "title", "summary"],
|
"required": ["kind", "title", "summary"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string"
|
"type": "string",
|
||||||
|
"enum": ["combat", "narrative", "recap", "meta"]
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@@ -209,12 +209,30 @@ wrong-direction rejection, execution failure, producer-correction success,
|
|||||||
added calls, latency, and token use together. A structurally successful
|
added calls, latency, and token use together. A structurally successful
|
||||||
provider run alone is not evidence that the validator should become a default.
|
provider run alone is not evidence that the validator should become a default.
|
||||||
|
|
||||||
Every D&D rejection describes the correction in transcript-grounded domain
|
Every producer-correctable D&D rejection describes all currently detectable
|
||||||
terms, using contextual names, artifact fields, and source segment ranges when
|
corrections in transcript-grounded domain terms, using contextual names,
|
||||||
useful. The guidance must not ask the model to reproduce durable entity IDs,
|
model-owned artifact fields, and source segment ranges when useful. Validators
|
||||||
hashes, validator module keys, or reason codes. Those identifiers remain in
|
collect independent record defects in one pass so one retry does not merely
|
||||||
ordinary validation provenance; only the actionable semantic guidance is
|
reveal the next issue. Shared D&D diagnostic helpers keep repeated rules and
|
||||||
eligible for the correction prompt.
|
record descriptions stable, de-duplicated, and bounded; each artifact family
|
||||||
|
continues to own the semantic rule and its prose.
|
||||||
|
|
||||||
|
Operator diagnostics and model guidance are separate products of the same
|
||||||
|
assessment. Operator messages may use typed paths, reason details, and opaque
|
||||||
|
application identities. Correction guidance must not copy those messages or
|
||||||
|
ask the model to reproduce durable entity IDs, hashes, validator module keys,
|
||||||
|
reason codes, or Go field paths. A registry-normalization rejection instead
|
||||||
|
speaks in terms of the duplicate-group proposal response the normalizer can
|
||||||
|
actually revise. Normalization-only deterministic invariants retain useful
|
||||||
|
operator detail but do not imply that a model controls derived ordering or
|
||||||
|
identity. Only bounded actionable semantic guidance is eligible for a
|
||||||
|
correction prompt.
|
||||||
|
|
||||||
|
Private LLM schemas use simple enums for closed categorical fields when the
|
||||||
|
provider-compatible shape can express the rule directly. Deterministic typed
|
||||||
|
validators retain the same checks as defense in depth and for non-LLM
|
||||||
|
producers. Private schemas keep every property required and avoid optional
|
||||||
|
properties, `uniqueItems`, and conditional cross-field logic.
|
||||||
|
|
||||||
Item-occurrence shape validation groups repeated holder mistakes by occurrence
|
Item-occurrence shape validation groups repeated holder mistakes by occurrence
|
||||||
kind and gives the producer the required JSON null/non-null relationship. It
|
kind and gives the producer the required JSON null/non-null relationship. It
|
||||||
@@ -228,6 +246,14 @@ the same comparison identity within one scene-scoped result. Normalization may
|
|||||||
combine results from distinct scenes, so it intentionally does not apply that
|
combine results from distinct scenes, so it intentionally does not apply that
|
||||||
rule. Configuration owns the exact validator key and chain position.
|
rule. Configuration owns the exact validator key and chain position.
|
||||||
|
|
||||||
|
Extraction source-reference validators share one full-span chunk-containment
|
||||||
|
policy. After ordinary reference validity succeeds, the policy resolves both
|
||||||
|
endpoints through document order and requires every source unit in the
|
||||||
|
inclusive range to be present in the current chunk. It does not assume numeric
|
||||||
|
unit-ID ordering, mutate input, or weaken wrong-source and unresolved-reference
|
||||||
|
validation. Scene descriptions remain separate because their validator owns an
|
||||||
|
exact one-scene range contract rather than general extraction containment.
|
||||||
|
|
||||||
Normalizers are deterministic for spells, combat turns, item occurrences, NPC
|
Normalizers are deterministic for spells, combat turns, item occurrences, NPC
|
||||||
occurrences, scene descriptions, enemy events, and location occurrences. They
|
occurrences, scene descriptions, enemy events, and location occurrences. They
|
||||||
canonicalize display values and evidence, use source-document order for stable
|
canonicalize display values and evidence, use source-document order for stable
|
||||||
|
|||||||
240
docs/roadmap/dnd-validation-reliability.md
Normal file
240
docs/roadmap/dnd-validation-reliability.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# D&D Validation Reliability Hardening
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Improve the correctness and efficiency of D&D validation retries by giving the
|
||||||
|
producer complete, contextual, semantically useful correction guidance; moving
|
||||||
|
simple closed-value checks into compatible LLM-facing schemas; and removing
|
||||||
|
duplicated source-range containment logic. The work should make one retry more
|
||||||
|
likely to repair every detectable defect without weakening deterministic
|
||||||
|
validation or exposing internal identifiers to a model.
|
||||||
|
|
||||||
|
## Current State And Findings
|
||||||
|
|
||||||
|
The validation architecture is coherent and the major safety boundaries are in
|
||||||
|
place. Validators run through the framework-managed retry protocol, model-facing
|
||||||
|
guidance is distinct from reason codes and operator diagnostics, schemas use
|
||||||
|
provider-compatible required fields, and D&D extraction adapters canonically
|
||||||
|
order safely resolvable source-reference endpoints before validation. The
|
||||||
|
review nevertheless found four opportunities to make that architecture more
|
||||||
|
consistent and effective.
|
||||||
|
|
||||||
|
### Generic correction guidance
|
||||||
|
|
||||||
|
Most rejecting D&D validators produce detailed operator-facing messages but
|
||||||
|
pair them with a fixed, general correction sentence. The framework correctly
|
||||||
|
constructs retry text from `CorrectionGuidance` alone, so the model does not see
|
||||||
|
the internal diagnostic—and therefore often does not learn which records were
|
||||||
|
wrong or how each record must change. Item-occurrence shape validation and the
|
||||||
|
combat-semantics validator already demonstrate the intended contextual pattern.
|
||||||
|
|
||||||
|
Passing the operator message through unchanged is not an acceptable fix.
|
||||||
|
Operator messages may contain Go paths, array indexes that do not reliably map
|
||||||
|
back to canonicalized model output, reason codes, validator terminology, or
|
||||||
|
opaque application identities. Those details are useful for diagnosis but are
|
||||||
|
not appropriate model context.
|
||||||
|
|
||||||
|
### Missing closed-value schema constraints
|
||||||
|
|
||||||
|
Four private LLM response schemas describe categorical fields as unconstrained
|
||||||
|
strings even though their prompts, durable artifact schemas, and deterministic
|
||||||
|
validators define closed value sets:
|
||||||
|
|
||||||
|
- `combat-turns`: `turn_kind`;
|
||||||
|
- `enemy-events`: `kind`;
|
||||||
|
- `npc-occurrences`: `kind`; and
|
||||||
|
- `scene-descriptions`: `kind`.
|
||||||
|
|
||||||
|
This defers an inexpensive structural check until the outer semantic-validation
|
||||||
|
loop. Item and location occurrences already use the preferable private-schema
|
||||||
|
enum pattern.
|
||||||
|
|
||||||
|
### First-defect validation
|
||||||
|
|
||||||
|
Spell shape validation returns after the first invalid cast field, and enemy
|
||||||
|
engagement validation returns after the first duplicated engagement subject.
|
||||||
|
When multiple defects exist, each retry can therefore reveal only one of them.
|
||||||
|
That needlessly consumes the bounded producer-attempt budget and makes
|
||||||
|
correction less reliable for smaller models.
|
||||||
|
|
||||||
|
### Duplicated source-range containment
|
||||||
|
|
||||||
|
Nine extraction source-reference validators independently determine whether a
|
||||||
|
citation belongs to the current chunk. Five validate every source unit spanned
|
||||||
|
by a reference, while four check only the endpoints. Those behaviors are
|
||||||
|
equivalent for today's contiguous materialized chunks, but the duplicated
|
||||||
|
implementations and different apparent contracts create a drift risk.
|
||||||
|
Scene-description validation has a separate exact-one-scene range contract and
|
||||||
|
should remain specialized.
|
||||||
|
|
||||||
|
## Target End State
|
||||||
|
|
||||||
|
- Every producer-correctable D&D rejection supplies bounded contextual guidance
|
||||||
|
that identifies all affected records using transcript-grounded names, source
|
||||||
|
ranges, and response fields as appropriate, explains each semantic defect,
|
||||||
|
and requests one complete corrected replacement.
|
||||||
|
- Model-facing guidance never contains reason codes, validator keys, Go field
|
||||||
|
paths, opaque or hash-derived entity IDs, or raw internal error text.
|
||||||
|
Operator diagnostics remain detailed and separately available.
|
||||||
|
- Every detectable issue in a candidate is collected in one validator pass,
|
||||||
|
subject to the repository's established diagnostic bounds. A retry is not
|
||||||
|
spent merely to reveal the next defect.
|
||||||
|
- Closed categorical fields are constrained by compatible enums in the private
|
||||||
|
LLM schemas. Deterministic typed validators retain the same rules as
|
||||||
|
defense-in-depth and as protection for non-LLM producers and later stages.
|
||||||
|
- One D&D-owned helper implements full-span chunk containment for extraction
|
||||||
|
evidence. All applicable source-reference validators use it and no
|
||||||
|
module-local endpoint-only or coverage implementation remains.
|
||||||
|
- Validator results remain immutable, deterministic, bounded, and suitable for
|
||||||
|
the existing feedback-aware replacement-request protocol. Retry budgets,
|
||||||
|
warning policy, durable artifact schemas, and accepted output semantics do
|
||||||
|
not change.
|
||||||
|
|
||||||
|
## Required Work
|
||||||
|
|
||||||
|
### Contextual correction guidance
|
||||||
|
|
||||||
|
Audit each rejecting D&D validator according to the stage that can actually
|
||||||
|
correct its result:
|
||||||
|
|
||||||
|
- Extraction validators should build model-facing issue descriptions from the
|
||||||
|
candidate and its transcript context. Shape, registry-membership, identity,
|
||||||
|
catalog, engagement, and source-reference validators should name the
|
||||||
|
contextual artifact and cited range where useful, state the invalid value or
|
||||||
|
relationship in plain language, and state the required replacement shape.
|
||||||
|
- LLM-backed NPC, item, and location registry reconciliation should continue to
|
||||||
|
translate its typed proposal issues through the shared category-to-prose
|
||||||
|
renderer, including any domain-specific supplement.
|
||||||
|
- Normalization-only deterministic invariant validators should retain useful
|
||||||
|
operator diagnostics. They should not claim that a producer can directly
|
||||||
|
repair deterministically derived ordering, identity, or normalization state.
|
||||||
|
If such a rejection can reach a feedback-capable producer, guidance must be
|
||||||
|
expressed only in terms of the source candidate that producer controls.
|
||||||
|
- LLM-backed semantic validators may continue using their verdict explanation
|
||||||
|
when it is bounded and semantically meaningful.
|
||||||
|
|
||||||
|
Introduce a small D&D-shared diagnostic utility only for demonstrated common
|
||||||
|
mechanics such as stable grouping, de-duplication, contextual source-range
|
||||||
|
rendering, and bounded correction aggregation. Domain validators must continue
|
||||||
|
to own the meaning and prose of their rules. The generic pipeline must not gain
|
||||||
|
D&D knowledge, and operator `Message` values must never be mechanically copied
|
||||||
|
into `CorrectionGuidance`.
|
||||||
|
|
||||||
|
Update validator policy fingerprints wherever correction behavior changes so
|
||||||
|
checkpoints created under generic feedback are not reused as though the policy
|
||||||
|
were identical.
|
||||||
|
|
||||||
|
### Private-schema enum guardrails
|
||||||
|
|
||||||
|
Add the existing supported value sets to the four private LLM schemas:
|
||||||
|
|
||||||
|
- `assets/dnd/combat-turns/schemas/dnd_combat_turns_llm.v1.json`;
|
||||||
|
- `assets/dnd/enemy-events/schemas/dnd_enemy_events_llm.v1.json`;
|
||||||
|
- `assets/dnd/npc-occurrences/schemas/dnd_npc_occurrences_llm.v1.json`; and
|
||||||
|
- `assets/dnd/scene-descriptions/schemas/dnd_scene_descriptions_llm.v1.json`.
|
||||||
|
|
||||||
|
Use the exact values owned by the corresponding durable contract and typed
|
||||||
|
domain constants. Keep all object properties required, retain current nullable
|
||||||
|
types where present, and do not introduce `uniqueItems`, optional properties,
|
||||||
|
or conditional schema logic. Revise schema tests that currently accept unknown
|
||||||
|
values, and rely on computed asset fingerprints to invalidate incompatible
|
||||||
|
LLM-output checkpoints. Keep deterministic enum validation in place.
|
||||||
|
|
||||||
|
### Complete per-attempt issue collection
|
||||||
|
|
||||||
|
Refactor spell shape and enemy engagement validation to inspect the complete
|
||||||
|
candidate and collect every detectable violation before returning. Build the
|
||||||
|
operator diagnostic and contextual correction request from the same evaluated
|
||||||
|
issue set while preserving their different audiences. De-duplicate repeated
|
||||||
|
semantic instructions, retain enough contextual identification for every
|
||||||
|
affected record, and use the established bounded diagnostic behavior rather
|
||||||
|
than an unbounded error string.
|
||||||
|
|
||||||
|
Review the surrounding D&D validators while applying the contextual-guidance
|
||||||
|
change. Remove any additional accidental early exits that prevent independent
|
||||||
|
candidate defects from being reported together, but retain immediate returns
|
||||||
|
for request-level prerequisites whose absence makes further inspection unsafe
|
||||||
|
or meaningless.
|
||||||
|
|
||||||
|
### Shared full-span chunk containment
|
||||||
|
|
||||||
|
Add one helper under `internal/modules/dnd/shared` that determines whether a
|
||||||
|
source reference's complete document-ordered span is contained by a chunk. The
|
||||||
|
helper must:
|
||||||
|
|
||||||
|
- validate source identity;
|
||||||
|
- resolve endpoints through `source.DocumentIndex` rather than numeric-ID
|
||||||
|
assumptions;
|
||||||
|
- require every unit in the inclusive span to be present in the chunk;
|
||||||
|
- handle nil or unresolved inputs without panic;
|
||||||
|
- avoid mutating the source, chunk, or reference; and
|
||||||
|
- leave source-reference validity and error wording to the consuming validator.
|
||||||
|
|
||||||
|
Use the helper from the spell, NPC-registry, NPC-occurrence, item-registry,
|
||||||
|
item-occurrence, location-registry, location-occurrence, combat-turn, and
|
||||||
|
enemy-event source-reference validators. Keep the scene-description exact-range
|
||||||
|
validator separate because it enforces a materially different contract.
|
||||||
|
|
||||||
|
## Testing And Documentation
|
||||||
|
|
||||||
|
Add lean offline behavioral coverage at the narrowest stable boundary:
|
||||||
|
|
||||||
|
- correction tests should prove that multiple contextual defects produce one
|
||||||
|
actionable, bounded request and that internal identifiers and diagnostic
|
||||||
|
syntax are absent; they should not snapshot exact prose or message length;
|
||||||
|
- schema tests should prove rejection of representative unsupported categorical
|
||||||
|
values and acceptance of the supported sets without duplicating every
|
||||||
|
provider behavior;
|
||||||
|
- spell-shape and engagement tests should prove that independent defects are
|
||||||
|
reported together;
|
||||||
|
- the shared containment helper should own the full case matrix, including
|
||||||
|
partial spans, non-monotonic unit IDs, wrong sources, unresolved endpoints,
|
||||||
|
and nil inputs; consuming validators need only enough coverage to prove they
|
||||||
|
use the common policy; and
|
||||||
|
- existing validator tests should be simplified when the shared helper makes
|
||||||
|
module-local cases redundant.
|
||||||
|
|
||||||
|
Update `docs/internal/dnd.md` to document the durable conventions for
|
||||||
|
contextual correction guidance, complete issue collection, private-schema enum
|
||||||
|
guardrails, and shared full-span extraction containment. Update
|
||||||
|
`docs/internal/pipeline.md` only if framework behavior changes; the intended
|
||||||
|
work applies its existing contract and should normally require only a link or
|
||||||
|
no change. No new ADR is required because ADR-0014 and the architecture policy
|
||||||
|
already decide the separation between semantic guidance and internal
|
||||||
|
diagnostics. Create an ADR only if implementation requires changing that
|
||||||
|
framework-level decision.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Passing operator diagnostics or internal errors directly to an LLM.
|
||||||
|
- Exposing durable IDs, hashes, reason codes, validator keys, or raw provider
|
||||||
|
responses in correction text.
|
||||||
|
- Changing stage retry counts, PromptKit structural-repair budgets, validator
|
||||||
|
failure policy, or warning classification.
|
||||||
|
- Silently repairing domain-semantic defects or weakening deterministic
|
||||||
|
validators after adding schema guardrails.
|
||||||
|
- Adding provider-sensitive schema constructs beyond simple enums.
|
||||||
|
- Moving D&D-specific behavior into the generic pipeline framework.
|
||||||
|
- Generalizing the scene-description exact-range contract into the shared
|
||||||
|
extraction containment helper.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Every producer-correctable D&D rejection reviewed in this work gives the next
|
||||||
|
attempt all currently detectable, actionable corrections in contextual prose.
|
||||||
|
- No model-facing correction request contains an opaque entity ID, hash,
|
||||||
|
validator key, reason code, Go-style field path, or unfiltered operator error.
|
||||||
|
- Spell shape and enemy engagement validation aggregate independent defects in
|
||||||
|
one pass and keep outputs immutable.
|
||||||
|
- The four private LLM schemas reject unsupported categorical values, retain
|
||||||
|
provider-compatible required-only shapes, and leave durable v1 contracts
|
||||||
|
unchanged.
|
||||||
|
- All nine applicable extraction source-reference validators use one D&D-shared
|
||||||
|
full-span containment policy; scene descriptions retain their specialized
|
||||||
|
exact-range check.
|
||||||
|
- Validator and asset fingerprints change wherever their effective policy
|
||||||
|
changes, preventing reuse of stale checkpoints.
|
||||||
|
- Canonical internal documentation records the conventions future D&D
|
||||||
|
validators must follow.
|
||||||
|
- Focused tests, `go test ./...`, `go vet ./...`, and
|
||||||
|
`go build ./cmd/notarius` pass in a supported development environment.
|
||||||
@@ -66,7 +66,7 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
|||||||
func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
||||||
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||||
{
|
{
|
||||||
Actor: " ", TurnKind: "unsupported",
|
Actor: " ", TurnKind: "turn",
|
||||||
SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||||
},
|
},
|
||||||
}}}
|
}}}
|
||||||
@@ -75,7 +75,7 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
|||||||
t.Fatalf("Extract() error = %v, want nil for candidate values", err)
|
t.Fatalf("Extract() error = %v, want nil for candidate values", err)
|
||||||
}
|
}
|
||||||
turn := result.Value.CombatTurns[0]
|
turn := result.Value.CombatTurns[0]
|
||||||
if turn.Actor != " " || turn.TurnKind != "unsupported" {
|
if turn.Actor != " " || turn.TurnKind != "turn" {
|
||||||
t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn)
|
t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn)
|
||||||
}
|
}
|
||||||
if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) {
|
if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) {
|
func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) {
|
||||||
@@ -37,7 +39,7 @@ func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *testing.T) {
|
func TestResponseSchemaLeavesNonCategoricalSemanticsToDeterministicValidators(t *testing.T) {
|
||||||
schema, err := loadResponseSchema()
|
schema, err := loadResponseSchema()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -45,7 +47,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
|
|||||||
semanticCandidate := validCombatResponse()
|
semanticCandidate := validCombatResponse()
|
||||||
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
|
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
|
||||||
turn["actor"] = ""
|
turn["actor"] = ""
|
||||||
turn["turn_kind"] = "unsupported"
|
|
||||||
ref := turn["source_refs"].([]any)[0].(map[string]any)
|
ref := turn["source_refs"].([]any)[0].(map[string]any)
|
||||||
ref["start_unit_id"] = 0
|
ref["start_unit_id"] = 0
|
||||||
ref["end_unit_id"] = -1
|
ref["end_unit_id"] = -1
|
||||||
@@ -54,7 +55,7 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||||
t.Fatalf("private schema rejected validator-owned semantics: %v", err)
|
t.Fatalf("private schema rejected non-categorical validator-owned semantics: %v", err)
|
||||||
}
|
}
|
||||||
turn["source_refs"] = []any{}
|
turn["source_refs"] = []any{}
|
||||||
content, err = json.Marshal(semanticCandidate)
|
content, err = json.Marshal(semanticCandidate)
|
||||||
@@ -79,6 +80,7 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
|
|||||||
{name: "wrong actor type", mutate: func(turn map[string]any) { turn["actor"] = 1 }},
|
{name: "wrong actor type", mutate: func(turn map[string]any) { turn["actor"] = 1 }},
|
||||||
{name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }},
|
{name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }},
|
||||||
{name: "missing source refs", mutate: func(turn map[string]any) { delete(turn, "source_refs") }},
|
{name: "missing source refs", mutate: func(turn map[string]any) { delete(turn, "source_refs") }},
|
||||||
|
{name: "unsupported turn kind", mutate: func(turn map[string]any) { turn["turn_kind"] = "unsupported" }},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
candidate := validCombatResponse()
|
candidate := validCombatResponse()
|
||||||
@@ -94,6 +96,24 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseSchemaAcceptsEverySupportedTurnKind(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, kind := range []string{string(dnd.CombatTurnKindTurn), string(dnd.CombatTurnKindReaction), string(dnd.CombatTurnKindLegendaryAction), string(dnd.CombatTurnKindLairAction), string(dnd.CombatTurnKindOther)} {
|
||||||
|
candidate := validCombatResponse()
|
||||||
|
candidate["combat_turns"].([]any)[0].(map[string]any)["turn_kind"] = kind
|
||||||
|
content, err := json.Marshal(candidate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||||
|
t.Fatalf("supported turn kind %q was rejected: %v", kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
||||||
first, err := loadResponseSchema()
|
first, err := loadResponseSchema()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -59,14 +59,14 @@ func TestExtractMapsEnemyEventsInSourceOrder(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtractPreservesSemanticCandidatesAndResponseOwnership(t *testing.T) {
|
func TestExtractPreservesSemanticCandidatesAndResponseOwnership(t *testing.T) {
|
||||||
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{{
|
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{{
|
||||||
Name: " ", Kind: "unsupported", SourceRefs: []enemySourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
Name: " ", Kind: "engaged", SourceRefs: []enemySourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||||
}}}}
|
}}}}
|
||||||
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
|
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
event := result.Value.Events[0]
|
event := result.Value.Events[0]
|
||||||
if event.Name != " " || event.Kind != "unsupported" || event.SourceRefs[0] != (source.SourceRef{SourceID: "combat-session", StartUnitID: 99}) {
|
if event.Name != " " || event.Kind != "engaged" || event.SourceRefs[0] != (source.SourceRef{SourceID: "combat-session", StartUnitID: 99}) {
|
||||||
t.Fatalf("semantic candidate = %#v", event)
|
t.Fatalf("semantic candidate = %#v", event)
|
||||||
}
|
}
|
||||||
result.Value.Events[0].SourceRefs[0].StartUnitID = 7
|
result.Value.Events[0].SourceRefs[0].StartUnitID = 7
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResponseSchemaDefinesPrivateStructuralBoundary(t *testing.T) {
|
func TestResponseSchemaDefinesPrivateStructuralBoundary(t *testing.T) {
|
||||||
@@ -28,13 +30,12 @@ func TestResponseSchemaDefinesPrivateStructuralBoundary(t *testing.T) {
|
|||||||
semantic := validEnemyResponse()
|
semantic := validEnemyResponse()
|
||||||
event := semantic["events"].([]any)[0].(map[string]any)
|
event := semantic["events"].([]any)[0].(map[string]any)
|
||||||
event["name"] = ""
|
event["name"] = ""
|
||||||
event["kind"] = "unsupported"
|
|
||||||
ref := event["source_refs"].([]any)[0].(map[string]any)
|
ref := event["source_refs"].([]any)[0].(map[string]any)
|
||||||
ref["start_unit_id"] = 0
|
ref["start_unit_id"] = 0
|
||||||
ref["end_unit_id"] = -1
|
ref["end_unit_id"] = -1
|
||||||
content, err = json.Marshal(semantic)
|
content, err = json.Marshal(semantic)
|
||||||
if err != nil || validateEnemySchema(content, schema.JSONSchema) != nil {
|
if err != nil || validateEnemySchema(content, schema.JSONSchema) != nil {
|
||||||
t.Fatalf("validator-owned semantics were rejected: %v", err)
|
t.Fatalf("non-categorical validator-owned semantics were rejected: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ func TestResponseSchemaRejectsInvalidStructure(t *testing.T) {
|
|||||||
for _, mutate := range []func(map[string]any){
|
for _, mutate := range []func(map[string]any){
|
||||||
func(event map[string]any) { delete(event, "name") },
|
func(event map[string]any) { delete(event, "name") },
|
||||||
func(event map[string]any) { event["kind"] = 1 },
|
func(event map[string]any) { event["kind"] = 1 },
|
||||||
|
func(event map[string]any) { event["kind"] = "unsupported" },
|
||||||
func(event map[string]any) { event["unexpected"] = true },
|
func(event map[string]any) { event["unexpected"] = true },
|
||||||
func(event map[string]any) { event["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session" },
|
func(event map[string]any) { event["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session" },
|
||||||
} {
|
} {
|
||||||
@@ -70,6 +72,24 @@ func TestResponseSchemaRejectsInvalidStructure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseSchemaAcceptsEverySupportedKind(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, kind := range []string{string(dnd.EnemyEventKindEngaged), string(dnd.EnemyEventKindKilled), string(dnd.EnemyEventKindFled), string(dnd.EnemyEventKindCaptured), string(dnd.EnemyEventKindIncapacitated)} {
|
||||||
|
candidate := validEnemyResponse()
|
||||||
|
candidate["events"].([]any)[0].(map[string]any)["kind"] = kind
|
||||||
|
content, err := json.Marshal(candidate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateEnemySchema(content, schema.JSONSchema); err != nil {
|
||||||
|
t.Fatalf("supported enemy-event kind %q was rejected: %v", kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validEnemyResponse() map[string]any {
|
func validEnemyResponse() map[string]any {
|
||||||
return map[string]any{"events": []any{map[string]any{
|
return map[string]any{"events": []any{map[string]any{
|
||||||
"name": "Ashfang", "kind": "engaged", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}},
|
"name": "Ashfang", "kind": "engaged", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|||||||
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
|
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
|
||||||
{Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 2)},
|
{Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 2)},
|
||||||
{Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
{Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
|
{Name: "Invalid", Kind: "other", SourceRefs: occurrenceRefs(0, 0)},
|
||||||
}}}
|
}}}
|
||||||
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -44,7 +44,7 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|||||||
dnd.NPCOccurrenceKindCombatAlly,
|
dnd.NPCOccurrenceKindCombatAlly,
|
||||||
dnd.NPCOccurrenceKindCombatOpponent,
|
dnd.NPCOccurrenceKindCombatOpponent,
|
||||||
dnd.NPCOccurrenceKindOther,
|
dnd.NPCOccurrenceKindOther,
|
||||||
"unsupported",
|
dnd.NPCOccurrenceKindOther,
|
||||||
}) {
|
}) {
|
||||||
t.Fatalf("occurrence kinds = %#v", got)
|
t.Fatalf("occurrence kinds = %#v", got)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|||||||
if id := result.Value.Occurrences[0].NPCID; id != identity.DeriveID("Mentioned") {
|
if id := result.Value.Occurrences[0].NPCID; id != identity.DeriveID("Mentioned") {
|
||||||
t.Fatalf("durable NPC ID = %q, want registry identity", id)
|
t.Fatalf("durable NPC ID = %q, want registry identity", id)
|
||||||
}
|
}
|
||||||
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != dnd.NPCOccurrenceKindOther || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
||||||
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
|
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
|
||||||
}
|
}
|
||||||
if len(client.requests) != 1 {
|
if len(client.requests) != 1 {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
||||||
@@ -29,7 +31,6 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
|||||||
semanticCandidate := validOccurrenceResponse()
|
semanticCandidate := validOccurrenceResponse()
|
||||||
occurrence := semanticCandidate["occurrences"].([]any)[0].(map[string]any)
|
occurrence := semanticCandidate["occurrences"].([]any)[0].(map[string]any)
|
||||||
occurrence["name"] = ""
|
occurrence["name"] = ""
|
||||||
occurrence["kind"] = "unsupported"
|
|
||||||
ref := occurrence["source_refs"].([]any)[0].(map[string]any)
|
ref := occurrence["source_refs"].([]any)[0].(map[string]any)
|
||||||
ref["start_unit_id"] = 0
|
ref["start_unit_id"] = 0
|
||||||
ref["end_unit_id"] = -1
|
ref["end_unit_id"] = -1
|
||||||
@@ -38,12 +39,13 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||||
t.Fatalf("schema rejected validator-owned semantics: %v", err)
|
t.Fatalf("schema rejected non-categorical validator-owned semantics: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mutate := range []func(map[string]any){
|
for _, mutate := range []func(map[string]any){
|
||||||
func(record map[string]any) { delete(record, "name") },
|
func(record map[string]any) { delete(record, "name") },
|
||||||
func(record map[string]any) { record["kind"] = 1 },
|
func(record map[string]any) { record["kind"] = 1 },
|
||||||
|
func(record map[string]any) { record["kind"] = "unsupported" },
|
||||||
func(record map[string]any) { record["npc_id"] = "npc:sha256:opaque" },
|
func(record map[string]any) { record["npc_id"] = "npc:sha256:opaque" },
|
||||||
func(record map[string]any) { record["unexpected"] = true },
|
func(record map[string]any) { record["unexpected"] = true },
|
||||||
func(record map[string]any) {
|
func(record map[string]any) {
|
||||||
@@ -79,6 +81,24 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseSchemaAcceptsEverySupportedKind(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, kind := range []string{string(dnd.NPCOccurrenceKindMentioned), string(dnd.NPCOccurrenceKindNoncombatPresence), string(dnd.NPCOccurrenceKindDialogue), string(dnd.NPCOccurrenceKindCombatAlly), string(dnd.NPCOccurrenceKindCombatOpponent), string(dnd.NPCOccurrenceKindOther)} {
|
||||||
|
candidate := validOccurrenceResponse()
|
||||||
|
candidate["occurrences"].([]any)[0].(map[string]any)["kind"] = kind
|
||||||
|
content, err := json.Marshal(candidate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||||
|
t.Fatalf("supported NPC-occurrence kind %q was rejected: %v", kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validOccurrenceResponse() map[string]any {
|
func validOccurrenceResponse() map[string]any {
|
||||||
return map[string]any{"occurrences": []any{map[string]any{
|
return map[string]any{"occurrences": []any{map[string]any{
|
||||||
"name": "Mira Thorn", "kind": "dialogue",
|
"name": "Mira Thorn", "kind": "dialogue",
|
||||||
|
|||||||
@@ -75,18 +75,18 @@ func TestExtractReturnsSemanticallyInvalidResponseForDeterministicValidation(t *
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadResponseSchema() error = %v", err)
|
t.Fatalf("loadResponseSchema() error = %v", err)
|
||||||
}
|
}
|
||||||
if err := validateJSONSchema(t, map[string]any{"kind": "unrecognized", "title": " ", "summary": ""}, schema.JSONSchema); err != nil {
|
if err := validateJSONSchema(t, map[string]any{"kind": "narrative", "title": " ", "summary": ""}, schema.JSONSchema); err != nil {
|
||||||
t.Fatalf("semantic candidate rejected by private schema: %v", err)
|
t.Fatalf("semantic candidate rejected by private schema: %v", err)
|
||||||
}
|
}
|
||||||
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{
|
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{
|
||||||
Kind: dnd.SceneKind("unrecognized"), Title: " ", Summary: "",
|
Kind: dnd.SceneKindNarrative, Title: " ", Summary: "",
|
||||||
}}
|
}}
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Extract() error = %v, want nil", err)
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
scene := result.Value.Scenes[0]
|
scene := result.Value.Scenes[0]
|
||||||
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "" || scene.Summary != "" {
|
if scene.Kind != dnd.SceneKindNarrative || scene.Title != "" || scene.Summary != "" {
|
||||||
t.Fatalf("scene = %#v, want semantic candidates returned for deterministic validation", scene)
|
t.Fatalf("scene = %#v, want semantic candidates returned for deterministic validation", scene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadResponseSchemaUsesStrictPrivateSceneDescriptionContract(t *testing.T) {
|
func TestLoadResponseSchemaUsesStrictPrivateSceneDescriptionContract(t *testing.T) {
|
||||||
@@ -30,7 +32,7 @@ func TestLoadResponseSchemaUsesStrictPrivateSceneDescriptionContract(t *testing.
|
|||||||
{name: "unknown framework field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "id": "assigned-later"}},
|
{name: "unknown framework field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "id": "assigned-later"}},
|
||||||
{name: "unknown application field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "source_ref": map[string]any{}}},
|
{name: "unknown application field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "source_ref": map[string]any{}}},
|
||||||
{name: "collection is not allowed", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "scenes": []any{}}},
|
{name: "collection is not allowed", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "scenes": []any{}}},
|
||||||
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}, valid: true},
|
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}},
|
||||||
{name: "empty title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}, valid: true},
|
{name: "empty title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}, valid: true},
|
||||||
{name: "empty summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}, valid: true},
|
{name: "empty summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}, valid: true},
|
||||||
{name: "wrong kind type", response: map[string]any{"kind": 7, "title": "Ambush", "summary": "Bandits strike."}},
|
{name: "wrong kind type", response: map[string]any{"kind": 7, "title": "Ambush", "summary": "Bandits strike."}},
|
||||||
@@ -64,6 +66,19 @@ func TestResponseSchemaIsMutationSafeAndDiagnosticsRedactContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResponseSchemaAcceptsEverySupportedKind(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, kind := range []string{string(dnd.SceneKindCombat), string(dnd.SceneKindNarrative), string(dnd.SceneKindRecap), string(dnd.SceneKindMeta)} {
|
||||||
|
response := map[string]any{"kind": kind, "title": "Title", "summary": "Summary"}
|
||||||
|
if err := validateJSONSchema(t, response, schema.JSONSchema); err != nil {
|
||||||
|
t.Fatalf("supported scene kind %q was rejected: %v", kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validateJSONSchema(t *testing.T, instance map[string]any, schemaContent []byte) error {
|
func validateJSONSchema(t *testing.T, instance map[string]any, schemaContent []byte) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
content, err := json.Marshal(instance)
|
content, err := json.Marshal(instance)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
frameworkdiagnostics "gitea.maximumdirect.net/eric/notarius/internal/framework/diagnostics"
|
frameworkdiagnostics "gitea.maximumdirect.net/eric/notarius/internal/framework/diagnostics"
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,79 @@ type Finding struct {
|
|||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Corrections collects domain-owned model instructions and contextual record
|
||||||
|
// descriptions without coupling them to operator-facing diagnostics. Rules are
|
||||||
|
// emitted before affected records so the bounded result remains useful even
|
||||||
|
// when a large candidate exceeds the display budget.
|
||||||
|
type Corrections struct {
|
||||||
|
groups []correctionGroup
|
||||||
|
indexes map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
type correctionGroup struct {
|
||||||
|
rule string
|
||||||
|
records []string
|
||||||
|
recordsSeen map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add records one semantic rule and, when non-empty, one contextual record to
|
||||||
|
// which it applies. key is local grouping state and is never rendered.
|
||||||
|
func (c *Corrections) Add(key, rule, record string) {
|
||||||
|
if c.indexes == nil {
|
||||||
|
c.indexes = make(map[string]int)
|
||||||
|
}
|
||||||
|
index, ok := c.indexes[key]
|
||||||
|
if !ok {
|
||||||
|
index = len(c.groups)
|
||||||
|
c.indexes[key] = index
|
||||||
|
c.groups = append(c.groups, correctionGroup{rule: rule, recordsSeen: make(map[string]struct{})})
|
||||||
|
}
|
||||||
|
if record == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group := &c.groups[index]
|
||||||
|
if _, seen := group.recordsSeen[record]; seen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group.recordsSeen[record] = struct{}{}
|
||||||
|
group.records = append(group.records, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guidance returns one bounded correction request. It deliberately renders
|
||||||
|
// neither grouping keys nor operator diagnostics.
|
||||||
|
func (c Corrections) Guidance(prefix string) string {
|
||||||
|
issues := make([]string, 0, len(c.groups)*2)
|
||||||
|
for _, group := range c.groups {
|
||||||
|
issues = append(issues, group.rule)
|
||||||
|
}
|
||||||
|
for _, group := range c.groups {
|
||||||
|
issues = append(issues, group.records...)
|
||||||
|
}
|
||||||
|
return Aggregate(prefix, issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceRange describes cited transcript positions without exposing source
|
||||||
|
// identities or application entity IDs.
|
||||||
|
func SourceRange(refs []source.SourceRef) string {
|
||||||
|
if len(refs) == 0 {
|
||||||
|
return "without a cited source range"
|
||||||
|
}
|
||||||
|
description := SourceRefRange(refs[0])
|
||||||
|
if len(refs) > 1 {
|
||||||
|
description += fmt.Sprintf(" (first of %d cited ranges)", len(refs))
|
||||||
|
}
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceRefRange describes one transcript range without exposing its source
|
||||||
|
// identity.
|
||||||
|
func SourceRefRange(ref source.SourceRef) string {
|
||||||
|
if ref.StartUnitID == ref.EndUnitID {
|
||||||
|
return "at source unit " + strconv.Itoa(ref.StartUnitID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("at source units %d-%d", ref.StartUnitID, ref.EndUnitID)
|
||||||
|
}
|
||||||
|
|
||||||
// DataQualityResult converts accepted source-quality findings into bounded,
|
// DataQualityResult converts accepted source-quality findings into bounded,
|
||||||
// locally grouped advisories. These findings do not indicate process
|
// locally grouped advisories. These findings do not indicate process
|
||||||
// degradation.
|
// degradation.
|
||||||
|
|||||||
@@ -6,9 +6,40 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCorrectionsGroupRulesBeforeContextAndHideKeys(t *testing.T) {
|
||||||
|
var corrections Corrections
|
||||||
|
corrections.Add("internal-kind", "Use a supported kind.", "Affected goblin at source unit 4.")
|
||||||
|
corrections.Add("internal-kind", "Use a supported kind.", "Affected ogre at source units 8-9.")
|
||||||
|
corrections.Add("internal-kind", "Use a supported kind.", "Affected goblin at source unit 4.")
|
||||||
|
corrections.Add("name", "Provide a contextual name.", "Affected unnamed record at source unit 12.")
|
||||||
|
|
||||||
|
guidance := corrections.Guidance("Correct every record")
|
||||||
|
if strings.Contains(guidance, "internal-kind") {
|
||||||
|
t.Fatalf("Guidance() exposed grouping key: %q", guidance)
|
||||||
|
}
|
||||||
|
if strings.Count(guidance, "Use a supported kind.") != 1 || strings.Count(guidance, "Affected goblin") != 1 {
|
||||||
|
t.Fatalf("Guidance() did not de-duplicate rules and records: %q", guidance)
|
||||||
|
}
|
||||||
|
if strings.Index(guidance, "Use a supported kind.") > strings.Index(guidance, "Affected goblin") {
|
||||||
|
t.Fatalf("Guidance() = %q, want rules before records", guidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceRangeUsesOnlyTranscriptPositions(t *testing.T) {
|
||||||
|
refs := []source.SourceRef{
|
||||||
|
{SourceID: "opaque-source", StartUnitID: 8, EndUnitID: 10},
|
||||||
|
{SourceID: "opaque-source", StartUnitID: 12, EndUnitID: 12},
|
||||||
|
}
|
||||||
|
got := SourceRange(refs)
|
||||||
|
if strings.Contains(got, "opaque-source") || !strings.Contains(got, "8-10") || !strings.Contains(got, "first of 2") {
|
||||||
|
t.Fatalf("SourceRange() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
||||||
issues := make([]string, MaxIssues)
|
issues := make([]string, MaxIssues)
|
||||||
for index := range issues {
|
for index := range issues {
|
||||||
|
|||||||
48
internal/modules/dnd/shared/source_ref_coverage.go
Normal file
48
internal/modules/dnd/shared/source_ref_coverage.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
|
||||||
|
// ChunkCoverage is an immutable snapshot of the source units materialized in
|
||||||
|
// one extraction chunk.
|
||||||
|
type ChunkCoverage struct {
|
||||||
|
sourceID string
|
||||||
|
unitIDs map[int]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChunkCoverage snapshots chunk without retaining or mutating it.
|
||||||
|
func NewChunkCoverage(chunk *source.Chunk) ChunkCoverage {
|
||||||
|
if chunk == nil {
|
||||||
|
return ChunkCoverage{}
|
||||||
|
}
|
||||||
|
coverage := ChunkCoverage{
|
||||||
|
sourceID: chunk.SourceID,
|
||||||
|
unitIDs: make(map[int]struct{}, len(chunk.Units)),
|
||||||
|
}
|
||||||
|
for _, unit := range chunk.Units {
|
||||||
|
coverage.unitIDs[unit.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
return coverage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether ref identifies a valid inclusive span in index and
|
||||||
|
// every unit in that document-ordered span is present in the chunk snapshot.
|
||||||
|
func (c ChunkCoverage) Contains(index source.DocumentIndex, doc *source.SourceDocument, ref source.SourceRef) bool {
|
||||||
|
documentID, ok := index.DocumentID()
|
||||||
|
if !ok || doc == nil || doc.ID != documentID || c.sourceID == "" || ref.SourceID != c.sourceID || ref.SourceID != documentID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
start, startOK := index.Position(ref.StartUnitID)
|
||||||
|
end, endOK := index.Position(ref.EndUnitID)
|
||||||
|
if !startOK || !endOK || start > end {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for position := start; position <= end; position++ {
|
||||||
|
if position >= len(doc.Units) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, found := c.unitIDs[doc.Units[position].ID]; !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
69
internal/modules/dnd/shared/source_ref_coverage_test.go
Normal file
69
internal/modules/dnd/shared/source_ref_coverage_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChunkCoverageRequiresCompleteDocumentOrderedSpan(t *testing.T) {
|
||||||
|
doc := coverageDocument(30, 10, 20, 40)
|
||||||
|
index := source.NewDocumentIndex(doc)
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
chunk *source.Chunk
|
||||||
|
ref source.SourceRef
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "complete non-monotonic span", chunk: coverageChunk(doc.ID, 30, 10, 20), ref: coverageRef(doc.ID, 30, 20), want: true},
|
||||||
|
{name: "single unit", chunk: coverageChunk(doc.ID, 10), ref: coverageRef(doc.ID, 10, 10), want: true},
|
||||||
|
{name: "missing middle unit", chunk: coverageChunk(doc.ID, 30, 20), ref: coverageRef(doc.ID, 30, 20)},
|
||||||
|
{name: "only endpoints", chunk: coverageChunk(doc.ID, 30, 40), ref: coverageRef(doc.ID, 30, 40)},
|
||||||
|
{name: "reversed", chunk: coverageChunk(doc.ID, 30, 10), ref: coverageRef(doc.ID, 10, 30)},
|
||||||
|
{name: "wrong source", chunk: coverageChunk(doc.ID, 30), ref: coverageRef("other", 30, 30)},
|
||||||
|
{name: "missing endpoint", chunk: coverageChunk(doc.ID, 30), ref: coverageRef(doc.ID, 30, 999)},
|
||||||
|
{name: "nil chunk", ref: coverageRef(doc.ID, 30, 30)},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
coverage := NewChunkCoverage(test.chunk)
|
||||||
|
if got := coverage.Contains(index, doc, test.ref); got != test.want {
|
||||||
|
t.Fatalf("Contains() = %t, want %t", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if NewChunkCoverage(coverageChunk(doc.ID, 30)).Contains(source.DocumentIndex{}, nil, coverageRef(doc.ID, 30, 30)) {
|
||||||
|
t.Fatal("Contains() with nil document and zero index = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkCoverageDoesNotRetainMutableChunkState(t *testing.T) {
|
||||||
|
doc := coverageDocument(1, 2)
|
||||||
|
chunk := coverageChunk(doc.ID, 1, 2)
|
||||||
|
coverage := NewChunkCoverage(chunk)
|
||||||
|
chunk.SourceID = "changed"
|
||||||
|
chunk.Units[0].ID = 99
|
||||||
|
if !coverage.Contains(source.NewDocumentIndex(doc), doc, coverageRef(doc.ID, 1, 2)) {
|
||||||
|
t.Fatal("Contains() changed after mutating source chunk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coverageDocument(ids ...int) *source.SourceDocument {
|
||||||
|
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, len(ids))}
|
||||||
|
for index, id := range ids {
|
||||||
|
doc.Units[index] = source.SourceUnit{ID: id}
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
func coverageChunk(sourceID string, ids ...int) *source.Chunk {
|
||||||
|
chunk := &source.Chunk{SourceID: sourceID, Units: make([]source.SourceUnit, len(ids))}
|
||||||
|
for index, id := range ids {
|
||||||
|
chunk.Units[index] = source.SourceUnit{ID: id}
|
||||||
|
}
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
func coverageRef(sourceID string, start, end int) source.SourceRef {
|
||||||
|
return source.SourceRef{SourceID: sourceID, StartUnitID: start, EndUnitID: end}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/combat-turns/shape"
|
Key = "extract/dnd/combat-turns/shape"
|
||||||
ReasonCode = "invalid_combat_turn_shape"
|
ReasonCode = "invalid_combat_turn_shape"
|
||||||
policy = "dnd.combat_turns.validator.shape.v1"
|
policy = "dnd.combat_turns.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -33,38 +33,48 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) {
|
||||||
if err := Validate(req.Value); err != nil {
|
issues, corrections := assess(req.Value)
|
||||||
return rejection(err.Error()), nil
|
if len(issues) != 0 {
|
||||||
|
return rejection(
|
||||||
|
diagnostics.Aggregate("invalid combat turn shape", issues),
|
||||||
|
corrections.Guidance("Correct every rejected combat turn and return the complete replacement combat-turn list"),
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.CombatTurnList) error {
|
func Validate(value dnd.CombatTurnList) error {
|
||||||
issues := issuesFor(value)
|
issues, _ := assess(value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.CombatTurnList) []string {
|
func assess(value dnd.CombatTurnList) ([]string, diagnostics.Corrections) {
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
if value.CombatTurns == nil {
|
if value.CombatTurns == nil {
|
||||||
return []string{"combat_turns must be present"}
|
corrections.Add("list", "Return a `combat_turns` array; use an empty array when the scene contains no combat turns.", "")
|
||||||
|
return []string{"combat_turns must be present"}, corrections
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for turnIndex, turn := range value.CombatTurns {
|
for turnIndex, turn := range value.CombatTurns {
|
||||||
prefix := fmt.Sprintf("combat_turns[%d]", turnIndex)
|
prefix := fmt.Sprintf("combat_turns[%d]", turnIndex)
|
||||||
|
record := fmt.Sprintf("Affected %s for actor %s %s.", diagnostics.Quote(string(turn.TurnKind)), diagnostics.Quote(strings.TrimSpace(turn.Actor)), diagnostics.SourceRange(turn.SourceRefs))
|
||||||
if strings.TrimSpace(turn.Actor) == "" {
|
if strings.TrimSpace(turn.Actor) == "" {
|
||||||
issues = append(issues, prefix+".actor must not be empty: "+diagnostics.Quote(turn.Actor))
|
issues = append(issues, prefix+".actor must not be empty: "+diagnostics.Quote(turn.Actor))
|
||||||
|
corrections.Add("actor", "Provide the contextual combatant name for every combat turn.", record)
|
||||||
}
|
}
|
||||||
if !validTurnKind(turn.TurnKind) {
|
if !validTurnKind(turn.TurnKind) {
|
||||||
issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind)))
|
issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind)))
|
||||||
|
corrections.Add("kind", "Set `turn_kind` to exactly one of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`.", record)
|
||||||
}
|
}
|
||||||
if len(turn.SourceRefs) == 0 {
|
if len(turn.SourceRefs) == 0 {
|
||||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||||
|
corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every combat turn.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
}
|
||||||
|
|
||||||
func validTurnKind(value dnd.CombatTurnKind) bool {
|
func validTurnKind(value dnd.CombatTurnKind) bool {
|
||||||
@@ -99,6 +109,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
|
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
|
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
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."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||||
)
|
)
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/combat-turns/source_refs"
|
Key = "extract/dnd/combat-turns/source_refs"
|
||||||
ReasonCode = "invalid_combat_turn_source_refs"
|
ReasonCode = "invalid_combat_turn_source_refs"
|
||||||
policy = "dnd.combat_turns.validator.source_refs.v2"
|
policy = "dnd.combat_turns.validator.source_refs.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -40,11 +41,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if err := combatshape.Validate(req.Value); err != nil {
|
if err := combatshape.Validate(req.Value); err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
var coverage *chunkCoverage
|
var coverage shared.ChunkCoverage
|
||||||
if req.Stage == string(pipeline.StageExtract) {
|
if req.Stage == string(pipeline.StageExtract) {
|
||||||
coverage = newChunkCoverage(req.Chunk)
|
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||||
}
|
}
|
||||||
issues := sourceRefIssues(source.NewDocumentIndex(req.Source), req.Source, coverage, req.Value)
|
issues, corrections := sourceRefIssues(source.NewDocumentIndex(req.Source), req.Source, coverage, req.Stage == string(pipeline.StageExtract), req.Value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -52,54 +53,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
Approved: false,
|
Approved: false,
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: diagnostics.Aggregate("invalid combat turn source references", issues),
|
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.",
|
CorrectionGuidance: corrections.Guidance("Correct every rejected combat-turn citation and return the complete replacement combat-turn list"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage *chunkCoverage, value dnd.CombatTurnList) []string {
|
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage shared.ChunkCoverage, checkCoverage bool, value dnd.CombatTurnList) ([]string, diagnostics.Corrections) {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for turnIndex, turn := range value.CombatTurns {
|
for turnIndex, turn := range value.CombatTurns {
|
||||||
for refIndex, ref := range turn.SourceRefs {
|
for refIndex, ref := range turn.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected %s for actor %s, citing %s.", diagnostics.Quote(string(turn.TurnKind)), diagnostics.Quote(turn.Actor), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: %s", turnIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: %s", turnIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if coverage != nil && !coverage.contains(doc, ref) {
|
if checkCoverage && !coverage.Contains(index, doc, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: source reference is outside the current extraction chunk", turnIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: source reference is outside the current extraction chunk", turnIndex, refIndex))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
|
||||||
|
|
||||||
type chunkCoverage struct {
|
|
||||||
sourceID string
|
|
||||||
unitIDs map[int]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
|
||||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
coverage.unitIDs[unit.ID] = struct{}{}
|
|
||||||
}
|
|
||||||
return coverage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
|
||||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
|
||||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
|
||||||
if !startOK || !endOK || start > end {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for position := start; position <= end; position++ {
|
|
||||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/enemy-events/engagements"
|
Key = "extract/dnd/enemy-events/engagements"
|
||||||
ReasonCode = "duplicate_enemy_engagement"
|
ReasonCode = "duplicate_enemy_engagement"
|
||||||
policy = "dnd.enemy_events.validator.engagements.v1"
|
policy = "dnd.enemy_events.validator.engagements.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -38,7 +38,9 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if enemyeventshape.Validate(req.Value) != nil {
|
if enemyeventshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]dnd.EnemyEvent)
|
||||||
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for _, event := range req.Value.Events {
|
for _, event := range req.Value.Events {
|
||||||
if event.Kind != dnd.EnemyEventKindEngaged {
|
if event.Kind != dnd.EnemyEventKindEngaged {
|
||||||
continue
|
continue
|
||||||
@@ -47,18 +49,25 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if identity == "" {
|
if identity == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, found := seen[identity]; found {
|
if first, found := seen[identity]; found {
|
||||||
|
issues = append(issues, fmt.Sprintf("subject %s has more than one engagement in one combat scene", diagnostics.Quote(event.Name)))
|
||||||
|
corrections.Add(
|
||||||
|
"duplicate-subject",
|
||||||
|
"Return at most one `engaged` event for each contextual enemy name within this combat scene; keep the source ranges together on that one event.",
|
||||||
|
fmt.Sprintf("Affected enemy %s: first engagement %s; additional engagement %s.", diagnostics.Quote(event.Name), diagnostics.SourceRange(first.SourceRefs), diagnostics.SourceRange(event.SourceRefs)),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[identity] = event
|
||||||
|
}
|
||||||
|
if len(issues) != 0 {
|
||||||
return contracts.ValidationResult{
|
return contracts.ValidationResult{
|
||||||
Approved: false,
|
Approved: false,
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: diagnostics.Aggregate("duplicate enemy engagement", []string{
|
Message: diagnostics.Aggregate("duplicate enemy engagement", issues),
|
||||||
fmt.Sprintf("subject %s has more than one engagement in one combat scene", diagnostics.Quote(event.Name)),
|
CorrectionGuidance: corrections.Guidance("Correct every duplicate enemy engagement and return the complete replacement event list"),
|
||||||
}),
|
|
||||||
CorrectionGuidance: "Return at most one engagement event for each contextual enemy name within the same combat scene.",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
seen[identity] = struct{}{}
|
|
||||||
}
|
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,27 @@ func TestValidatorRegistersStrictOptionsAndPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidatorReportsEveryDuplicateSubjectWithContext(t *testing.T) {
|
||||||
|
value := events(
|
||||||
|
event("Ashfang", dnd.EnemyEventKindEngaged, 1),
|
||||||
|
event("Ashfang", dnd.EnemyEventKindEngaged, 2),
|
||||||
|
event("Briar", dnd.EnemyEventKindEngaged, 3),
|
||||||
|
event("Briar", dnd.EnemyEventKindEngaged, 4),
|
||||||
|
)
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
|
||||||
|
if err != nil || result.Approved {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Ashfang", "Briar", "source unit 1", "source unit 2", "source unit 3", "source unit 4"} {
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, want) {
|
||||||
|
t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(result.CorrectionGuidance, "events[") || strings.Contains(result.CorrectionGuidance, ReasonCode) {
|
||||||
|
t.Fatalf("CorrectionGuidance exposed internal diagnostics: %q", result.CorrectionGuidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func events(values ...dnd.EnemyEvent) dnd.EnemyEventList { return dnd.EnemyEventList{Events: values} }
|
func events(values ...dnd.EnemyEvent) dnd.EnemyEventList { return dnd.EnemyEventList{Events: values} }
|
||||||
|
|
||||||
func event(name string, kind dnd.EnemyEventKind, unitID int) dnd.EnemyEvent {
|
func event(name string, kind dnd.EnemyEventKind, unitID int) dnd.EnemyEvent {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/enemy-events/shape"
|
Key = "extract/dnd/enemy-events/shape"
|
||||||
ReasonCode = "invalid_enemy_event_shape"
|
ReasonCode = "invalid_enemy_event_shape"
|
||||||
policy = "dnd.enemy_events.validator.shape.v1"
|
policy = "dnd.enemy_events.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -35,38 +35,50 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
|
||||||
if err := Validate(req.Value); err != nil {
|
issues, corrections := assess(req.Value)
|
||||||
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
|
if len(issues) != 0 {
|
||||||
|
return contracts.ValidationResult{
|
||||||
|
Approved: false,
|
||||||
|
ReasonCode: ReasonCode,
|
||||||
|
Message: diagnostics.Aggregate("invalid enemy event shape", issues),
|
||||||
|
CorrectionGuidance: corrections.Guidance("Correct every rejected enemy event and return the complete replacement event list"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.EnemyEventList) error {
|
func Validate(value dnd.EnemyEventList) error {
|
||||||
issues := issuesFor(value)
|
issues, _ := assess(value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid enemy event shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid enemy event shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.EnemyEventList) []string {
|
func assess(value dnd.EnemyEventList) ([]string, diagnostics.Corrections) {
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
if value.Events == nil {
|
if value.Events == nil {
|
||||||
return []string{"events must be present"}
|
corrections.Add("list", "Return an `events` array; use an empty array when the combat scene establishes no enemy events.", "")
|
||||||
|
return []string{"events must be present"}, corrections
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for eventIndex, event := range value.Events {
|
for eventIndex, event := range value.Events {
|
||||||
prefix := fmt.Sprintf("events[%d]", eventIndex)
|
prefix := fmt.Sprintf("events[%d]", eventIndex)
|
||||||
|
record := fmt.Sprintf("Affected %s event for enemy %s %s.", diagnostics.Quote(string(event.Kind)), diagnostics.Quote(strings.TrimSpace(event.Name)), diagnostics.SourceRange(event.SourceRefs))
|
||||||
if strings.TrimSpace(event.Name) == "" {
|
if strings.TrimSpace(event.Name) == "" {
|
||||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
|
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
|
||||||
|
corrections.Add("name", "Select a nonblank contextual enemy name from the supplied NPC registry for every event.", record)
|
||||||
}
|
}
|
||||||
if !enemyeventmodel.SupportedKind(event.Kind) {
|
if !enemyeventmodel.SupportedKind(event.Kind) {
|
||||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(event.Kind)))
|
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(event.Kind)))
|
||||||
|
corrections.Add("kind", "Set `kind` to exactly one of `engaged`, `killed`, `fled`, `captured`, or `incapacitated`.", record)
|
||||||
}
|
}
|
||||||
if len(event.SourceRefs) == 0 {
|
if len(event.SourceRefs) == 0 {
|
||||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||||
|
corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every enemy event.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/enemy-events/source_refs"
|
Key = "extract/dnd/enemy-events/source_refs"
|
||||||
ReasonCode = "invalid_enemy_event_source_refs"
|
ReasonCode = "invalid_enemy_event_source_refs"
|
||||||
policy = "dnd.enemy_events.validator.source_refs.v1"
|
policy = "dnd.enemy_events.validator.source_refs.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -42,61 +43,40 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
var coverage *chunkCoverage
|
var coverage shared.ChunkCoverage
|
||||||
if req.Stage == string(pipeline.StageExtract) {
|
if req.Stage == string(pipeline.StageExtract) {
|
||||||
coverage = newChunkCoverage(req.Chunk)
|
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||||
}
|
}
|
||||||
issues := sourceRefIssues(index, req.Source, coverage, req.Value)
|
issues, corrections := sourceRefIssues(index, req.Source, coverage, req.Stage == string(pipeline.StageExtract), req.Value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, 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
|
return contracts.ValidationResult{
|
||||||
|
Approved: false,
|
||||||
|
ReasonCode: ReasonCode,
|
||||||
|
Message: diagnostics.Aggregate("invalid enemy event source references", issues),
|
||||||
|
CorrectionGuidance: corrections.Guidance("Correct every rejected enemy-event citation and return the complete replacement event list"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage *chunkCoverage, value dnd.EnemyEventList) []string {
|
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage shared.ChunkCoverage, checkCoverage bool, value dnd.EnemyEventList) ([]string, diagnostics.Corrections) {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for eventIndex, event := range value.Events {
|
for eventIndex, event := range value.Events {
|
||||||
for refIndex, ref := range event.SourceRefs {
|
for refIndex, ref := range event.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected %s event for enemy %s, citing %s.", diagnostics.Quote(string(event.Kind)), diagnostics.Quote(event.Name), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if coverage != nil && !coverage.contains(doc, ref) {
|
if checkCoverage && !coverage.Contains(index, doc, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
|
||||||
|
|
||||||
type chunkCoverage struct {
|
|
||||||
sourceID string
|
|
||||||
unitIDs map[int]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
|
||||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
coverage.unitIDs[unit.ID] = struct{}{}
|
|
||||||
}
|
|
||||||
return coverage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
|
||||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
|
||||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
|
||||||
if !startOK || !endOK || start > end {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for position := start; position <= end; position++ {
|
|
||||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/item-occurrences/registry"
|
Key = "extract/dnd/item-occurrences/registry"
|
||||||
ReasonCode = "invalid_item_occurrence_registry"
|
ReasonCode = "invalid_item_occurrence_registry"
|
||||||
policy = "dnd.item_occurrences.validator.registry.v1"
|
policy = "dnd.item_occurrences.validator.registry.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -76,27 +76,33 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{}, fmt.Errorf("resolve item registry: %w", err)
|
return contracts.ValidationResult{}, fmt.Errorf("resolve item registry: %w", err)
|
||||||
}
|
}
|
||||||
if !registry.Bound() {
|
if !registry.Bound() {
|
||||||
return rejection([]string{"item registry reference is required"}), nil
|
var corrections diagnostics.Corrections
|
||||||
|
corrections.Add("registry", "Use only contextual item names from the supplied item registry; omit an occurrence that cannot be matched unambiguously.", "")
|
||||||
|
return rejection([]string{"item registry reference is required"}, corrections), nil
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for index, occurrence := range req.Value.Occurrences {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
|
record := fmt.Sprintf("Affected %s occurrence for item %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||||
item, ok := registry.LookupID(occurrence.ItemID)
|
item, ok := registry.LookupID(occurrence.ItemID)
|
||||||
if !ok {
|
if !ok {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].item_id is not in the item registry: %s", index, diagnostics.Quote(occurrence.ItemID)))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].item_id is not in the item registry: %s", index, diagnostics.Quote(occurrence.ItemID)))
|
||||||
|
corrections.Add("registry", "Use only contextual item names from the supplied item registry; omit an occurrence that cannot be matched unambiguously.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if occurrence.Name != item.Name {
|
if occurrence.Name != item.Name {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry item %s", index, diagnostics.Quote(occurrence.ItemID)))
|
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry item %s", index, diagnostics.Quote(occurrence.ItemID)))
|
||||||
|
corrections.Add("canonical-name", "Use the exact contextual item name supplied by the registry.", record+" Use registry name "+diagnostics.Quote(item.Name)+".")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
return rejection(issues), nil
|
return rejection(issues, corrections), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rejection(issues []string) contracts.ValidationResult {
|
func rejection(issues []string, corrections diagnostics.Corrections) contracts.ValidationResult {
|
||||||
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."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues), CorrectionGuidance: corrections.Guidance("Correct every rejected item occurrence and return the complete replacement occurrence list")}
|
||||||
}
|
}
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/item-occurrences/shape"
|
Key = "extract/dnd/item-occurrences/shape"
|
||||||
ReasonCode = "invalid_item_occurrence_shape"
|
ReasonCode = "invalid_item_occurrence_shape"
|
||||||
policy = "dnd.item_occurrences.shape.v2"
|
policy = "dnd.item_occurrences.shape.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -58,19 +58,11 @@ func Validate(value dnd.ItemOccurrenceList) error {
|
|||||||
|
|
||||||
type validationAssessment struct {
|
type validationAssessment struct {
|
||||||
operatorIssues []string
|
operatorIssues []string
|
||||||
correctionGroups []correctionGroup
|
corrections diagnostics.Corrections
|
||||||
groupIndexes map[string]int
|
|
||||||
}
|
|
||||||
|
|
||||||
type correctionGroup struct {
|
|
||||||
label string
|
|
||||||
rule string
|
|
||||||
records []string
|
|
||||||
recordsSeen map[string]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func assess(value dnd.ItemOccurrenceList) validationAssessment {
|
func assess(value dnd.ItemOccurrenceList) validationAssessment {
|
||||||
assessment := validationAssessment{groupIndexes: make(map[string]int)}
|
var assessment validationAssessment
|
||||||
if value.Occurrences == nil {
|
if value.Occurrences == nil {
|
||||||
assessment.operatorIssues = append(assessment.operatorIssues, "occurrences must be present")
|
assessment.operatorIssues = append(assessment.operatorIssues, "occurrences must be present")
|
||||||
assessment.addCorrection("occurrences", "item-occurrence list", "Return an `occurrences` array; use an empty array when the transcript establishes no occurrences.", "")
|
assessment.addCorrection("occurrences", "item-occurrence list", "Return an `occurrences` array; use an empty array when the transcript establishes no occurrences.", "")
|
||||||
@@ -107,20 +99,10 @@ func assess(value dnd.ItemOccurrenceList) validationAssessment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (assessment *validationAssessment) addCorrection(key, label, rule, record string) {
|
func (assessment *validationAssessment) addCorrection(key, label, rule, record string) {
|
||||||
index, ok := assessment.groupIndexes[key]
|
|
||||||
if !ok {
|
|
||||||
index = len(assessment.correctionGroups)
|
|
||||||
assessment.groupIndexes[key] = index
|
|
||||||
assessment.correctionGroups = append(assessment.correctionGroups, correctionGroup{label: label, rule: rule, recordsSeen: make(map[string]struct{})})
|
|
||||||
}
|
|
||||||
if record != "" {
|
if record != "" {
|
||||||
group := &assessment.correctionGroups[index]
|
record = "Affected " + label + " record: " + record
|
||||||
if _, seen := group.recordsSeen[record]; seen {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
group.recordsSeen[record] = struct{}{}
|
|
||||||
group.records = append(group.records, record)
|
|
||||||
}
|
}
|
||||||
|
assessment.corrections.Add(key, rule, record)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (assessment validationAssessment) operatorMessage() string {
|
func (assessment validationAssessment) operatorMessage() string {
|
||||||
@@ -128,16 +110,7 @@ func (assessment validationAssessment) operatorMessage() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (assessment validationAssessment) correctionGuidance() string {
|
func (assessment validationAssessment) correctionGuidance() string {
|
||||||
issues := make([]string, 0, len(assessment.correctionGroups)+len(assessment.operatorIssues))
|
return assessment.corrections.Guidance("Correct every rejected item occurrence and return the complete replacement list")
|
||||||
for _, group := range assessment.correctionGroups {
|
|
||||||
issues = append(issues, group.rule)
|
|
||||||
}
|
|
||||||
for _, group := range assessment.correctionGroups {
|
|
||||||
for _, record := range group.records {
|
|
||||||
issues = append(issues, "Affected "+group.label+" record: "+record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return diagnostics.Aggregate("Correct every rejected item occurrence and return the complete replacement list", issues)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func holderOperatorIssue(occurrence dnd.ItemOccurrence) string {
|
func holderOperatorIssue(occurrence dnd.ItemOccurrence) string {
|
||||||
@@ -159,7 +132,7 @@ func holderExpectation(kind dnd.ItemOccurrenceKind) string {
|
|||||||
case dnd.ItemOccurrenceKindConsumed:
|
case dnd.ItemOccurrenceKindConsumed:
|
||||||
return "from present and to absent"
|
return "from present and to absent"
|
||||||
case dnd.ItemOccurrenceKindTransferred:
|
case dnd.ItemOccurrenceKindTransferred:
|
||||||
return "distinct named non-party holders"
|
return "distinct named party-member holders"
|
||||||
default:
|
default:
|
||||||
return "a supported holder combination"
|
return "a supported holder combination"
|
||||||
}
|
}
|
||||||
@@ -188,19 +161,7 @@ func occurrenceContext(occurrence dnd.ItemOccurrence) string {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
context = "item with a blank contextual name"
|
context = "item with a blank contextual name"
|
||||||
}
|
}
|
||||||
if len(occurrence.SourceRefs) == 0 {
|
context += " " + diagnostics.SourceRange(occurrence.SourceRefs)
|
||||||
context += " without a cited source range"
|
|
||||||
} else {
|
|
||||||
ref := occurrence.SourceRefs[0]
|
|
||||||
if ref.StartUnitID == ref.EndUnitID {
|
|
||||||
context += fmt.Sprintf(" at source unit %d", ref.StartUnitID)
|
|
||||||
} else {
|
|
||||||
context += fmt.Sprintf(" at source units %d-%d", ref.StartUnitID, ref.EndUnitID)
|
|
||||||
}
|
|
||||||
if len(occurrence.SourceRefs) > 1 {
|
|
||||||
context += fmt.Sprintf(" (first of %d cited ranges)", len(occurrence.SourceRefs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return context + " with from " + holderDisplay(occurrence.From) + " and to " + holderDisplay(occurrence.To)
|
return context + " with from " + holderDisplay(occurrence.From) + " and to " + holderDisplay(occurrence.To)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
itemoccurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemoccurrences/shape"
|
itemoccurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemoccurrences/shape"
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/item-occurrences/source_refs"
|
Key = "extract/dnd/item-occurrences/source_refs"
|
||||||
ReasonCode = "invalid_item_occurrence_source_references"
|
ReasonCode = "invalid_item_occurrence_source_references"
|
||||||
policy = "dnd.item_occurrences.source_refs.v1"
|
policy = "dnd.item_occurrences.source_refs.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -42,59 +43,35 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
var coverage *chunkCoverage
|
var coverage shared.ChunkCoverage
|
||||||
if req.Stage == string(pipeline.StageExtract) {
|
if req.Stage == string(pipeline.StageExtract) {
|
||||||
coverage = newChunkCoverage(req.Chunk)
|
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
||||||
for refIndex, ref := range occurrence.SourceRefs {
|
for refIndex, ref := range occurrence.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected %s occurrence for item %s, citing %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", occurrenceIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", occurrenceIndex, refIndex))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, 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
|
return contracts.ValidationResult{
|
||||||
}
|
Approved: false,
|
||||||
|
ReasonCode: ReasonCode,
|
||||||
type chunkCoverage struct {
|
Message: diagnostics.Aggregate("invalid item occurrence source references", issues),
|
||||||
sourceID string
|
CorrectionGuidance: corrections.Guidance("Correct every rejected item-occurrence citation and return the complete replacement occurrence list"),
|
||||||
unitIDs map[int]struct{}
|
}, nil
|
||||||
}
|
|
||||||
|
|
||||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
|
||||||
coverage := &chunkCoverage{
|
|
||||||
sourceID: chunk.SourceID,
|
|
||||||
unitIDs: make(map[int]struct{}, len(chunk.Units)),
|
|
||||||
}
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
coverage.unitIDs[unit.ID] = struct{}{}
|
|
||||||
}
|
|
||||||
return coverage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
|
||||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
|
||||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
|
||||||
if !startOK || !endOK || start > end {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for position := start; position <= end; position++ {
|
|
||||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
Key = "normalize/dnd/item-registry/identity"
|
Key = "normalize/dnd/item-registry/identity"
|
||||||
ReasonCode = "invalid_item_identity"
|
ReasonCode = "invalid_item_identity"
|
||||||
policy = domainidentity.Policy
|
policy = domainidentity.Policy
|
||||||
|
correctionPolicy = "dnd.item_registry.validator.identity.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -31,7 +32,7 @@ func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
|||||||
return contracts.ExecutionClassDeterministic
|
return contracts.ExecutionClassDeterministic
|
||||||
}
|
}
|
||||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "correction_policy", Value: correctionPolicy}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemRegistry]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemRegistry]) (contracts.ValidationResult, error) {
|
||||||
@@ -43,10 +44,31 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
issues := make([]string, len(identityIssues))
|
issues := make([]string, len(identityIssues))
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for index, issue := range identityIssues {
|
for index, issue := range identityIssues {
|
||||||
issues[index] = fmt.Sprintf("items[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
|
issues[index] = fmt.Sprintf("items[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
|
||||||
|
if issue.RecordIndex < 0 || issue.RecordIndex >= len(req.Value.Items) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := req.Value.Items[issue.RecordIndex]
|
||||||
|
corrections.Add(string(issue.Code), itemIdentityCorrection(issue.Code), fmt.Sprintf("Affected item %s %s.", diagnostics.Quote(item.Name), diagnostics.SourceRange(item.SourceRefs)))
|
||||||
|
}
|
||||||
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item identity", issues), CorrectionGuidance: corrections.Guidance("Correct the duplicate-group proposals and return the complete replacement proposal response")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemIdentityCorrection(code domainidentity.IssueCode) string {
|
||||||
|
switch code {
|
||||||
|
case domainidentity.IssueEmptyCanonicalName:
|
||||||
|
return "Select a canonical proposal member with a nonblank, transcript-supported item name."
|
||||||
|
case domainidentity.IssueDuplicateCanonical:
|
||||||
|
return "Put duplicate mentions of the same item in one proposal group and select one transcript-supported canonical member."
|
||||||
|
case domainidentity.IssueInvalidID, domainidentity.IssueIDMismatch:
|
||||||
|
return "Revise the proposal so its canonical item member has a valid transcript-supported name; Notarius derives durable identity without model input."
|
||||||
|
case domainidentity.IssueDuplicateID:
|
||||||
|
return "Do not use proposals that collapse distinct items into one canonical identity; group only records that describe the same item."
|
||||||
|
default:
|
||||||
|
return "Revise the duplicate-group proposal so every canonical item is transcript-supported and distinct."
|
||||||
}
|
}
|
||||||
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 {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ func TestValidatorRejectsInvalidAndDuplicateItemIdentity(t *testing.T) {
|
|||||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "invalid_id") || !strings.Contains(result.Message, "duplicate_canonical_identity") {
|
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "invalid_id") || !strings.Contains(result.Message, "duplicate_canonical_identity") {
|
||||||
t.Fatalf("Validate() = %#v, %v; want identity rejection", result, err)
|
t.Fatalf("Validate() = %#v, %v; want identity rejection", result, err)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, "Rope") || strings.Contains(result.CorrectionGuidance, "wrong") || strings.Contains(result.CorrectionGuidance, "items[") {
|
||||||
|
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance without IDs or operator paths", result.CorrectionGuidance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorRegistersPolicy(t *testing.T) {
|
func TestValidatorRegistersPolicy(t *testing.T) {
|
||||||
@@ -31,7 +34,7 @@ func TestValidatorRegistersPolicy(t *testing.T) {
|
|||||||
if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic {
|
if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||||
t.Fatalf("registered spec = %#v, present = %t", got, ok)
|
t.Fatalf("registered spec = %#v, present = %t", got, ok)
|
||||||
}
|
}
|
||||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != domainidentity.Policy {
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 2 || got[0].Value != domainidentity.Policy || got[1].Name != "correction_policy" || got[1].Value != correctionPolicy {
|
||||||
t.Fatalf("fingerprints = %#v", got)
|
t.Fatalf("fingerprints = %#v", got)
|
||||||
}
|
}
|
||||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/item-registry/shape"
|
Key = "extract/dnd/item-registry/shape"
|
||||||
ReasonCode = "invalid_item_shape"
|
ReasonCode = "invalid_item_shape"
|
||||||
policy = "dnd.item_registry.validator.shape.v1"
|
policy = "dnd.item_registry.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -34,39 +34,58 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemRegistry]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemRegistry]) (contracts.ValidationResult, error) {
|
||||||
issues := issuesFor(req.Value)
|
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||||
|
issues, corrections := assess(req.Value, normalization)
|
||||||
if len(issues) > 0 {
|
if len(issues) > 0 {
|
||||||
return rejection(diagnostics.Aggregate("invalid item shape", issues)), nil
|
prefix := "Correct every rejected item and return the complete replacement registry"
|
||||||
|
if normalization {
|
||||||
|
prefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||||
|
}
|
||||||
|
return rejection(diagnostics.Aggregate("invalid item shape", issues), corrections.Guidance(prefix)), nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.ItemRegistry) error {
|
func Validate(value dnd.ItemRegistry) error {
|
||||||
issues := issuesFor(value)
|
issues, _ := assess(value, false)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.ItemRegistry) []string {
|
func assess(value dnd.ItemRegistry, normalization bool) ([]string, diagnostics.Corrections) {
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
|
nameRule := "Provide one nonblank, transcript-supported item name for every registry entry."
|
||||||
|
refRule := "Provide at least one transcript source range that directly supports every named item."
|
||||||
|
listRule := "Return an `items` array; use an empty array when the transcript establishes no named items."
|
||||||
|
if normalization {
|
||||||
|
nameRule = "Revise the duplicate-group proposals so every selected canonical item has a nonblank, transcript-supported name."
|
||||||
|
refRule = "Revise the duplicate-group proposals so every selected canonical item preserves direct transcript evidence."
|
||||||
|
listRule = "Revise the duplicate-group proposals so normalization retains the complete item candidate registry."
|
||||||
|
}
|
||||||
if value.Items == nil {
|
if value.Items == nil {
|
||||||
return []string{"items must be present"}
|
corrections.Add("list", listRule, "")
|
||||||
|
return []string{"items must be present"}, corrections
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, item := range value.Items {
|
for index, item := range value.Items {
|
||||||
prefix := fmt.Sprintf("items[%d]", index)
|
prefix := fmt.Sprintf("items[%d]", index)
|
||||||
|
record := "Affected item " + diagnostics.Quote(strings.TrimSpace(item.Name)) + " " + diagnostics.SourceRange(item.SourceRefs) + "."
|
||||||
if strings.TrimSpace(item.ID) == "" {
|
if strings.TrimSpace(item.ID) == "" {
|
||||||
issues = append(issues, prefix+".id must not be empty")
|
issues = append(issues, prefix+".id must not be empty")
|
||||||
|
corrections.Add("name", nameRule, record)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(item.Name) == "" {
|
if strings.TrimSpace(item.Name) == "" {
|
||||||
issues = append(issues, prefix+".name must not be empty")
|
issues = append(issues, prefix+".name must not be empty")
|
||||||
|
corrections.Add("name", nameRule, record)
|
||||||
}
|
}
|
||||||
if len(item.SourceRefs) == 0 {
|
if len(item.SourceRefs) == 0 {
|
||||||
issues = append(issues, prefix+".source_refs must not be empty")
|
issues = append(issues, prefix+".source_refs must not be empty")
|
||||||
|
corrections.Add("source-refs", refRule, record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
@@ -92,6 +111,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
|
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
|
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete item registry containing only properly named items with valid source references."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
itemshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/shape"
|
itemshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/shape"
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/item-registry/source_refs"
|
Key = "extract/dnd/item-registry/source_refs"
|
||||||
ReasonCode = "invalid_item_source_refs"
|
ReasonCode = "invalid_item_source_refs"
|
||||||
policy = "dnd.item_registry.validator.source_refs.v1"
|
policy = "dnd.item_registry.validator.source_refs.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -42,35 +43,37 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
|
coverage := shared.NewChunkCoverage(req.Chunk)
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
|
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||||
|
validRangeRule := "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first."
|
||||||
|
guidancePrefix := "Correct every rejected item citation and return the complete replacement item registry"
|
||||||
|
if normalization {
|
||||||
|
validRangeRule = "Revise the duplicate-group proposals so every selected canonical item preserves valid transcript evidence; do not reproduce application IDs."
|
||||||
|
guidancePrefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||||
|
}
|
||||||
for itemIndex, item := range req.Value.Items {
|
for itemIndex, item := range req.Value.Items {
|
||||||
for refIndex, ref := range item.SourceRefs {
|
for refIndex, ref := range item.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected item %s, citing %s.", diagnostics.Quote(item.Name), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("items[%d].source_refs[%d]: %s", itemIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("items[%d].source_refs[%d]: %s", itemIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", validRangeRule, record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("items[%d].source_refs[%d]: source reference is outside the current extraction chunk", itemIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("items[%d].source_refs[%d]: source reference is outside the current extraction chunk", itemIndex, refIndex))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
return rejection(diagnostics.Aggregate("invalid item source references", issues)), nil
|
return rejection(
|
||||||
}
|
diagnostics.Aggregate("invalid item source references", issues),
|
||||||
|
corrections.Guidance(guidancePrefix),
|
||||||
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
|
), nil
|
||||||
if chunk == nil || ref.SourceID != chunk.SourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
startFound := false
|
|
||||||
endFound := false
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
startFound = startFound || unit.ID == ref.StartUnitID
|
|
||||||
endFound = endFound || unit.ID == ref.EndUnitID
|
|
||||||
}
|
|
||||||
return startFound && endFound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
@@ -96,6 +99,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
|
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
|
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
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."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ func TestValidatorDefersMalformedShapeAndRegisters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidatorRequiresEveryUnitInCitedSpanToBeInChunk(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}}}
|
||||||
|
value := validItemRegistry()
|
||||||
|
value.Items[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 20}}
|
||||||
|
chunk := &source.Chunk{SourceID: doc.ID, Units: []source.SourceUnit{{ID: 30}, {ID: 20}}}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: value})
|
||||||
|
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want missing-middle-unit rejection", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func request(doc *source.SourceDocument, value dnd.ItemRegistry) contracts.TypedValidationRequest[dnd.ItemRegistry] {
|
func request(doc *source.SourceDocument, value dnd.ItemRegistry) contracts.TypedValidationRequest[dnd.ItemRegistry] {
|
||||||
return contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, Value: value}
|
return contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, Value: value}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/location-occurrences/registry"
|
Key = "extract/dnd/location-occurrences/registry"
|
||||||
ReasonCode = "invalid_location_occurrence_registry"
|
ReasonCode = "invalid_location_occurrence_registry"
|
||||||
policy = "dnd.location_occurrences.validator.registry.v1"
|
policy = "dnd.location_occurrences.validator.registry.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -76,27 +76,33 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{}, fmt.Errorf("resolve location registry: %w", err)
|
return contracts.ValidationResult{}, fmt.Errorf("resolve location registry: %w", err)
|
||||||
}
|
}
|
||||||
if !registry.Bound() {
|
if !registry.Bound() {
|
||||||
return rejection([]string{"location registry reference is required"}), nil
|
var corrections diagnostics.Corrections
|
||||||
|
corrections.Add("registry", "Use only proper contextual location names from the supplied location registry; omit an occurrence that cannot be matched unambiguously.", "")
|
||||||
|
return rejection([]string{"location registry reference is required"}, corrections), nil
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for index, occurrence := range req.Value.Occurrences {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
|
record := fmt.Sprintf("Affected %s occurrence for location %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||||
location, ok := registry.Lookup(occurrence.LocationID)
|
location, ok := registry.Lookup(occurrence.LocationID)
|
||||||
if !ok {
|
if !ok {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].location_id is not in the location registry: %s", index, diagnostics.Quote(occurrence.LocationID)))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].location_id is not in the location registry: %s", index, diagnostics.Quote(occurrence.LocationID)))
|
||||||
|
corrections.Add("registry", "Use only proper contextual location names from the supplied location registry; omit an occurrence that cannot be matched unambiguously.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if occurrence.Name != location.Name {
|
if occurrence.Name != location.Name {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry location %s", index, diagnostics.Quote(occurrence.LocationID)))
|
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry location %s", index, diagnostics.Quote(occurrence.LocationID)))
|
||||||
|
corrections.Add("canonical-name", "Use the exact proper location name supplied by the registry.", record+" Use registry name "+diagnostics.Quote(location.Name)+".")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
return rejection(issues), nil
|
return rejection(issues, corrections), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rejection(issues []string) contracts.ValidationResult {
|
func rejection(issues []string, corrections diagnostics.Corrections) contracts.ValidationResult {
|
||||||
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."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence registry", issues), CorrectionGuidance: corrections.Guidance("Correct every rejected location occurrence and return the complete replacement occurrence list")}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/location-occurrences/shape"
|
Key = "extract/dnd/location-occurrences/shape"
|
||||||
ReasonCode = "invalid_location_occurrence_shape"
|
ReasonCode = "invalid_location_occurrence_shape"
|
||||||
policy = "dnd.location_occurrences.validator.shape.v1"
|
policy = "dnd.location_occurrences.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -34,41 +34,54 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if err := Validate(req.Value); err != nil {
|
issues, corrections := assess(req.Value)
|
||||||
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
|
if len(issues) != 0 {
|
||||||
|
return contracts.ValidationResult{
|
||||||
|
Approved: false,
|
||||||
|
ReasonCode: ReasonCode,
|
||||||
|
Message: diagnostics.Aggregate("invalid location occurrence shape", issues),
|
||||||
|
CorrectionGuidance: corrections.Guidance("Correct every rejected location occurrence and return the complete replacement occurrence list"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.LocationOccurrenceList) error {
|
func Validate(value dnd.LocationOccurrenceList) error {
|
||||||
issues := issuesFor(value)
|
issues, _ := assess(value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid location occurrence shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid location occurrence shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.LocationOccurrenceList) []string {
|
func assess(value dnd.LocationOccurrenceList) ([]string, diagnostics.Corrections) {
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
if value.Occurrences == nil {
|
if value.Occurrences == nil {
|
||||||
return []string{"occurrences must be present"}
|
corrections.Add("list", "Return an `occurrences` array; use an empty array when the transcript establishes no location occurrences.", "")
|
||||||
|
return []string{"occurrences must be present"}, corrections
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, occurrence := range value.Occurrences {
|
for index, occurrence := range value.Occurrences {
|
||||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||||
|
record := fmt.Sprintf("Affected %s occurrence for location %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(strings.TrimSpace(occurrence.Name)), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||||
if strings.TrimSpace(occurrence.LocationID) == "" {
|
if strings.TrimSpace(occurrence.LocationID) == "" {
|
||||||
issues = append(issues, prefix+".location_id must not be empty")
|
issues = append(issues, prefix+".location_id must not be empty")
|
||||||
|
corrections.Add("name", "Select a nonblank proper location name from the supplied registry for every occurrence.", record)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(occurrence.Name) == "" {
|
if strings.TrimSpace(occurrence.Name) == "" {
|
||||||
issues = append(issues, prefix+".name must not be empty")
|
issues = append(issues, prefix+".name must not be empty")
|
||||||
|
corrections.Add("name", "Select a nonblank proper location name from the supplied registry for every occurrence.", record)
|
||||||
}
|
}
|
||||||
if !validKind(occurrence.Kind) {
|
if !validKind(occurrence.Kind) {
|
||||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind)))
|
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind)))
|
||||||
|
corrections.Add("kind", "Set `kind` to exactly one of `visited`, `planned`, `recalled`, or `mentioned`.", record)
|
||||||
}
|
}
|
||||||
if len(occurrence.SourceRefs) == 0 {
|
if len(occurrence.SourceRefs) == 0 {
|
||||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||||
|
corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every location occurrence.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
}
|
||||||
|
|
||||||
func validKind(value dnd.LocationOccurrenceKind) bool {
|
func validKind(value dnd.LocationOccurrenceKind) bool {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape"
|
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape"
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/location-occurrences/source_refs"
|
Key = "extract/dnd/location-occurrences/source_refs"
|
||||||
ReasonCode = "invalid_location_occurrence_source_refs"
|
ReasonCode = "invalid_location_occurrence_source_refs"
|
||||||
policy = "dnd.location_occurrences.validator.source_refs.v1"
|
policy = "dnd.location_occurrences.validator.source_refs.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -42,35 +43,34 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
|
coverage := shared.NewChunkCoverage(req.Chunk)
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
||||||
for refIndex, ref := range occurrence.SourceRefs {
|
for refIndex, ref := range occurrence.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected %s occurrence for location %s, citing %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", occurrenceIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", occurrenceIndex, refIndex))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, 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
|
return contracts.ValidationResult{
|
||||||
|
Approved: false,
|
||||||
|
ReasonCode: ReasonCode,
|
||||||
|
Message: diagnostics.Aggregate("invalid location occurrence source references", issues),
|
||||||
|
CorrectionGuidance: corrections.Guidance("Correct every rejected location-occurrence citation and return the complete replacement occurrence list"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
|
|
||||||
if chunk == nil || ref.SourceID != chunk.SourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
startFound, endFound := false, false
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
startFound = startFound || unit.ID == ref.StartUnitID
|
|
||||||
endFound = endFound || unit.ID == ref.EndUnitID
|
|
||||||
}
|
|
||||||
return startFound && endFound
|
|
||||||
}
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
Key = "normalize/dnd/location-registry/identity"
|
Key = "normalize/dnd/location-registry/identity"
|
||||||
ReasonCode = "invalid_location_identity"
|
ReasonCode = "invalid_location_identity"
|
||||||
policy = domainidentity.Policy
|
policy = domainidentity.Policy
|
||||||
|
correctionPolicy = "dnd.location_registry.validator.identity.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -31,7 +32,7 @@ func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
|||||||
return contracts.ExecutionClassDeterministic
|
return contracts.ExecutionClassDeterministic
|
||||||
}
|
}
|
||||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "correction_policy", Value: correctionPolicy}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationRegistry]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationRegistry]) (contracts.ValidationResult, error) {
|
||||||
@@ -43,17 +44,38 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
issues := make([]string, len(identityIssues))
|
issues := make([]string, len(identityIssues))
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for index, issue := range identityIssues {
|
for index, issue := range identityIssues {
|
||||||
issues[index] = fmt.Sprintf("locations[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
|
issues[index] = fmt.Sprintf("locations[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
|
||||||
|
if issue.RecordIndex < 0 || issue.RecordIndex >= len(req.Value.Locations) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
location := req.Value.Locations[issue.RecordIndex]
|
||||||
|
corrections.Add(string(issue.Code), locationIdentityCorrection(issue.Code), fmt.Sprintf("Affected location %s %s.", diagnostics.Quote(location.Name), diagnostics.SourceRange(location.SourceRefs)))
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{
|
return contracts.ValidationResult{
|
||||||
Approved: false,
|
Approved: false,
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: diagnostics.Aggregate("invalid location identity", issues),
|
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.",
|
CorrectionGuidance: corrections.Guidance("Correct the duplicate-group proposals and return the complete replacement proposal response"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func locationIdentityCorrection(code domainidentity.IssueCode) string {
|
||||||
|
switch code {
|
||||||
|
case domainidentity.IssueEmptyCanonicalName:
|
||||||
|
return "Select a canonical proposal member with a nonblank, transcript-supported proper location name."
|
||||||
|
case domainidentity.IssueMissingEvidence:
|
||||||
|
return "Select a canonical proposal member that has direct transcript evidence; do not discard all supported evidence for a location."
|
||||||
|
case domainidentity.IssueInvalidID, domainidentity.IssueIDMismatch:
|
||||||
|
return "Revise the proposal so its canonical location member has a valid name and evidence anchor; Notarius derives durable identity without model input."
|
||||||
|
case domainidentity.IssueDuplicateID:
|
||||||
|
return "Do not use proposals that collapse distinct locations or evidence anchors into one canonical identity; group only records that describe the same location."
|
||||||
|
default:
|
||||||
|
return "Revise the duplicate-group proposal so every canonical location is transcript-supported and distinct."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ func TestValidatorDefersShapeAndRejectsDerivationAndDuplicateID(t *testing.T) {
|
|||||||
t.Fatalf("message %q missing %q", result.Message, want)
|
t.Fatalf("message %q missing %q", result.Message, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, "Gate") || strings.Contains(result.CorrectionGuidance, "not-an-id") || strings.Contains(result.CorrectionGuidance, "locations[") {
|
||||||
|
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance without IDs or operator paths", result.CorrectionGuidance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorRegistersIdentityPolicy(t *testing.T) {
|
func TestValidatorRegistersIdentityPolicy(t *testing.T) {
|
||||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != domainidentity.Policy {
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 2 || got[0].Value != domainidentity.Policy || got[1].Name != "correction_policy" || got[1].Value != correctionPolicy {
|
||||||
t.Fatalf("fingerprints = %#v", got)
|
t.Fatalf("fingerprints = %#v", got)
|
||||||
}
|
}
|
||||||
registry := pipeline.NewValidatorRegistry()
|
registry := pipeline.NewValidatorRegistry()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/location-registry/shape"
|
Key = "extract/dnd/location-registry/shape"
|
||||||
ReasonCode = "invalid_location_shape"
|
ReasonCode = "invalid_location_shape"
|
||||||
policy = "dnd.location_registry.validator.shape.v1"
|
policy = "dnd.location_registry.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -34,39 +34,58 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationRegistry]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationRegistry]) (contracts.ValidationResult, error) {
|
||||||
issues := issuesFor(req.Value)
|
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||||
|
issues, corrections := assess(req.Value, normalization)
|
||||||
if len(issues) > 0 {
|
if len(issues) > 0 {
|
||||||
return rejection(diagnostics.Aggregate("invalid location shape", issues)), nil
|
prefix := "Correct every rejected location and return the complete replacement registry"
|
||||||
|
if normalization {
|
||||||
|
prefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||||
|
}
|
||||||
|
return rejection(diagnostics.Aggregate("invalid location shape", issues), corrections.Guidance(prefix)), nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.LocationRegistry) error {
|
func Validate(value dnd.LocationRegistry) error {
|
||||||
issues := issuesFor(value)
|
issues, _ := assess(value, false)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid location shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid location shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.LocationRegistry) []string {
|
func assess(value dnd.LocationRegistry, normalization bool) ([]string, diagnostics.Corrections) {
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
|
nameRule := "Provide one nonblank, transcript-supported proper location name for every registry entry."
|
||||||
|
refRule := "Provide at least one transcript source range that directly supports every named location."
|
||||||
|
listRule := "Return a `locations` array; use an empty array when the transcript establishes no named locations."
|
||||||
|
if normalization {
|
||||||
|
nameRule = "Revise the duplicate-group proposals so every selected canonical location has a nonblank, transcript-supported proper name."
|
||||||
|
refRule = "Revise the duplicate-group proposals so every selected canonical location preserves direct transcript evidence."
|
||||||
|
listRule = "Revise the duplicate-group proposals so normalization retains the complete location candidate registry."
|
||||||
|
}
|
||||||
if value.Locations == nil {
|
if value.Locations == nil {
|
||||||
return []string{"locations must be present"}
|
corrections.Add("list", listRule, "")
|
||||||
|
return []string{"locations must be present"}, corrections
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, location := range value.Locations {
|
for index, location := range value.Locations {
|
||||||
prefix := fmt.Sprintf("locations[%d]", index)
|
prefix := fmt.Sprintf("locations[%d]", index)
|
||||||
|
record := "Affected location " + diagnostics.Quote(strings.TrimSpace(location.Name)) + " " + diagnostics.SourceRange(location.SourceRefs) + "."
|
||||||
if strings.TrimSpace(location.ID) == "" {
|
if strings.TrimSpace(location.ID) == "" {
|
||||||
issues = append(issues, prefix+".id must not be empty")
|
issues = append(issues, prefix+".id must not be empty")
|
||||||
|
corrections.Add("name", nameRule, record)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(location.Name) == "" {
|
if strings.TrimSpace(location.Name) == "" {
|
||||||
issues = append(issues, prefix+".name must not be empty")
|
issues = append(issues, prefix+".name must not be empty")
|
||||||
|
corrections.Add("name", nameRule, record)
|
||||||
}
|
}
|
||||||
if len(location.SourceRefs) == 0 {
|
if len(location.SourceRefs) == 0 {
|
||||||
issues = append(issues, prefix+".source_refs must not be empty")
|
issues = append(issues, prefix+".source_refs must not be empty")
|
||||||
|
corrections.Add("source-refs", refRule, record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
@@ -92,6 +111,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
|
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
|
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete location registry containing only properly named locations with valid source references."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
locationshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationregistry/shape"
|
locationshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationregistry/shape"
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/location-registry/source_refs"
|
Key = "extract/dnd/location-registry/source_refs"
|
||||||
ReasonCode = "invalid_location_source_refs"
|
ReasonCode = "invalid_location_source_refs"
|
||||||
policy = "dnd.location_registry.validator.source_refs.v2"
|
policy = "dnd.location_registry.validator.source_refs.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -42,35 +43,37 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
|
coverage := shared.NewChunkCoverage(req.Chunk)
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
|
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||||
|
validRangeRule := "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first."
|
||||||
|
guidancePrefix := "Correct every rejected location citation and return the complete replacement location registry"
|
||||||
|
if normalization {
|
||||||
|
validRangeRule = "Revise the duplicate-group proposals so every selected canonical location preserves valid transcript evidence; do not reproduce application IDs."
|
||||||
|
guidancePrefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||||
|
}
|
||||||
for locationIndex, location := range req.Value.Locations {
|
for locationIndex, location := range req.Value.Locations {
|
||||||
for refIndex, ref := range location.SourceRefs {
|
for refIndex, ref := range location.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected location %s, citing %s.", diagnostics.Quote(location.Name), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("locations[%d].source_refs[%d]: %s", locationIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("locations[%d].source_refs[%d]: %s", locationIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", validRangeRule, record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("locations[%d].source_refs[%d]: source reference is outside the current extraction chunk", locationIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("locations[%d].source_refs[%d]: source reference is outside the current extraction chunk", locationIndex, refIndex))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
return rejection(diagnostics.Aggregate("invalid location source references", issues)), nil
|
return rejection(
|
||||||
}
|
diagnostics.Aggregate("invalid location source references", issues),
|
||||||
|
corrections.Guidance(guidancePrefix),
|
||||||
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
|
), nil
|
||||||
if chunk == nil || ref.SourceID != chunk.SourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
startFound := false
|
|
||||||
endFound := false
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
startFound = startFound || unit.ID == ref.StartUnitID
|
|
||||||
endFound = endFound || unit.ID == ref.EndUnitID
|
|
||||||
}
|
|
||||||
return startFound && endFound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
@@ -96,6 +99,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
|
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
|
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
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."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/npc-occurrences/registry"
|
Key = "extract/dnd/npc-occurrences/registry"
|
||||||
ReasonCode = "invalid_npc_occurrence_registry"
|
ReasonCode = "invalid_npc_occurrence_registry"
|
||||||
policy = "dnd.npc_occurrences.validator.registry.v1"
|
policy = "dnd.npc_occurrences.validator.registry.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -84,31 +84,37 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
|
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
|
||||||
}
|
}
|
||||||
if !npcRegistry.Bound() {
|
if !npcRegistry.Bound() {
|
||||||
return rejection([]string{"NPC registry reference is required"}), nil
|
var corrections diagnostics.Corrections
|
||||||
|
corrections.Add("registry", "Use only contextual NPC names from the supplied NPC registry; omit an occurrence that cannot be matched unambiguously.", "")
|
||||||
|
return rejection([]string{"NPC registry reference is required"}, corrections), nil
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for index, occurrence := range req.Value.Occurrences {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
|
record := fmt.Sprintf("Affected %s occurrence for NPC %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||||
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
|
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
|
||||||
if !ok {
|
if !ok {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].npc_id is not in the NPC registry: %s", index, diagnostics.Quote(occurrence.NPCID)))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].npc_id is not in the NPC registry: %s", index, diagnostics.Quote(occurrence.NPCID)))
|
||||||
|
corrections.Add("registry", "Use only contextual NPC names from the supplied NPC registry; omit an occurrence that cannot be matched unambiguously.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if occurrence.Name != canonical.Name {
|
if occurrence.Name != canonical.Name {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].name does not match npc_id: %s", index, diagnostics.Quote(occurrence.Name)))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].name does not match npc_id: %s", index, diagnostics.Quote(occurrence.Name)))
|
||||||
|
corrections.Add("canonical-name", "Use the exact contextual NPC name supplied by the registry.", record+" Use registry name "+diagnostics.Quote(canonical.Name)+".")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
return rejection(issues), nil
|
return rejection(issues, corrections), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rejection(issues []string) contracts.ValidationResult {
|
func rejection(issues []string, corrections diagnostics.Corrections) contracts.ValidationResult {
|
||||||
return contracts.ValidationResult{
|
return contracts.ValidationResult{
|
||||||
Approved: false,
|
Approved: false,
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: diagnostics.Aggregate("invalid NPC occurrence registry", issues),
|
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.",
|
CorrectionGuidance: corrections.Guidance("Correct every rejected NPC occurrence and return the complete replacement occurrence list"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/npc-occurrences/shape"
|
Key = "extract/dnd/npc-occurrences/shape"
|
||||||
ReasonCode = "invalid_npc_occurrence_shape"
|
ReasonCode = "invalid_npc_occurrence_shape"
|
||||||
policy = "dnd.npc_occurrences.validator.shape.v1"
|
policy = "dnd.npc_occurrences.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -34,41 +34,54 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
if err := Validate(req.Value); err != nil {
|
issues, corrections := assess(req.Value)
|
||||||
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
|
if len(issues) != 0 {
|
||||||
|
return contracts.ValidationResult{
|
||||||
|
Approved: false,
|
||||||
|
ReasonCode: ReasonCode,
|
||||||
|
Message: diagnostics.Aggregate("invalid NPC occurrence shape", issues),
|
||||||
|
CorrectionGuidance: corrections.Guidance("Correct every rejected NPC occurrence and return the complete replacement occurrence list"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.NPCOccurrenceList) error {
|
func Validate(value dnd.NPCOccurrenceList) error {
|
||||||
issues := issuesFor(value)
|
issues, _ := assess(value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC occurrence shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC occurrence shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.NPCOccurrenceList) []string {
|
func assess(value dnd.NPCOccurrenceList) ([]string, diagnostics.Corrections) {
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
if value.Occurrences == nil {
|
if value.Occurrences == nil {
|
||||||
return []string{"occurrences must be present"}
|
corrections.Add("list", "Return an `occurrences` array; use an empty array when the transcript establishes no NPC occurrences.", "")
|
||||||
|
return []string{"occurrences must be present"}, corrections
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, occurrence := range value.Occurrences {
|
for index, occurrence := range value.Occurrences {
|
||||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||||
|
record := fmt.Sprintf("Affected %s occurrence for NPC %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(strings.TrimSpace(occurrence.Name)), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||||
if strings.TrimSpace(occurrence.NPCID) == "" {
|
if strings.TrimSpace(occurrence.NPCID) == "" {
|
||||||
issues = append(issues, prefix+".npc_id must not be empty: "+diagnostics.Quote(occurrence.NPCID))
|
issues = append(issues, prefix+".npc_id must not be empty: "+diagnostics.Quote(occurrence.NPCID))
|
||||||
|
corrections.Add("name", "Select a nonblank contextual NPC name from the supplied registry for every occurrence.", record)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(occurrence.Name) == "" {
|
if strings.TrimSpace(occurrence.Name) == "" {
|
||||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(occurrence.Name))
|
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(occurrence.Name))
|
||||||
|
corrections.Add("name", "Select a nonblank contextual NPC name from the supplied registry for every occurrence.", record)
|
||||||
}
|
}
|
||||||
if !validKind(occurrence.Kind) {
|
if !validKind(occurrence.Kind) {
|
||||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind)))
|
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind)))
|
||||||
|
corrections.Add("kind", "Set `kind` to exactly one of `mentioned`, `noncombat_presence`, `dialogue`, `combat_ally`, `combat_opponent`, or `other`.", record)
|
||||||
}
|
}
|
||||||
if len(occurrence.SourceRefs) == 0 {
|
if len(occurrence.SourceRefs) == 0 {
|
||||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||||
|
corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every NPC occurrence.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
}
|
||||||
|
|
||||||
func validKind(value dnd.NPCOccurrenceKind) bool {
|
func validKind(value dnd.NPCOccurrenceKind) bool {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcoccurrences/shape"
|
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcoccurrences/shape"
|
||||||
)
|
)
|
||||||
@@ -16,7 +17,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/npc-occurrences/source_refs"
|
Key = "extract/dnd/npc-occurrences/source_refs"
|
||||||
ReasonCode = "invalid_npc_occurrence_source_refs"
|
ReasonCode = "invalid_npc_occurrence_source_refs"
|
||||||
policy = "dnd.npc_occurrences.validator.source_refs.v2"
|
policy = "dnd.npc_occurrences.validator.source_refs.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -42,18 +43,23 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
|
coverage := shared.NewChunkCoverage(req.Chunk)
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
||||||
for refIndex, ref := range occurrence.SourceRefs {
|
for refIndex, ref := range occurrence.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected %s occurrence for NPC %s, citing %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||||
issues = append(issues, fmt.Sprintf(
|
issues = append(issues, fmt.Sprintf(
|
||||||
"occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk",
|
"occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk",
|
||||||
occurrenceIndex, refIndex,
|
occurrenceIndex, refIndex,
|
||||||
))
|
))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,23 +70,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
Approved: false,
|
Approved: false,
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: diagnostics.Aggregate("invalid NPC occurrence source references", issues),
|
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.",
|
CorrectionGuidance: corrections.Guidance("Correct every rejected NPC-occurrence citation and return the complete replacement occurrence list"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
|
|
||||||
if chunk == nil || ref.SourceID != chunk.SourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
startFound := false
|
|
||||||
endFound := false
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
startFound = startFound || unit.ID == ref.StartUnitID
|
|
||||||
endFound = endFound || unit.ID == ref.EndUnitID
|
|
||||||
}
|
|
||||||
return startFound && endFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
Key = "normalize/dnd/npc-registry/identity"
|
Key = "normalize/dnd/npc-registry/identity"
|
||||||
ReasonCode = "invalid_npc_identity"
|
ReasonCode = "invalid_npc_identity"
|
||||||
policy = domainidentity.Policy
|
policy = domainidentity.Policy
|
||||||
|
correctionPolicy = "dnd.npc_registry.validator.identity.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -33,7 +34,7 @@ func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "correction_policy", Value: correctionPolicy}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) {
|
||||||
@@ -46,18 +47,39 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
issues := make([]string, len(identityIssues))
|
issues := make([]string, len(identityIssues))
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for index, issue := range identityIssues {
|
for index, issue := range identityIssues {
|
||||||
location := fmt.Sprintf("npcs[%d]", issue.RecordIndex)
|
location := fmt.Sprintf("npcs[%d]", issue.RecordIndex)
|
||||||
issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value))
|
issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value))
|
||||||
|
if issue.RecordIndex < 0 || issue.RecordIndex >= len(req.Value.NPCs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
npc := req.Value.NPCs[issue.RecordIndex]
|
||||||
|
corrections.Add(string(issue.Code), npcIdentityCorrection(issue.Code), fmt.Sprintf("Affected NPC %s %s.", diagnostics.Quote(npc.Name), diagnostics.SourceRange(npc.SourceRefs)))
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{
|
return contracts.ValidationResult{
|
||||||
Approved: false,
|
Approved: false,
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: diagnostics.Aggregate("invalid NPC identity", issues),
|
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.",
|
CorrectionGuidance: corrections.Guidance("Correct the duplicate-group proposals and return the complete replacement proposal response"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func npcIdentityCorrection(code domainidentity.IssueCode) string {
|
||||||
|
switch code {
|
||||||
|
case domainidentity.IssueEmptyCanonicalName:
|
||||||
|
return "Select a canonical proposal member with a nonblank, transcript-supported proper NPC name."
|
||||||
|
case domainidentity.IssueDuplicateCanonical:
|
||||||
|
return "Put duplicate mentions of the same NPC in one proposal group and select one transcript-supported canonical member."
|
||||||
|
case domainidentity.IssueInvalidID, domainidentity.IssueIDMismatch:
|
||||||
|
return "Revise the proposal so its canonical NPC member has a valid transcript-supported name; Notarius derives durable identity without model input."
|
||||||
|
case domainidentity.IssueDuplicateID:
|
||||||
|
return "Do not use proposals that collapse distinct NPCs into one canonical identity; group only records that describe the same NPC."
|
||||||
|
default:
|
||||||
|
return "Revise the duplicate-group proposal so every canonical NPC is transcript-supported and distinct."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestValidatorContractAndRegistration(t *testing.T) {
|
|||||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||||
t.Fatalf("DecodeOptions() error = %v, want unknown option error", err)
|
t.Fatalf("DecodeOptions() error = %v, want unknown option error", err)
|
||||||
}
|
}
|
||||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 2 || got[0].Name != "policy" || got[0].Value != policy || got[1].Name != "correction_policy" || got[1].Value != correctionPolicy {
|
||||||
t.Fatalf("fingerprints = %#v, want identity policy", got)
|
t.Fatalf("fingerprints = %#v, want identity policy", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +65,9 @@ func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) {
|
|||||||
t.Fatalf("identity message %q missing %q", result.Message, want)
|
t.Fatalf("identity message %q missing %q", result.Message, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, value.NPCs[0].Name) || strings.Contains(result.CorrectionGuidance, value.NPCs[0].ID) || strings.Contains(result.CorrectionGuidance, "npcs[") {
|
||||||
|
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance without IDs or operator paths", result.CorrectionGuidance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorBoundsUnicodeDiagnostics(t *testing.T) {
|
func TestValidatorBoundsUnicodeDiagnostics(t *testing.T) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/npc-registry/shape"
|
Key = "extract/dnd/npc-registry/shape"
|
||||||
ReasonCode = "invalid_npc_shape"
|
ReasonCode = "invalid_npc_shape"
|
||||||
policy = "dnd.npc_registry.validator.shape.v1"
|
policy = "dnd.npc_registry.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -33,39 +33,58 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) {
|
||||||
issues := issuesFor(req.Value)
|
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||||
|
issues, corrections := assess(req.Value, normalization)
|
||||||
if len(issues) > 0 {
|
if len(issues) > 0 {
|
||||||
return rejection(diagnostics.Aggregate("invalid NPC shape", issues)), nil
|
prefix := "Correct every rejected NPC and return the complete replacement registry"
|
||||||
|
if normalization {
|
||||||
|
prefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||||
|
}
|
||||||
|
return rejection(diagnostics.Aggregate("invalid NPC shape", issues), corrections.Guidance(prefix)), nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.NPCRegistry) error {
|
func Validate(value dnd.NPCRegistry) error {
|
||||||
issues := issuesFor(value)
|
issues, _ := assess(value, false)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(value dnd.NPCRegistry) []string {
|
func assess(value dnd.NPCRegistry, normalization bool) ([]string, diagnostics.Corrections) {
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
|
nameRule := "Provide one nonblank, transcript-supported proper NPC name for every registry entry."
|
||||||
|
refRule := "Provide at least one transcript source range that directly supports every named NPC."
|
||||||
|
listRule := "Return an `npcs` array; use an empty array when the transcript establishes no named NPCs."
|
||||||
|
if normalization {
|
||||||
|
nameRule = "Revise the duplicate-group proposals so every selected canonical NPC has a nonblank, transcript-supported proper name."
|
||||||
|
refRule = "Revise the duplicate-group proposals so every selected canonical NPC preserves direct transcript evidence."
|
||||||
|
listRule = "Revise the duplicate-group proposals so normalization retains the complete NPC candidate registry."
|
||||||
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
if value.NPCs == nil {
|
if value.NPCs == nil {
|
||||||
return []string{"npcs must be present"}
|
corrections.Add("list", listRule, "")
|
||||||
|
return []string{"npcs must be present"}, corrections
|
||||||
}
|
}
|
||||||
for index, npc := range value.NPCs {
|
for index, npc := range value.NPCs {
|
||||||
prefix := fmt.Sprintf("npcs[%d]", index)
|
prefix := fmt.Sprintf("npcs[%d]", index)
|
||||||
|
record := "Affected NPC " + diagnostics.Quote(strings.TrimSpace(npc.Name)) + " " + diagnostics.SourceRange(npc.SourceRefs) + "."
|
||||||
if strings.TrimSpace(npc.ID) == "" {
|
if strings.TrimSpace(npc.ID) == "" {
|
||||||
issues = append(issues, prefix+".id must not be empty")
|
issues = append(issues, prefix+".id must not be empty")
|
||||||
|
corrections.Add("name", nameRule, record)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(npc.Name) == "" {
|
if strings.TrimSpace(npc.Name) == "" {
|
||||||
issues = append(issues, prefix+".name must not be empty")
|
issues = append(issues, prefix+".name must not be empty")
|
||||||
|
corrections.Add("name", nameRule, record)
|
||||||
}
|
}
|
||||||
if len(npc.SourceRefs) == 0 {
|
if len(npc.SourceRefs) == 0 {
|
||||||
issues = append(issues, prefix+".source_refs must not be empty")
|
issues = append(issues, prefix+".source_refs must not be empty")
|
||||||
|
corrections.Add("source-refs", refRule, record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return issues
|
return issues, corrections
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
@@ -91,6 +110,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
|
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
|
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete NPC registry containing only properly named NPCs with valid source references."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestValidatorBoundsDiagnosticsAndQuotesUnicode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != "dnd.npc_registry.validator.shape.v1" {
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||||
}
|
}
|
||||||
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
|
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||||
@@ -74,6 +74,21 @@ func TestValidatorDoesNotMutateValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidatorUsesProposalGuidanceDuringNormalization(t *testing.T) {
|
||||||
|
value := validNPCRegistry()
|
||||||
|
value.NPCs[0].ID = "npc:sha256:opaque"
|
||||||
|
value.NPCs[0].Name = ""
|
||||||
|
req := requestWithValue(value)
|
||||||
|
req.Stage = string(pipeline.StageNormalize)
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), req)
|
||||||
|
if err != nil || result.Approved {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || strings.Contains(result.CorrectionGuidance, value.NPCs[0].ID) || strings.Contains(result.CorrectionGuidance, "npcs[") {
|
||||||
|
t.Fatalf("CorrectionGuidance = %q, want proposal guidance without IDs or operator paths", result.CorrectionGuidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func requestWithValue(value dnd.NPCRegistry) contracts.TypedValidationRequest[dnd.NPCRegistry] {
|
func requestWithValue(value dnd.NPCRegistry) contracts.TypedValidationRequest[dnd.NPCRegistry] {
|
||||||
return contracts.TypedValidationRequest[dnd.NPCRegistry]{Value: value}
|
return contracts.TypedValidationRequest[dnd.NPCRegistry]{Value: value}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcregistry/shape"
|
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcregistry/shape"
|
||||||
)
|
)
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/npc-registry/source_refs"
|
Key = "extract/dnd/npc-registry/source_refs"
|
||||||
ReasonCode = "invalid_npc_source_refs"
|
ReasonCode = "invalid_npc_source_refs"
|
||||||
policy = "dnd.npc_registry.validator.source_refs.v2"
|
policy = "dnd.npc_registry.validator.source_refs.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -41,56 +42,40 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
var coverage *chunkCoverage
|
var coverage shared.ChunkCoverage
|
||||||
if req.Stage == string(pipeline.StageExtract) {
|
if req.Stage == string(pipeline.StageExtract) {
|
||||||
coverage = newChunkCoverage(req.Chunk)
|
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
|
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||||
|
validRangeRule := "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first."
|
||||||
|
guidancePrefix := "Correct every rejected NPC citation and return the complete replacement NPC registry"
|
||||||
|
if normalization {
|
||||||
|
validRangeRule = "Revise the duplicate-group proposals so every selected canonical NPC preserves valid transcript evidence; do not reproduce application IDs."
|
||||||
|
guidancePrefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||||
|
}
|
||||||
for npcIndex, npc := range req.Value.NPCs {
|
for npcIndex, npc := range req.Value.NPCs {
|
||||||
for refIndex, ref := range npc.SourceRefs {
|
for refIndex, ref := range npc.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected NPC %s, citing %s.", diagnostics.Quote(npc.Name), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: %s", npcIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: %s", npcIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", validRangeRule, record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: source reference is outside the current extraction chunk", npcIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: source reference is outside the current extraction chunk", npcIndex, refIndex))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
return rejection(diagnostics.Aggregate("invalid NPC source references", issues)), nil
|
return rejection(
|
||||||
}
|
diagnostics.Aggregate("invalid NPC source references", issues),
|
||||||
|
corrections.Guidance(guidancePrefix),
|
||||||
type chunkCoverage struct {
|
), nil
|
||||||
sourceID string
|
|
||||||
unitIDs map[int]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
|
||||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
coverage.unitIDs[unit.ID] = struct{}{}
|
|
||||||
}
|
|
||||||
return coverage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
|
||||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
|
||||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
|
||||||
if !startOK || !endOK || start > end {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for position := start; position <= end; position++ {
|
|
||||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
@@ -116,6 +101,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
|
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
|
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
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."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ func TestValidatorDefersMalformedShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidatorUsesProposalGuidanceDuringNormalization(t *testing.T) {
|
||||||
|
value := validNPCRegistry()
|
||||||
|
value.NPCs[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}
|
||||||
|
req := requestWithValue(validDocument(), value)
|
||||||
|
req.Stage = string(pipeline.StageNormalize)
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), req)
|
||||||
|
if err != nil || result.Approved {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, "Mira Thorn") || strings.Contains(result.CorrectionGuidance, "npcs[") {
|
||||||
|
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance", result.CorrectionGuidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidatorBoundsDiagnosticsAndHandlesMissingDocument(t *testing.T) {
|
func TestValidatorBoundsDiagnosticsAndHandlesMissingDocument(t *testing.T) {
|
||||||
value := validNPCRegistry()
|
value := validNPCRegistry()
|
||||||
value.NPCs[0].SourceRefs = make([]source.SourceRef, 24)
|
value.NPCs[0].SourceRefs = make([]source.SourceRef, 24)
|
||||||
@@ -86,7 +100,7 @@ func TestValidatorBoundsDiagnosticsAndHandlesMissingDocument(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != "dnd.npc_registry.validator.source_refs.v2" {
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||||
}
|
}
|
||||||
if Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
if Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/scene-descriptions/shape"
|
Key = "extract/dnd/scene-descriptions/shape"
|
||||||
ReasonCode = "invalid_scene_description_shape"
|
ReasonCode = "invalid_scene_description_shape"
|
||||||
policy = "dnd.scene_descriptions.validator.shape.v1"
|
policy = "dnd.scene_descriptions.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -34,8 +34,14 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
|
||||||
if err := ValidateForStage(req.Value, req.Stage); err != nil {
|
issues, corrections := assess(req.Value, req.Stage == string(pipeline.StageExtract))
|
||||||
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
|
if len(issues) != 0 {
|
||||||
|
return contracts.ValidationResult{
|
||||||
|
Approved: false,
|
||||||
|
ReasonCode: ReasonCode,
|
||||||
|
Message: diagnostics.Aggregate("invalid scene description shape", issues),
|
||||||
|
CorrectionGuidance: corrections.Guidance("Correct the rejected scene description and return the complete replacement response"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -47,38 +53,53 @@ func ValidateForStage(value dnd.SceneDescriptionList, stage string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validate(value dnd.SceneDescriptionList, exactlyOne bool) error {
|
func validate(value dnd.SceneDescriptionList, exactlyOne bool) error {
|
||||||
issues := make([]string, 0)
|
issues, _ := assess(value, exactlyOne)
|
||||||
if value.Scenes == nil {
|
|
||||||
issues = append(issues, "scenes must be present")
|
|
||||||
} else if len(value.Scenes) == 0 {
|
|
||||||
issues = append(issues, "scenes must not be empty")
|
|
||||||
} else if exactlyOne && len(value.Scenes) != 1 {
|
|
||||||
issues = append(issues, "extraction must contain exactly one scene")
|
|
||||||
}
|
|
||||||
for index, scene := range value.Scenes {
|
|
||||||
prefix := fmt.Sprintf("scenes[%d]", index)
|
|
||||||
if strings.TrimSpace(scene.ID) == "" || scene.ID != strings.TrimSpace(scene.ID) {
|
|
||||||
issues = append(issues, prefix+".id must be non-empty and trimmed")
|
|
||||||
}
|
|
||||||
if !ValidKind(scene.Kind) {
|
|
||||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(scene.Kind)))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(scene.Title) == "" || scene.Title != strings.TrimSpace(scene.Title) {
|
|
||||||
issues = append(issues, prefix+".title must be non-empty and trimmed")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(scene.Summary) == "" || scene.Summary != strings.TrimSpace(scene.Summary) {
|
|
||||||
issues = append(issues, prefix+".summary must be non-empty and trimmed")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(scene.SourceRef.SourceID) == "" || scene.SourceRef.SourceID != strings.TrimSpace(scene.SourceRef.SourceID) || scene.SourceRef.StartUnitID <= 0 || scene.SourceRef.EndUnitID <= 0 {
|
|
||||||
issues = append(issues, prefix+".source_ref must have a trimmed source ID and positive unit IDs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid scene description shape", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid scene description shape", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assess(value dnd.SceneDescriptionList, exactlyOne bool) ([]string, diagnostics.Corrections) {
|
||||||
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
|
if value.Scenes == nil {
|
||||||
|
issues = append(issues, "scenes must be present")
|
||||||
|
corrections.Add("scene", "Return one scene description for the supplied extraction chunk.", "")
|
||||||
|
} else if len(value.Scenes) == 0 {
|
||||||
|
issues = append(issues, "scenes must not be empty")
|
||||||
|
corrections.Add("scene", "Return one scene description for the supplied extraction chunk.", "")
|
||||||
|
} else if exactlyOne && len(value.Scenes) != 1 {
|
||||||
|
issues = append(issues, "extraction must contain exactly one scene")
|
||||||
|
corrections.Add("scene", "Return exactly one scene description for the supplied extraction chunk.", "")
|
||||||
|
}
|
||||||
|
for index, scene := range value.Scenes {
|
||||||
|
prefix := fmt.Sprintf("scenes[%d]", index)
|
||||||
|
record := fmt.Sprintf("Affected scene titled %s with kind %s, citing %s.", diagnostics.Quote(strings.TrimSpace(scene.Title)), diagnostics.Quote(string(scene.Kind)), diagnostics.SourceRefRange(scene.SourceRef))
|
||||||
|
if strings.TrimSpace(scene.ID) == "" || scene.ID != strings.TrimSpace(scene.ID) {
|
||||||
|
issues = append(issues, prefix+".id must be non-empty and trimmed")
|
||||||
|
corrections.Add("scene", "Return exactly one scene description for the supplied extraction chunk.", record)
|
||||||
|
}
|
||||||
|
if !ValidKind(scene.Kind) {
|
||||||
|
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(scene.Kind)))
|
||||||
|
corrections.Add("kind", "Set `kind` to exactly one of `combat`, `narrative`, `recap`, or `meta`.", record)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(scene.Title) == "" || scene.Title != strings.TrimSpace(scene.Title) {
|
||||||
|
issues = append(issues, prefix+".title must be non-empty and trimmed")
|
||||||
|
corrections.Add("title", "Provide a concise, nonblank, trimmed title grounded in the supplied scene transcript.", record)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(scene.Summary) == "" || scene.Summary != strings.TrimSpace(scene.Summary) {
|
||||||
|
issues = append(issues, prefix+".summary must be non-empty and trimmed")
|
||||||
|
corrections.Add("summary", "Provide a concise, nonblank, trimmed summary grounded in the supplied scene transcript.", record)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(scene.SourceRef.SourceID) == "" || scene.SourceRef.SourceID != strings.TrimSpace(scene.SourceRef.SourceID) || scene.SourceRef.StartUnitID <= 0 || scene.SourceRef.EndUnitID <= 0 {
|
||||||
|
issues = append(issues, prefix+".source_ref must have a trimmed source ID and positive unit IDs")
|
||||||
|
corrections.Add("scene", "Return exactly one scene description for the supplied extraction chunk.", record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues, corrections
|
||||||
|
}
|
||||||
|
|
||||||
func ValidKind(value dnd.SceneKind) bool {
|
func ValidKind(value dnd.SceneKind) bool {
|
||||||
switch value {
|
switch value {
|
||||||
case dnd.SceneKindCombat, dnd.SceneKindNarrative, dnd.SceneKindRecap, dnd.SceneKindMeta:
|
case dnd.SceneKindCombat, dnd.SceneKindNarrative, dnd.SceneKindRecap, dnd.SceneKindMeta:
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||||
)
|
)
|
||||||
@@ -15,9 +17,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/spells/catalog"
|
Key = "extract/dnd/spells/catalog"
|
||||||
ReasonCode = "unknown_spell"
|
ReasonCode = "unknown_spell"
|
||||||
maxIssues = 20
|
policy = "dnd.spells.validator.catalog.v2"
|
||||||
maxDisplayedNameRunes = 128
|
|
||||||
maxMessageBytes = 4096
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -54,7 +54,10 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{{Name: "effective_catalog", Value: v.catalog.Digest()}}
|
return []pipeline.CheckpointFingerprint{
|
||||||
|
{Name: "policy", Value: policy},
|
||||||
|
{Name: "effective_catalog", Value: v.catalog.Digest()},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||||
@@ -69,7 +72,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := v.catalog.Lookup(name); !ok {
|
if _, ok := v.catalog.Lookup(name); !ok {
|
||||||
unknown = append(unknown, unknownSpell{index: index, name: name})
|
unknown = append(unknown, unknownSpell{index: index, name: name, caster: spell.Caster, sourceRefs: spell.SourceRefs})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(unknown) == 0 {
|
if len(unknown) == 0 {
|
||||||
@@ -81,40 +84,27 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
type unknownSpell struct {
|
type unknownSpell struct {
|
||||||
index int
|
index int
|
||||||
name string
|
name string
|
||||||
|
caster string
|
||||||
|
sourceRefs []source.SourceRef
|
||||||
}
|
}
|
||||||
|
|
||||||
func rejection(unknown []unknownSpell) contracts.ValidationResult {
|
func rejection(unknown []unknownSpell) contracts.ValidationResult {
|
||||||
limit := len(unknown)
|
issues := make([]string, 0, len(unknown))
|
||||||
if limit > maxIssues {
|
var corrections diagnostics.Corrections
|
||||||
limit = maxIssues
|
for _, item := range unknown {
|
||||||
|
issues = append(issues, fmt.Sprintf("spell_casts[%d].spell %s", item.index, diagnostics.Quote(item.name)))
|
||||||
|
corrections.Add(
|
||||||
|
"recognized-spell",
|
||||||
|
"Use a recognized D&D spell name supported by the transcript, or omit the record when the evidence does not establish a spell cast.",
|
||||||
|
fmt.Sprintf("Affected spell %s by caster %s %s.", diagnostics.Quote(item.name), diagnostics.Quote(strings.TrimSpace(item.caster)), diagnostics.SourceRange(item.sourceRefs)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
issues := make([]string, 0, limit)
|
return contracts.ValidationResult{
|
||||||
for _, item := range unknown[:limit] {
|
Approved: false,
|
||||||
issue := fmt.Sprintf("spell_casts[%d].spell %q", item.index, truncateDisplayedName(item.name))
|
ReasonCode: ReasonCode,
|
||||||
candidate := rejectionMessage(append(issues, issue), len(unknown)-len(issues)-1)
|
Message: diagnostics.Aggregate("unknown spell names", issues),
|
||||||
if len(candidate) > maxMessageBytes {
|
CorrectionGuidance: corrections.Guidance("Correct every unrecognized spell and return the complete replacement spell-cast list"),
|
||||||
break
|
|
||||||
}
|
}
|
||||||
issues = append(issues, issue)
|
|
||||||
}
|
|
||||||
message := rejectionMessage(issues, len(unknown)-len(issues))
|
|
||||||
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 {
|
|
||||||
runes := []rune(name)
|
|
||||||
if len(runes) <= maxDisplayedNameRunes {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return string(runes[:maxDisplayedNameRunes-1]) + "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
func rejectionMessage(issues []string, omitted int) string {
|
|
||||||
message := "unknown spell names: " + strings.Join(issues, ", ")
|
|
||||||
if omitted > 0 {
|
|
||||||
message += fmt.Sprintf("; %d additional issue(s) omitted", omitted)
|
|
||||||
}
|
|
||||||
return message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ package catalog
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
@@ -36,7 +34,7 @@ func TestValidatorCheckpointFingerprintUsesEffectiveCatalogDigest(t *testing.T)
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
fingerprints := validator.CheckpointFingerprints()
|
fingerprints := validator.CheckpointFingerprints()
|
||||||
if len(fingerprints) != 1 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != validator.catalog.Digest() {
|
if len(fingerprints) != 2 || fingerprints[0].Name != "policy" || fingerprints[0].Value != policy || fingerprints[1].Name != "effective_catalog" || fingerprints[1].Value != validator.catalog.Digest() {
|
||||||
t.Fatalf("checkpoint fingerprints = %#v, want effective catalog digest %q", fingerprints, validator.catalog.Digest())
|
t.Fatalf("checkpoint fingerprints = %#v, want effective catalog digest %q", fingerprints, validator.catalog.Digest())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,59 +58,13 @@ func TestValidatorRejectsMultipleUnknownCastsInStableOrder(t *testing.T) {
|
|||||||
if want := `spell_casts[0].spell "Unknown First", spell_casts[2].spell "Unknown Second"`; !strings.Contains(result.Message, want) {
|
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)
|
t.Fatalf("message = %q, want %q", result.Message, want)
|
||||||
}
|
}
|
||||||
}
|
for _, want := range []string{"Unknown First", "Unknown Second", "source unit 1", "complete replacement"} {
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, want) {
|
||||||
func TestValidatorBoundsUnknownCastMessage(t *testing.T) {
|
t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, want)
|
||||||
value := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 22)}
|
|
||||||
for index := range value.SpellCasts {
|
|
||||||
value.SpellCasts[index] = validCast(fmt.Sprintf("Unknown Spell %02d", index))
|
|
||||||
}
|
|
||||||
validator, err := New(Options{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Validate() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
if result.Approved || result.ReasonCode != ReasonCode {
|
|
||||||
t.Fatalf("Validate() = %#v, want unknown-spell rejection", result)
|
|
||||||
}
|
|
||||||
if got := strings.Count(result.Message, "spell_casts["); got != maxIssues {
|
|
||||||
t.Fatalf("message includes %d issues, want %d: %q", got, maxIssues, result.Message)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.Message, "2 additional issue(s) omitted") || strings.Contains(result.Message, "Unknown Spell 21") {
|
|
||||||
t.Fatalf("message = %q, want bounded diagnostics", result.Message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(result.CorrectionGuidance, "spell_casts[") || strings.Contains(result.CorrectionGuidance, ReasonCode) {
|
||||||
func TestValidatorBoundsUnknownSpellNamesAndTotalMessage(t *testing.T) {
|
t.Fatalf("CorrectionGuidance exposed internal diagnostics: %q", result.CorrectionGuidance)
|
||||||
longName := strings.Repeat("火", maxDisplayedNameRunes+100) + "\n\t"
|
|
||||||
value := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, maxIssues)}
|
|
||||||
for index := range value.SpellCasts {
|
|
||||||
value.SpellCasts[index] = validCast(fmt.Sprintf("%s-%d", longName, index))
|
|
||||||
}
|
|
||||||
validator, err := New(Options{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result.Approved || len([]byte(result.Message)) > maxMessageBytes || !strings.Contains(result.Message, "…") {
|
|
||||||
t.Fatalf("message length/content = %d/%q, want bounded message with truncation", len([]byte(result.Message)), result.Message)
|
|
||||||
}
|
|
||||||
if !strings.Contains(result.Message, "additional issue(s) omitted") {
|
|
||||||
t.Fatalf("message = %q, want byte-budget omitted count", result.Message)
|
|
||||||
}
|
|
||||||
displayed := strings.Count(result.Message, "spell_casts[")
|
|
||||||
wantOmitted := fmt.Sprintf("%d additional issue(s) omitted", len(value.SpellCasts)-displayed)
|
|
||||||
if !strings.Contains(result.Message, wantOmitted) {
|
|
||||||
t.Fatalf("message = %q, want omitted count %q", result.Message, wantOmitted)
|
|
||||||
}
|
|
||||||
if !utf8.ValidString(result.Message) {
|
|
||||||
t.Fatal("bounded message is not valid UTF-8")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "extract/dnd/spells/shape"
|
Key = "extract/dnd/spells/shape"
|
||||||
ReasonCode = "invalid_spell_shape"
|
ReasonCode = "invalid_spell_shape"
|
||||||
policy = "dnd.spells.validator.shape.v1"
|
policy = "dnd.spells.validator.shape.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -31,28 +32,69 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||||
}
|
}
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||||
if err := Validate(req.Value); err != nil {
|
assessment := assess(req.Value)
|
||||||
return rejection(err.Error()), nil
|
if len(assessment.issues) != 0 {
|
||||||
|
return rejection(assessment.message(), assessment.corrections.Guidance("Correct every rejected spell cast and return the complete replacement list")), nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Validate(value dnd.SpellList) error {
|
func Validate(value dnd.SpellList) error {
|
||||||
|
assessment := assess(value)
|
||||||
|
if len(assessment.issues) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", assessment.message())
|
||||||
|
}
|
||||||
|
|
||||||
|
type validationAssessment struct {
|
||||||
|
issues []string
|
||||||
|
corrections diagnostics.Corrections
|
||||||
|
}
|
||||||
|
|
||||||
|
func assess(value dnd.SpellList) validationAssessment {
|
||||||
|
var assessment validationAssessment
|
||||||
if value.SpellCasts == nil {
|
if value.SpellCasts == nil {
|
||||||
return fmt.Errorf("spell_casts must be present")
|
assessment.issues = append(assessment.issues, "spell_casts must be present")
|
||||||
|
assessment.corrections.Add("list", "Return a `spell_casts` array; use an empty array when the transcript establishes no spell casts.", "")
|
||||||
|
return assessment
|
||||||
}
|
}
|
||||||
for index, spell := range value.SpellCasts {
|
for index, spell := range value.SpellCasts {
|
||||||
|
context := spellContext(spell)
|
||||||
if strings.TrimSpace(spell.Caster) == "" {
|
if strings.TrimSpace(spell.Caster) == "" {
|
||||||
return fmt.Errorf("spell_casts[%d].caster must not be empty", index)
|
assessment.issues = append(assessment.issues, fmt.Sprintf("spell_casts[%d].caster must not be empty", index))
|
||||||
|
assessment.corrections.Add("caster", "Provide the contextual caster name for every spell cast.", context)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(spell.Spell) == "" {
|
if strings.TrimSpace(spell.Spell) == "" {
|
||||||
return fmt.Errorf("spell_casts[%d].spell must not be empty", index)
|
assessment.issues = append(assessment.issues, fmt.Sprintf("spell_casts[%d].spell must not be empty", index))
|
||||||
|
assessment.corrections.Add("spell", "Provide the transcript-supported spell name for every spell cast.", context)
|
||||||
}
|
}
|
||||||
if len(spell.SourceRefs) == 0 {
|
if len(spell.SourceRefs) == 0 {
|
||||||
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index)
|
assessment.issues = append(assessment.issues, fmt.Sprintf("spell_casts[%d].source_refs must not be empty", index))
|
||||||
|
assessment.corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every spell cast.", context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return assessment
|
||||||
|
}
|
||||||
|
|
||||||
|
func (assessment validationAssessment) message() string {
|
||||||
|
return diagnostics.Aggregate("invalid spell shape", assessment.issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
func spellContext(spell dnd.SpellCast) string {
|
||||||
|
name := strings.TrimSpace(spell.Spell)
|
||||||
|
if name == "" {
|
||||||
|
name = "blank spell name"
|
||||||
|
} else {
|
||||||
|
name = "spell " + diagnostics.Quote(name)
|
||||||
|
}
|
||||||
|
caster := strings.TrimSpace(spell.Caster)
|
||||||
|
if caster == "" {
|
||||||
|
caster = "blank caster name"
|
||||||
|
} else {
|
||||||
|
caster = "caster " + diagnostics.Quote(caster)
|
||||||
|
}
|
||||||
|
return "Affected cast: " + name + " with " + caster + " " + diagnostics.SourceRange(spell.SourceRefs) + "."
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
@@ -74,6 +116,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
return Options{}, nil
|
return Options{}, nil
|
||||||
}
|
}
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
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."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package shape
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
@@ -54,6 +55,30 @@ func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidatorReportsAllSpellDefectsWithContextualGuidance(t *testing.T) {
|
||||||
|
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
||||||
|
{Caster: "", Spell: "Fire Bolt", SourceRefs: refsAt(4)},
|
||||||
|
{Caster: "Aria", Spell: "", SourceRefs: nil},
|
||||||
|
}}
|
||||||
|
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
|
||||||
|
if err != nil || result.Approved {
|
||||||
|
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"spell_casts[0].caster", "spell_casts[1].spell", "spell_casts[1].source_refs"} {
|
||||||
|
if !strings.Contains(result.Message, want) {
|
||||||
|
t.Fatalf("Message = %q, want %q", result.Message, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Fire Bolt", "source unit 4", "Aria", "complete replacement"} {
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, want) {
|
||||||
|
t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(result.CorrectionGuidance, "spell_casts[") {
|
||||||
|
t.Fatalf("CorrectionGuidance exposed operator path: %q", result.CorrectionGuidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||||
@@ -77,3 +102,7 @@ func requestWithValue(value dnd.SpellList) contracts.TypedValidationRequest[dnd.
|
|||||||
func validSpellList() dnd.SpellList {
|
func validSpellList() dnd.SpellList {
|
||||||
return dnd.SpellList{SpellCasts: []dnd.SpellCast{{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
return dnd.SpellList{SpellCasts: []dnd.SpellCast{{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refsAt(unitID int) []source.SourceRef {
|
||||||
|
return []source.SourceRef{{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID}}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||||
)
|
)
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "extract/dnd/spells/source_refs"
|
Key = "extract/dnd/spells/source_refs"
|
||||||
ReasonCode = "invalid_source_refs"
|
ReasonCode = "invalid_source_refs"
|
||||||
policy = "dnd.spells.validator.source_refs.v2"
|
policy = "dnd.spells.validator.source_refs.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -40,57 +41,35 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
var coverage *chunkCoverage
|
var coverage shared.ChunkCoverage
|
||||||
if req.Stage == string(pipeline.StageExtract) {
|
if req.Stage == string(pipeline.StageExtract) {
|
||||||
coverage = newChunkCoverage(req.Chunk)
|
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||||
}
|
}
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
|
var corrections diagnostics.Corrections
|
||||||
for spellIndex, spell := range req.Value.SpellCasts {
|
for spellIndex, spell := range req.Value.SpellCasts {
|
||||||
for refIndex, ref := range spell.SourceRefs {
|
for refIndex, ref := range spell.SourceRefs {
|
||||||
|
record := fmt.Sprintf("Affected spell %s by caster %s, citing %s.", diagnostics.Quote(spell.Spell), diagnostics.Quote(spell.Caster), diagnostics.SourceRefRange(ref))
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
|
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||||
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: source reference is outside the current extraction chunk", spellIndex, refIndex))
|
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: source reference is outside the current extraction chunk", spellIndex, refIndex))
|
||||||
|
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(issues) > 0 {
|
if len(issues) > 0 {
|
||||||
return rejection(diagnostics.Aggregate("invalid spell source references", issues)), nil
|
return rejection(
|
||||||
|
diagnostics.Aggregate("invalid spell source references", issues),
|
||||||
|
corrections.Guidance("Correct every rejected spell citation and return the complete replacement spell-cast list"),
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type chunkCoverage struct {
|
|
||||||
sourceID string
|
|
||||||
unitIDs map[int]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
|
||||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
|
||||||
for _, unit := range chunk.Units {
|
|
||||||
coverage.unitIDs[unit.ID] = struct{}{}
|
|
||||||
}
|
|
||||||
return coverage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
|
||||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
|
||||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
|
||||||
if !startOK || !endOK || start > end {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for position := start; position <= end; position++ {
|
|
||||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
}
|
}
|
||||||
@@ -110,6 +89,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
|||||||
return Options{}, nil
|
return Options{}, nil
|
||||||
}
|
}
|
||||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||||
func rejection(message string) contracts.ValidationResult {
|
func rejection(message, guidance string) contracts.ValidationResult {
|
||||||
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."}
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ func TestValidatorRejectsInvalidSourceRefs(t *testing.T) {
|
|||||||
if result.ReasonCode != ReasonCode {
|
if result.ReasonCode != ReasonCode {
|
||||||
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode)
|
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode)
|
||||||
}
|
}
|
||||||
|
for _, want := range []string{"Cure Wounds", "Aria", "source unit 99", "complete replacement"} {
|
||||||
|
if !strings.Contains(result.CorrectionGuidance, want) {
|
||||||
|
t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(result.CorrectionGuidance, "spell_casts[") || strings.Contains(result.CorrectionGuidance, ReasonCode) {
|
||||||
|
t.Fatalf("CorrectionGuidance exposed internal diagnostics: %q", result.CorrectionGuidance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorRejectsMissingSourceDocument(t *testing.T) {
|
func TestValidatorRejectsMissingSourceDocument(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user