Fix D&D extraction issues and retire the completed audit

This commit is contained in:
2026-07-25 12:30:42 +00:00
parent 84a2854b5e
commit e4471fc300
15 changed files with 295 additions and 1782 deletions

View File

@@ -8,31 +8,46 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
type orderedCombatTurnResponse struct {
value combatTurnResponse
earliest int
hasEvidence bool
}
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
if response == nil {
return
}
ordered := make([]orderedCombatTurnResponse, len(response.CombatTurns))
for index := range response.CombatTurns {
canonicalizeCombatTurn(&response.CombatTurns[index], order, sourceID)
}
sort.SliceStable(response.CombatTurns, func(i, j int) bool {
left, leftOK := order.EarliestValid(canonicalSourceRefs(response.CombatTurns[i].SourceRefs, sourceID))
right, rightOK := order.EarliestValid(canonicalSourceRefs(response.CombatTurns[j].SourceRefs, sourceID))
if leftOK != rightOK {
return leftOK
earliest, hasEvidence := canonicalizeCombatTurn(&response.CombatTurns[index], order, sourceID)
ordered[index] = orderedCombatTurnResponse{
value: response.CombatTurns[index],
earliest: earliest,
hasEvidence: hasEvidence,
}
if !leftOK {
}
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].hasEvidence != ordered[j].hasEvidence {
return ordered[i].hasEvidence
}
if !ordered[i].hasEvidence {
return false
}
return left < right
return ordered[i].earliest < ordered[j].earliest
})
for index := range ordered {
response.CombatTurns[index] = ordered[index].value
}
}
func canonicalizeCombatTurn(turn *combatTurnResponse, order shared.SourceRefOrder, sourceID string) {
func canonicalizeCombatTurn(turn *combatTurnResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if turn == nil {
return
return 0, false
}
turn.SourceRefs = combatResponseRefs(order.Canonicalize(canonicalSourceRefs(turn.SourceRefs, sourceID)))
refs := order.Canonicalize(canonicalSourceRefs(turn.SourceRefs, sourceID))
turn.SourceRefs = combatResponseRefs(refs)
return order.EarliestValid(refs)
}
func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.CombatTurnList {

View File

@@ -65,14 +65,15 @@ func TestScriptoriumPromptPreparesRequiredInputs(t *testing.T) {
for index, want := range []struct {
role string
cached bool
marker string
}{
{role: "system"},
{role: "user"},
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
{role: "user", marker: "Transcript units are the only evidence"},
{role: "user", cached: true, marker: "most specific supported in-world"},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user"},
{role: "user", cached: true},
{role: "user", marker: "combat-turn artifacts"},
{role: "user", cached: true, marker: "turn_kind"},
{role: "user"},
} {
if index >= len(prepared.Messages) {
@@ -89,6 +90,9 @@ func TestScriptoriumPromptPreparesRequiredInputs(t *testing.T) {
} else if message.CacheControl != nil {
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
}
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
}
}
if len(prepared.Messages) != 8 {
t.Fatalf("prepared prompt has %d messages, want 8", len(prepared.Messages))

View File

@@ -8,31 +8,46 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
type orderedInteractionResponse struct {
value interactionResponse
earliest int
hasEvidence bool
}
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
if response == nil {
return
}
ordered := make([]orderedInteractionResponse, len(response.Interactions))
for index := range response.Interactions {
canonicalizeInteraction(&response.Interactions[index], order, sourceID)
}
sort.SliceStable(response.Interactions, func(i, j int) bool {
left, leftOK := order.EarliestValid(canonicalSourceRefs(response.Interactions[i].SourceRefs, sourceID))
right, rightOK := order.EarliestValid(canonicalSourceRefs(response.Interactions[j].SourceRefs, sourceID))
if leftOK != rightOK {
return leftOK
earliest, hasEvidence := canonicalizeInteraction(&response.Interactions[index], order, sourceID)
ordered[index] = orderedInteractionResponse{
value: response.Interactions[index],
earliest: earliest,
hasEvidence: hasEvidence,
}
if !leftOK {
}
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].hasEvidence != ordered[j].hasEvidence {
return ordered[i].hasEvidence
}
if !ordered[i].hasEvidence {
return false
}
return left < right
return ordered[i].earliest < ordered[j].earliest
})
for index := range ordered {
response.Interactions[index] = ordered[index].value
}
}
func canonicalizeInteraction(interaction *interactionResponse, order shared.SourceRefOrder, sourceID string) {
func canonicalizeInteraction(interaction *interactionResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if interaction == nil {
return
return 0, false
}
interaction.SourceRefs = interactionResponseRefs(order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID)))
refs := order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID))
interaction.SourceRefs = interactionResponseRefs(refs)
return order.EarliestValid(refs)
}
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList {

View File

@@ -54,14 +54,15 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
for index, want := range []struct {
role string
cached bool
marker string
}{
{role: "system"},
{role: "user"},
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
{role: "user", marker: "Transcript units are the only evidence"},
{role: "user", cached: true, marker: "most specific supported in-world"},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user"},
{role: "user", cached: true},
{role: "user", marker: "interaction occurrences"},
{role: "user", cached: true, marker: "Use exactly one kind per occurrence"},
{role: "user"},
} {
if index >= len(prepared.Messages) {
@@ -78,6 +79,9 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
} else if message.CacheControl != nil {
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
}
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
}
}
if len(prepared.Messages) != 8 {
t.Fatalf("prepared prompt has %d messages, want 8", len(prepared.Messages))

View File

@@ -9,31 +9,46 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
type orderedNPCResponse struct {
value npcResponse
earliest int
hasEvidence bool
}
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
if response == nil {
return
}
ordered := make([]orderedNPCResponse, len(response.NPCs))
for index := range response.NPCs {
canonicalizeNPC(&response.NPCs[index], order, sourceID)
}
sort.SliceStable(response.NPCs, func(i, j int) bool {
left, leftOK := order.EarliestValid(canonicalSourceRefs(response.NPCs[i].SourceRefs, sourceID))
right, rightOK := order.EarliestValid(canonicalSourceRefs(response.NPCs[j].SourceRefs, sourceID))
if leftOK != rightOK {
return leftOK
earliest, hasEvidence := canonicalizeNPC(&response.NPCs[index], order, sourceID)
ordered[index] = orderedNPCResponse{
value: response.NPCs[index],
earliest: earliest,
hasEvidence: hasEvidence,
}
if !leftOK {
}
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].hasEvidence != ordered[j].hasEvidence {
return ordered[i].hasEvidence
}
if !ordered[i].hasEvidence {
return false
}
return left < right
return ordered[i].earliest < ordered[j].earliest
})
for index := range ordered {
response.NPCs[index] = ordered[index].value
}
}
func canonicalizeNPC(npc *npcResponse, order shared.SourceRefOrder, sourceID string) {
func canonicalizeNPC(npc *npcResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if npc == nil {
return
return 0, false
}
npc.SourceRefs = npcResponseRefs(order.Canonicalize(canonicalSourceRefs(npc.SourceRefs, sourceID)))
refs := order.Canonicalize(canonicalSourceRefs(npc.SourceRefs, sourceID))
npc.SourceRefs = npcResponseRefs(refs)
return order.EarliestValid(refs)
}
func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList {

View File

@@ -45,13 +45,14 @@ func TestRegisterPromptAssetsAndPrepareNPCPrompt(t *testing.T) {
for index, want := range []struct {
role string
cached bool
marker string
}{
{role: "system"},
{role: "user"},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user"},
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
{role: "user", marker: "Transcript units are the only evidence"},
{role: "user", cached: true, marker: "most specific supported in-world"},
{role: "user", cached: true},
{role: "user", marker: "individually identifiable"},
{role: "user", cached: true, marker: "observed display name"},
{role: "user"},
} {
if index >= len(prepared.Messages) {
@@ -68,6 +69,9 @@ func TestRegisterPromptAssetsAndPrepareNPCPrompt(t *testing.T) {
} else if message.CacheControl != nil {
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
}
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
}
}
if len(prepared.Messages) != 7 {
t.Fatalf("prepared prompt has %d messages, want 7", len(prepared.Messages))

View File

@@ -8,31 +8,46 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
type orderedSpellResponse struct {
value spellCastResponse
earliest int
hasEvidence bool
}
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
if response == nil {
return
}
ordered := make([]orderedSpellResponse, len(response.SpellCasts))
for index := range response.SpellCasts {
canonicalizeSpellCast(&response.SpellCasts[index], order, sourceID)
}
sort.SliceStable(response.SpellCasts, func(i, j int) bool {
left, leftOK := order.EarliestValid(spellSourceRefs(response.SpellCasts[i].SourceRefs, sourceID))
right, rightOK := order.EarliestValid(spellSourceRefs(response.SpellCasts[j].SourceRefs, sourceID))
if leftOK != rightOK {
return leftOK
earliest, hasEvidence := canonicalizeSpellCast(&response.SpellCasts[index], order, sourceID)
ordered[index] = orderedSpellResponse{
value: response.SpellCasts[index],
earliest: earliest,
hasEvidence: hasEvidence,
}
if !leftOK {
}
sort.SliceStable(ordered, func(i, j int) bool {
if ordered[i].hasEvidence != ordered[j].hasEvidence {
return ordered[i].hasEvidence
}
if !ordered[i].hasEvidence {
return false
}
return left < right
return ordered[i].earliest < ordered[j].earliest
})
for index := range ordered {
response.SpellCasts[index] = ordered[index].value
}
}
func canonicalizeSpellCast(spell *spellCastResponse, order shared.SourceRefOrder, sourceID string) {
func canonicalizeSpellCast(spell *spellCastResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if spell == nil {
return
return 0, false
}
spell.SourceRefs = spellResponseRefs(order.Canonicalize(spellSourceRefs(spell.SourceRefs, sourceID)))
refs := order.Canonicalize(spellSourceRefs(spell.SourceRefs, sourceID))
spell.SourceRefs = spellResponseRefs(refs)
return order.EarliestValid(refs)
}
func spellSourceRefs(refs []spellSourceRefResponse, sourceID string) []source.SourceRef {

View File

@@ -24,15 +24,16 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
for index, want := range []struct {
role string
cached bool
marker string
}{
{role: "system"},
{role: "user"},
{role: "user", cached: true},
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
{role: "user", marker: "Transcript units are the only evidence"},
{role: "user", cached: true, marker: "most specific supported in-world"},
{role: "user", cached: true},
{role: "user", cached: true},
{role: "user"},
{role: "user"},
{role: "user", cached: true},
{role: "user", marker: "spell-cast artifacts"},
{role: "user", cached: true, marker: "source references must collectively support"},
{role: "user"},
} {
if index >= len(prepared.Messages) {
@@ -49,6 +50,9 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
} else if message.CacheControl != nil {
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
}
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
}
}
if len(prepared.Messages) != 9 {
t.Fatalf("prepared prompt has %d messages, want 9", len(prepared.Messages))

View File

@@ -17,7 +17,7 @@ import (
const (
Key = "dnd/spells"
normalizationPolicy = "dnd.spells.normalize.v1"
normalizationPolicy = "dnd.spells.normalize.v2"
NormalizationPolicy = normalizationPolicy
)
@@ -157,20 +157,15 @@ func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast {
}
func canonicalizeSourceRefs(order shared.SourceRefOrder, input []source.SourceRef) ([]source.SourceRef, bool, int) {
canonical := order.Canonicalize(input)
return canonical, !sourceRefsEqual(input, canonical), len(input) - len(canonical)
}
func sourceRefsEqual(left, right []source.SourceRef) bool {
if (left == nil) != (right == nil) || len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
orderChanged := false
for index := 1; index < len(input); index++ {
if order.Less(input[index], input[index-1]) {
orderChanged = true
break
}
}
return true
canonical := order.Canonicalize(input)
return canonical, orderChanged, len(input) - len(canonical)
}
type duplicateGroup struct {

View File

@@ -104,6 +104,9 @@ func TestIdentityAndMetadataAreDefensive(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if NormalizationPolicy != "dnd.spells.normalize.v2" {
t.Fatalf("NormalizationPolicy = %q, want v2 policy", NormalizationPolicy)
}
fingerprints := normalizer.CheckpointFingerprints()
if len(fingerprints) != 2 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() || fingerprints[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
@@ -116,7 +119,7 @@ func TestIdentityAndMetadataAreDefensive(t *testing.T) {
}
metadata := normalizer.ManifestMetadata()
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() {
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() || metadata["normalization_policy"] != normalizationPolicy {
t.Fatalf("metadata = %#v, want catalog identity", metadata)
}
metadata["catalog_overlay_ids"].([]string)[0] = "changed"
@@ -205,11 +208,32 @@ func TestNormalizeSortsAndDeduplicatesExactSourceReferences(t *testing.T) {
if !reflect.DeepEqual(result.Value.SpellCasts[0].SourceRefs, wantRefs) {
t.Fatalf("source refs = %#v, want %#v", result.Value.SpellCasts[0].SourceRefs, wantRefs)
}
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized || !strings.Contains(result.Warnings[0].Message, "original count 6") || !strings.Contains(result.Warnings[0].Message, "final count 5") || !strings.Contains(result.Warnings[0].Message, "duplicates removed 1") {
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized || !strings.Contains(result.Warnings[0].Message, "original count 6") || !strings.Contains(result.Warnings[0].Message, "final count 5") || !strings.Contains(result.Warnings[0].Message, "order changed true") || !strings.Contains(result.Warnings[0].Message, "duplicates removed 1") {
t.Fatalf("warnings = %#v, want source normalization warning", result.Warnings)
}
}
func TestNormalizeReportsDuplicateRemovalWithoutOrderChange(t *testing.T) {
ref1 := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}
ref2 := source.SourceRef{SourceID: "source", StartUnitID: 2, EndUnitID: 2}
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
Spell: "Cure Wounds",
SourceRefs: []source.SourceRef{ref1, ref1, ref2},
}}}
result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(input))
if err != nil {
t.Fatalf("Normalize() error = %v, want nil", err)
}
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized {
t.Fatalf("warnings = %#v, want one source normalization warning", result.Warnings)
}
message := result.Warnings[0].Message
if !strings.Contains(message, "order changed false") || !strings.Contains(message, "duplicates removed 1") {
t.Fatalf("warning = %q, want duplicate-only repair without order change", message)
}
}
func TestNormalizeOrdersReferencesBySourceDocumentPosition(t *testing.T) {
doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{

View File

@@ -2,7 +2,6 @@ package shared
import (
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
)
@@ -10,8 +9,8 @@ import (
// SourceRefOrder provides a stable snapshot of a source document's unit
// ordering for source-reference comparison and canonicalization.
type SourceRefOrder struct {
sourceID string
positions map[int]int
sourceID string
index source.DocumentIndex
}
// NewSourceRefOrder captures the source identity and unit positions from doc.
@@ -19,13 +18,7 @@ func NewSourceRefOrder(doc *source.SourceDocument) SourceRefOrder {
if doc == nil {
return SourceRefOrder{}
}
positions := make(map[int]int, len(doc.Units))
for position, unit := range doc.Units {
if _, exists := positions[unit.ID]; !exists {
positions[unit.ID] = position
}
}
return SourceRefOrder{sourceID: doc.ID, positions: positions}
return SourceRefOrder{sourceID: doc.ID, index: source.NewDocumentIndex(doc)}
}
// Less orders references by source identity, then document positions when
@@ -34,14 +27,13 @@ func (o SourceRefOrder) Less(left, right source.SourceRef) bool {
if left.SourceID != right.SourceID {
return left.SourceID < right.SourceID
}
positions := o.positionsFor(left.SourceID)
if lessEndpoint(positions, left.StartUnitID, right.StartUnitID) {
if o.lessEndpoint(left.SourceID, left.StartUnitID, right.StartUnitID) {
return true
}
if lessEndpoint(positions, right.StartUnitID, left.StartUnitID) {
if o.lessEndpoint(left.SourceID, right.StartUnitID, left.StartUnitID) {
return false
}
return lessEndpoint(positions, left.EndUnitID, right.EndUnitID)
return o.lessEndpoint(left.SourceID, left.EndUnitID, right.EndUnitID)
}
// EarliestValid returns the earliest document position among valid refs.
@@ -52,14 +44,10 @@ func (o SourceRefOrder) EarliestValid(refs []source.SourceRef) (int, bool) {
found := false
earliest := 0
for _, ref := range refs {
if ref.SourceID != o.sourceID || strings.TrimSpace(ref.SourceID) != ref.SourceID || ref.StartUnitID <= 0 || ref.EndUnitID <= 0 {
continue
}
start, startOK := o.positions[ref.StartUnitID]
end, endOK := o.positions[ref.EndUnitID]
if !startOK || !endOK || start > end {
if o.index.ValidateRef(ref) != nil {
continue
}
start, _ := o.index.Position(ref.StartUnitID)
if !found || start < earliest {
earliest = start
found = true
@@ -87,16 +75,9 @@ func (o SourceRefOrder) Canonicalize(refs []source.SourceRef) []source.SourceRef
return unique
}
func (o SourceRefOrder) positionsFor(sourceID string) map[int]int {
if o.sourceID == "" || sourceID != o.sourceID {
return nil
}
return o.positions
}
func lessEndpoint(positions map[int]int, left, right int) bool {
leftPosition, leftOK := positions[left]
rightPosition, rightOK := positions[right]
func (o SourceRefOrder) lessEndpoint(sourceID string, left, right int) bool {
leftPosition, leftOK := o.position(sourceID, left)
rightPosition, rightOK := o.position(sourceID, right)
if leftOK != rightOK {
return leftOK
}
@@ -105,3 +86,10 @@ func lessEndpoint(positions map[int]int, left, right int) bool {
}
return left < right
}
func (o SourceRefOrder) position(sourceID string, unitID int) (int, bool) {
if o.sourceID == "" || sourceID != o.sourceID {
return 0, false
}
return o.index.Position(unitID)
}