Complete minimal D&D extraction cutover
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0009: Prefer minimal evidence-grounded extraction artifacts
|
# ADR-0009: Prefer minimal evidence-grounded extraction artifacts
|
||||||
|
|
||||||
**Status:** Proposed
|
**Status:** Accepted
|
||||||
**Date:** 2026-07-22
|
**Date:** 2026-07-22
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ combines overlapping cited ranges once in document order and compares actors
|
|||||||
with the shared Unicode-aware NPC identity policy.
|
with the shared Unicode-aware NPC identity policy.
|
||||||
|
|
||||||
The production D&D registrar exposes the extractor and these validators. Its
|
The production D&D registrar exposes the extractor and these validators. Its
|
||||||
default extraction chain preserves this order: JSON syntax, private response
|
default extraction chain preserves this order: JSON syntax, combat shape,
|
||||||
schema, combat shape, source references, then source relatedness.
|
source references, private response schema, then source relatedness.
|
||||||
|
|
||||||
## Normalization boundary
|
## Normalization boundary
|
||||||
|
|
||||||
@@ -143,8 +143,8 @@ with `invalid_combat_turn_normalization` under policy
|
|||||||
`dnd.combat_turns.validator.normalized.v1`.
|
`dnd.combat_turns.validator.normalized.v1`.
|
||||||
|
|
||||||
The production D&D registrar exposes the normalizer and normalized-invariants
|
The production D&D registrar exposes the normalizer and normalized-invariants
|
||||||
validator. Its default normalization chain is JSON syntax, durable schema,
|
validator. Its default normalization chain is JSON syntax, combat shape,
|
||||||
combat shape, normalized invariants, source references, then source
|
normalized invariants, source references, durable schema, then source
|
||||||
relatedness. The lane uses the framework's typed append-order merger and has no
|
relatedness. The lane uses the framework's typed append-order merger and has no
|
||||||
merge validator chain.
|
merge validator chain.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
This document defines the durable D&D NPC-list artifact, its JSON codec, and
|
This document defines the durable D&D NPC-list artifact, its JSON codec, and
|
||||||
the selectable production NPC pipeline. The normalized JSON payload can be
|
the selectable production NPC pipeline. The normalized JSON payload can be
|
||||||
passed explicitly to the spell extractor as an optional caster-name registry
|
passed explicitly to the spell extractor as an optional caster-name registry
|
||||||
or to the combat extractor and normalizer as an actor/target registry. It
|
or to the combat extractor and normalizer as an actor registry. It
|
||||||
remains a reference, not spell or combat evidence.
|
remains a reference, not spell or combat evidence.
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
@@ -81,12 +81,13 @@ by source evidence. Groups, generic roles, invented labels, and descriptive or
|
|||||||
relationship enrichment are outside the contract.
|
relationship enrichment are outside the contract.
|
||||||
|
|
||||||
The default extraction chain is `generic/valid_json`,
|
The default extraction chain is `generic/valid_json`,
|
||||||
`generic/valid_json_schema`, `extract/dnd/npcs/shape`,
|
`extract/dnd/npcs/shape`, `extract/dnd/npcs/source_refs`,
|
||||||
`extract/dnd/npcs/source_refs`, and
|
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`. The
|
||||||
`extract/dnd/npcs/source_relatedness`. The normalize chain adds
|
default normalize chain is `generic/valid_json`, `extract/dnd/npcs/shape`,
|
||||||
`normalize/dnd/npcs/identity` before the source-reference and relatedness
|
`normalize/dnd/npcs/identity`, `extract/dnd/npcs/source_refs`,
|
||||||
checks. Relatedness emits bounded warnings when an NPC canonical name is not
|
`generic/valid_json_schema`, and `extract/dnd/npcs/source_relatedness`.
|
||||||
present near its cited transcript text; opaque campaign
|
Relatedness emits bounded warnings when an NPC canonical name is not present
|
||||||
|
near its cited transcript text; opaque campaign
|
||||||
references may explain such a warning but do not become evidence.
|
references may explain such a warning but do not become evidence.
|
||||||
|
|
||||||
## Manifest And Artifact Handoff
|
## Manifest And Artifact Handoff
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ chain, the catalog validator rejects the candidate with `unknown_spell`; the
|
|||||||
explicit validator override that accepts the candidate promotes the unresolved
|
explicit validator override that accepts the candidate promotes the unresolved
|
||||||
warning normally.
|
warning normally.
|
||||||
|
|
||||||
|
The default extraction and normalization chains both preserve this registered
|
||||||
|
order: JSON syntax, spell shape, catalog membership, source references, JSON
|
||||||
|
Schema, then source relatedness. Extraction validates the private response
|
||||||
|
schema; normalization validates the durable artifact schema.
|
||||||
|
|
||||||
## Manifest Metadata
|
## Manifest Metadata
|
||||||
|
|
||||||
The extractor adds prompt and response-schema provenance under the artifact lane
|
The extractor adds prompt and response-schema provenance under the artifact lane
|
||||||
|
|||||||
@@ -424,9 +424,9 @@ an unrelated actor. Actors use normalized consecutive-token matching. The
|
|||||||
normalized-invariants validator owns actor display normalization, canonical
|
normalized-invariants validator owns actor display normalization, canonical
|
||||||
source-reference order, chronology, and exact duplicate identity; it defers
|
source-reference order, chronology, and exact duplicate identity; it defers
|
||||||
shape and source-reference failures. All four validators are deterministic and
|
shape and source-reference failures. All four validators are deterministic and
|
||||||
expose local policy fingerprints. The D&D registrar orders them after generic
|
expose local policy fingerprints. In the registered defaults, JSON syntax runs
|
||||||
JSON validation and before response-schema validation at extraction and
|
first; combat shape, normalized invariants when applicable, and source-reference
|
||||||
normalization.
|
validation precede JSON Schema validation; warning-only relatedness runs last.
|
||||||
|
|
||||||
## Production Registration
|
## Production Registration
|
||||||
|
|
||||||
|
|||||||
@@ -7,19 +7,6 @@ not as committed release dates.
|
|||||||
|
|
||||||
## Near-Term D&D Pipeline
|
## Near-Term D&D Pipeline
|
||||||
|
|
||||||
### Simplify D&D Extraction Contracts
|
|
||||||
|
|
||||||
- Adopt the proposed principle in
|
|
||||||
[ADR-0009](../adr/0009-minimal-evidence-grounded-extraction-artifacts.md)
|
|
||||||
that extractors should return the smallest useful evidence-grounded facts
|
|
||||||
needed to answer one narrow question.
|
|
||||||
- Redesign the spell, NPC, and combat-turn durable contracts and prompts as
|
|
||||||
described in
|
|
||||||
[Minimal D&D Extraction Contracts](minimal-dnd-extraction-contracts.md).
|
|
||||||
- Replace the unpublished v1 schema shapes in place rather than adding optional
|
|
||||||
or synthetic compatibility fields, and evaluate the reduced outputs against
|
|
||||||
human-reviewed transcripts after cutover.
|
|
||||||
|
|
||||||
### Evaluate Spell Extraction And Normalization
|
### Evaluate Spell Extraction And Normalization
|
||||||
|
|
||||||
- Evaluate ordinary extraction retries and the completed normalization path
|
- Evaluate ordinary extraction retries and the completed normalization path
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Minimal D&D Extraction Contracts Implementation Plan
|
# Minimal D&D Extraction Contracts Implementation Plan
|
||||||
|
|
||||||
**Status:** Ready for implementation
|
**Status:** Implemented
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Minimal D&D Extraction Contracts
|
# Minimal D&D Extraction Contracts
|
||||||
|
|
||||||
**Status:** Proposed
|
**Status:** Implemented
|
||||||
|
|
||||||
## Intent
|
## Intent
|
||||||
|
|
||||||
@@ -213,8 +213,8 @@ and live model calls are evaluation aids rather than deterministic CI gates.
|
|||||||
## Documentation Ownership
|
## Documentation Ownership
|
||||||
|
|
||||||
The spell, NPC, and combat integration documents are the canonical owners of
|
The spell, NPC, and combat integration documents are the canonical owners of
|
||||||
their redesigned durable schemas once implemented. Internal LLM and module
|
their redesigned durable schemas. Internal LLM and module documents own the
|
||||||
documents own the corresponding current prompt, DTO, validator, normalizer, and
|
corresponding current prompt, DTO, validator, normalizer, and NPC
|
||||||
NPC prompt-projection behavior. This roadmap owns the desired end state and
|
prompt-projection behavior. This roadmap records the implemented design and
|
||||||
policy; [the implementation plan](implementation.md) owns sequencing and
|
policy; [the implementation plan](implementation.md) records the completed
|
||||||
completion criteria until the cutover is complete.
|
sequencing and completion criteria.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||||
"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"
|
||||||
|
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||||
)
|
)
|
||||||
@@ -47,6 +48,10 @@ func TestProductionNPCConfigurationResolvesTypedLane(t *testing.T) {
|
|||||||
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
|
if !ok || !reflect.DeepEqual(normalizeSpec.Requires, []string{"merged"}) || !reflect.DeepEqual(normalizeSpec.Provides, []string{"normalized"}) {
|
||||||
t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
|
t.Fatalf("NPC normalizer spec = %#v, want merged/normalized capabilities", normalizeSpec)
|
||||||
}
|
}
|
||||||
|
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCListKind)
|
||||||
|
if !ok || codecSpec.Kind != dnd.NPCListKind || codecSpec.Schema.ID != npccodec.SchemaID || codecSpec.Schema.Version != npccodec.SchemaVersion {
|
||||||
|
t.Fatalf("NPC codec spec = %#v, want typed v1 durable schema", codecSpec)
|
||||||
|
}
|
||||||
|
|
||||||
wantExtractChain := []pipeline.ModuleBinding{
|
wantExtractChain := []pipeline.ModuleBinding{
|
||||||
pipeline.Binding("generic/valid_json"),
|
pipeline.Binding("generic/valid_json"),
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ func TestCodecStrictlyRejectsMalformedOrUnknownJSON(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "unknown top-level", raw: `{"npcs":[],"unexpected":true}`, want: "unknown field"},
|
{name: "unknown top-level", raw: `{"npcs":[],"unexpected":true}`, want: "unknown field"},
|
||||||
{name: "unknown nested", raw: `{"npcs":[{"id":"x","name":"Mira","source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}],"unexpected":true}]}`, want: "unknown field"},
|
{name: "unknown nested", raw: `{"npcs":[{"id":"x","name":"Mira","source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}],"unexpected":true}]}`, want: "unknown field"},
|
||||||
{name: "removed enrichment", raw: `{"npcs":[{"id":"x","name":"Mira","aliases":[],"source_refs":[{"source_id":"s","start_unit_id":1,"end_unit_id":1}]}]}`, want: "unknown field"},
|
|
||||||
{name: "trailing", raw: `{"npcs":[]} {}`, want: "multiple JSON values"},
|
{name: "trailing", raw: `{"npcs":[]} {}`, want: "multiple JSON values"},
|
||||||
{name: "missing array", raw: `{}`, want: "npcs must be present"},
|
{name: "missing array", raw: `{}`, want: "npcs must be present"},
|
||||||
{name: "invalid reference", raw: `{"npcs":[{"id":"npc:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"Mira","source_refs":[{"source_id":"s","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"},
|
{name: "invalid reference", raw: `{"npcs":[{"id":"npc:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"Mira","source_refs":[{"source_id":"s","start_unit_id":0,"end_unit_id":1}]}]}`, want: "start_unit_id"},
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
For every NPC record, return only the observed display name and transcript
|
For every NPC record, return only the observed display name and transcript
|
||||||
units that support that identity.
|
units that support that identity.
|
||||||
|
|
||||||
Do not return descriptions, aliases, relationships, biographies, statistics,
|
Return no other details or lore inferred from general D&D knowledge. Do not
|
||||||
alignment, motivations, encounter summaries, or lore inferred from general
|
invent a label for an anonymous creature, crowd, or generic role.
|
||||||
D&D knowledge. Do not invent a label for an anonymous creature, crowd, or
|
|
||||||
generic role.
|
|
||||||
|
|
||||||
Preserve observed display spelling. Return at least one narrow source range for
|
Preserve observed display spelling. Return at least one narrow source range for
|
||||||
every record.
|
every record.
|
||||||
|
|||||||
@@ -98,10 +98,9 @@ func TestRegistryLookupAndAccessorsAreImmutable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveRejectsRichOrInvalidRegistryJSON(t *testing.T) {
|
func TestResolveRejectsMalformedOrUnsupportedRegistryInput(t *testing.T) {
|
||||||
rich := []byte(`{"npcs":[{"id":"npc:sha256:99a16589618a04f535a7d21fdcc71a0b1c05d22f752cd492065b1086d97bc3d7","name":"Mira Thorn","aliases":[],"source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":1}]}]}`)
|
|
||||||
for _, item := range []contracts.ReferenceItem{
|
for _, item := range []contracts.ReferenceItem{
|
||||||
{MediaType: "application/json", Content: rich},
|
{MediaType: "application/json", Content: []byte(`{"npcs":[`)},
|
||||||
{MediaType: "text/plain", Content: []byte(`{"npcs":[]}`)},
|
{MediaType: "text/plain", Content: []byte(`{"npcs":[]}`)},
|
||||||
} {
|
} {
|
||||||
_, err := Resolve(referenceSet(item))
|
_, err := Resolve(referenceSet(item))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||||
|
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||||
@@ -34,7 +36,25 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &groundedDNDLLMClient{}
|
client := &groundedDNDLLMClient{}
|
||||||
output, err := runPreparedPipeline(t, registries, materialized, client, pipeline.RunInput{
|
prepared, err := pipeline.Prepare(materialized, registries, pipeline.ModuleDependencies{LLM: client})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Prepare() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{
|
||||||
|
"extract:npcs:dnd/npcs:prompt",
|
||||||
|
"extract:npcs:dnd/npcs:response_schema",
|
||||||
|
"extract:spells:dnd/spells:prompt",
|
||||||
|
"extract:spells:dnd/spells:response_schema",
|
||||||
|
"extract:spells:dnd/spells:npc_registry",
|
||||||
|
"extract:combat:dnd/combat-turns:prompt",
|
||||||
|
"extract:combat:dnd/combat-turns:response_schema",
|
||||||
|
"extract:combat:dnd/combat-turns:npc_registry",
|
||||||
|
"normalize:combat:dnd/combat-turns:npc_registry",
|
||||||
|
} {
|
||||||
|
assertCombatFingerprint(t, prepared.CheckpointFingerprints(), name)
|
||||||
|
}
|
||||||
|
output, err := pipeline.New().Run(context.Background(), pipeline.RunInput{
|
||||||
|
Prepared: prepared,
|
||||||
RawInput: readNPCFixture(t),
|
RawInput: readNPCFixture(t),
|
||||||
ExtractWorkers: 1,
|
ExtractWorkers: 1,
|
||||||
})
|
})
|
||||||
@@ -44,6 +64,24 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
|||||||
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 3 {
|
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 3 {
|
||||||
t.Fatalf("run outputs = %#v rejected = %#v, want NPC, spell, and combat outputs", output.NormalizeOutputs, output.Rejected)
|
t.Fatalf("run outputs = %#v rejected = %#v, want NPC, spell, and combat outputs", output.NormalizeOutputs, output.Rejected)
|
||||||
}
|
}
|
||||||
|
wantSchemas := map[string]string{"npcs": npccodec.SchemaID, "spells": spellcodec.SchemaID, "combat": combatcodec.SchemaID}
|
||||||
|
for _, serialized := range output.NormalizeOutputs {
|
||||||
|
if serialized.Artifact.Schema.ID != wantSchemas[serialized.LaneID] || serialized.Artifact.Schema.Version != "v1" {
|
||||||
|
t.Fatalf("%s artifact schema = %#v, want minimal v1 identity", serialized.LaneID, serialized.Artifact.Schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wantExtractorIdentity := map[string]struct{ promptID, schemaID string }{
|
||||||
|
"npcs": {npcs.PromptID, npcs.ResponseSchemaID},
|
||||||
|
"spells": {spells.PromptID, spells.ResponseSchemaID},
|
||||||
|
"combat": {combatextract.PromptID, combatextract.ResponseSchemaID},
|
||||||
|
}
|
||||||
|
for _, lane := range output.Manifest.ArtifactLanes {
|
||||||
|
want, ok := wantExtractorIdentity[lane.ID]
|
||||||
|
metadata, metadataOK := lane.Metadata["extractor"].(map[string]any)
|
||||||
|
if !ok || !metadataOK || metadata["prompt_id"] != want.promptID || metadata["prompt_version"] != "v1" || metadata["response_schema_id"] != want.schemaID || metadata["response_schema_version"] != "v1" {
|
||||||
|
t.Fatalf("%s extractor metadata = %#v, want v1 prompt/schema identity", lane.ID, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var npcPayload []byte
|
var npcPayload []byte
|
||||||
for _, serialized := range output.NormalizeOutputs {
|
for _, serialized := range output.NormalizeOutputs {
|
||||||
@@ -94,6 +132,15 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
|||||||
if provenanceCount != 3 {
|
if provenanceCount != 3 {
|
||||||
t.Fatalf("NPC generated provenance count = %d, want spell extract plus combat extract/normalize", provenanceCount)
|
t.Fatalf("NPC generated provenance count = %d, want spell extract plus combat extract/normalize", provenanceCount)
|
||||||
}
|
}
|
||||||
|
manifestContent, err := json.Marshal(output.Manifest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{"Mira Thorn", "Hooded Guard", "For every NPC record", "Extract Dungeons & Dragons"} {
|
||||||
|
if strings.Contains(string(manifestContent), forbidden) {
|
||||||
|
t.Fatalf("manifest exposes prompt or reference payload content %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, lane := range output.Manifest.ArtifactLanes {
|
for _, lane := range output.Manifest.ArtifactLanes {
|
||||||
for _, component := range []string{"extractor", "normalizer"} {
|
for _, component := range []string{"extractor", "normalizer"} {
|
||||||
metadata, ok := lane.Metadata[component].(map[string]any)
|
metadata, ok := lane.Metadata[component].(map[string]any)
|
||||||
|
|||||||
Reference in New Issue
Block a user