Compare commits

...

4 Commits

12 changed files with 234 additions and 47 deletions

View File

@@ -157,18 +157,18 @@ messages remain canonical shared assets rather than copied package text.
### D&D NPC Normalization Prompt Ordering And Cache Boundaries ### D&D NPC Normalization Prompt Ordering And Cache Boundaries
NPC normalization has a distinct prompt and response-schema identity from NPC NPC normalization has a distinct prompt and response-schema identity from NPC
extraction. Its stable message tiers are the common D&D system and identity extraction. Its stable message tiers are the common D&D system asset, followed
assets, followed by package-owned task and normalization instructions. Cache by package-owned task and normalization instructions. Cache boundaries follow
boundaries follow the shared identity tier and the package instructions. The the shared system tier and the package instructions. The variable tail contains
variable tail contains the private candidate-name-and-range input and a the private candidate-name-and-range input and a windowed transcript input
windowed transcript input whose cited units provide local context; neither has whose cited units provide local context; neither has a cache boundary because
a cache boundary because it changes with the document. it changes with the document.
This prompt intentionally omits extraction-evidence and campaign-reference This prompt intentionally omits shared identity guidance,
assets: it reconciles existing records rather than extracting events or adding extraction-evidence, and campaign-reference assets: it reconciles existing
evidence. Its package-owned manifest and schema identity are fingerprinted records rather than extracting events or adding evidence. Its package-owned
separately, so a normalization prompt or schema change cannot reuse a prior manifest and schema identity are fingerprinted separately, so a normalization
normalization checkpoint. prompt or schema change cannot reuse a prior normalization checkpoint.
Shared wording belongs in the canonical assets under Shared wording belongs in the canonical assets under
`internal/modules/dnd/shared`; extraction packages reference those assets in `internal/modules/dnd/shared`; extraction packages reference those assets in

View File

@@ -325,12 +325,24 @@ processing immediately.
Structured-completion adapters classify malformed or undecodable provider Structured-completion adapters classify malformed or undecodable provider
output with the provider-neutral `contracts.ErrInvalidStructuredOutput` error. output with the provider-neutral `contracts.ErrInvalidStructuredOutput` error.
A typed normalizer may turn that condition, or another unsafe proposal, into a A typed normalizer may turn that condition, or another unsafe proposal, into a
normalize retry directive with a deterministic safe candidate, stable normalize retry directive with a module-supplied safe candidate, stable
diagnostic, and fallback warnings. The directive consumes the same configured diagnostic, and fallback warnings. The directive consumes the same configured
normalize retry budget: `retries` permits that many additional attempts after normalize retry budget: `retries` permits that many additional attempts after
the initial attempt. It neither creates a normalizer-local retry loop nor the initial attempt. It neither creates a normalizer-local retry loop nor
records an accepted checkpoint for the discarded attempt. records an accepted checkpoint for the discarded attempt.
Before adding a normalize retry directive to attempt debug data, the runner
requires a nonblank, valid UTF-8 reason code of at most 128 bytes and a
nonblank, valid UTF-8 message of at most 4,096 bytes. These are encoded-byte
limits. The framework rejects an invalid directive without truncating or
rewriting either field. It validates only this mechanical contract; normalizers
remain responsible for ensuring their otherwise valid diagnostics do not expose
source material, credentials, paths, names, or other sensitive content.
The framework treats a module-supplied candidate as opaque. The normalizer owns
its safety determination, and the configured normalizer validator chain remains
the acceptance boundary for the final fallback.
When a later normalize attempt succeeds, its candidate alone proceeds through When a later normalize attempt succeeds, its candidate alone proceeds through
the usual validation and checkpoint path. When the final attempt still returns the usual validation and checkpoint path. When the final attempt still returns
a directive, the runner validates its supplied safe fallback through that same a directive, the runner validates its supplied safe fallback through that same

View File

@@ -93,9 +93,9 @@ generated-reference barrier, `dnd/npcs` performs one document-level semantic
normalization call for each configured normalize attempt when eligible distinct normalization call for each configured normalize attempt when eligible distinct
names remain. The normalize binding's `retries` setting controls additional names remain. The normalize binding's `retries` setting controls additional
attempts. If an invalid or unsafe identity proposal exhausts that budget, the attempts. If an invalid or unsafe identity proposal exhausts that budget, the
run safely accepts the deterministic and any independently safe partial run safely accepts the deterministic base result and any independently
consolidation, with a bounded warning; ordinary validation still applies before validated, model-proposed partial consolidation, with a bounded warning;
the artifact can cross the barrier. ordinary validation still applies before the artifact can cross the barrier.
An accepted normalized NPC checkpoint can be reused on `--resume` just like An accepted normalized NPC checkpoint can be reused on `--resume` just like
other accepted normalize work. A changed normalization prompt, response schema, other accepted normalize work. A changed normalization prompt, response schema,

View File

@@ -95,6 +95,11 @@ type TypedNormalizeResult[T any] struct {
// NormalizeRetry asks the framework to retry normalization while retaining a // NormalizeRetry asks the framework to retry normalization while retaining a
// safe candidate for acceptance if the retry budget is exhausted. // safe candidate for acceptance if the retry budget is exhausted.
const (
MaxNormalizeRetryReasonCodeBytes = 128
MaxNormalizeRetryMessageBytes = 4096
)
type NormalizeRetry struct { type NormalizeRetry struct {
ReasonCode string ReasonCode string
Message string Message string

View File

@@ -0,0 +1,34 @@
package pipeline
import (
"errors"
"strings"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func validateNormalizeRetry(retry *contracts.NormalizeRetry) error {
if retry == nil {
return errors.New("normalize retry directive is missing")
}
if !utf8.ValidString(retry.ReasonCode) {
return errors.New("normalize retry directive reason code has invalid UTF-8")
}
if strings.TrimSpace(retry.ReasonCode) == "" {
return errors.New("normalize retry directive reason code is blank")
}
if len(retry.ReasonCode) > contracts.MaxNormalizeRetryReasonCodeBytes {
return errors.New("normalize retry directive reason code exceeds maximum length")
}
if !utf8.ValidString(retry.Message) {
return errors.New("normalize retry directive message has invalid UTF-8")
}
if strings.TrimSpace(retry.Message) == "" {
return errors.New("normalize retry directive message is blank")
}
if len(retry.Message) > contracts.MaxNormalizeRetryMessageBytes {
return errors.New("normalize retry directive message exceeds maximum length")
}
return nil
}

View File

@@ -150,14 +150,112 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) {
} }
} }
func TestRunnerRejectsBlankNormalizeRetryDiagnostic(t *testing.T) { func TestRunnerValidatesNormalizeRetryDiagnostics(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t) const (
prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) { reasonSentinel = "reason-diagnostic-sentinel"
return erasedTypedResult{Value: codecNotes{Items: []string{"safe"}}, Retry: &contracts.NormalizeRetry{ReasonCode: " ", Message: "missing reason"}}, nil messageSentinel = "message-diagnostic-sentinel"
)
reasonOverLimit := strings.Repeat("r", contracts.MaxNormalizeRetryReasonCodeBytes-len(reasonSentinel)) + reasonSentinel + "x"
messageOverLimit := strings.Repeat("m", contracts.MaxNormalizeRetryMessageBytes-len(messageSentinel)) + messageSentinel + "x"
tests := []struct {
name string
retry contracts.NormalizeRetry
wantError string
hiddenValues []string
}{
{
name: "accepts byte limits",
retry: contracts.NormalizeRetry{
ReasonCode: strings.Repeat("r", contracts.MaxNormalizeRetryReasonCodeBytes),
Message: strings.Repeat("m", contracts.MaxNormalizeRetryMessageBytes),
},
},
{
name: "rejects oversized reason code",
retry: contracts.NormalizeRetry{
ReasonCode: reasonOverLimit,
Message: messageSentinel,
},
wantError: "reason code exceeds maximum length",
hiddenValues: []string{reasonSentinel, messageSentinel},
},
{
name: "rejects oversized message",
retry: contracts.NormalizeRetry{
ReasonCode: reasonSentinel,
Message: messageOverLimit,
},
wantError: "message exceeds maximum length",
hiddenValues: []string{reasonSentinel, messageSentinel},
},
{
name: "rejects invalid reason code UTF-8",
retry: contracts.NormalizeRetry{
ReasonCode: reasonSentinel + string([]byte{0xff}),
Message: messageSentinel,
},
wantError: "reason code has invalid UTF-8",
hiddenValues: []string{reasonSentinel, messageSentinel},
},
{
name: "rejects invalid message UTF-8",
retry: contracts.NormalizeRetry{
ReasonCode: reasonSentinel,
Message: messageSentinel + string([]byte{0xff}),
},
wantError: "message has invalid UTF-8",
hiddenValues: []string{reasonSentinel, messageSentinel},
},
{
name: "rejects blank reason code",
retry: contracts.NormalizeRetry{
ReasonCode: " \t\n ",
Message: messageSentinel,
},
wantError: "reason code is blank",
hiddenValues: []string{messageSentinel},
},
{
name: "rejects blank message",
retry: contracts.NormalizeRetry{
ReasonCode: reasonSentinel,
Message: " \t\n ",
},
wantError: "message is blank",
hiddenValues: []string{reasonSentinel},
},
} }
_, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: newCapturedDebugRecorder()})
if err == nil || !strings.Contains(err.Error(), "blank reason code or message") { for _, tc := range tests {
t.Fatalf("Run() error = %v, want retry diagnostic contract error", err) t.Run(tc.name, func(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
prepared.Steps[0].lanes[0].typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: codecNotes{Items: []string{"safe"}}, Retry: &tc.retry}, nil
}
debug := newCapturedDebugRecorder()
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug})
if tc.wantError == "" {
if err != nil {
t.Fatalf("Run() error = %v, want accepted retry fallback", err)
}
if len(output.NormalizeOutputs) != 1 {
t.Fatalf("normalize outputs = %#v, want one accepted fallback", output.NormalizeOutputs)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("Run() error = %v, want fixed %q error", err, tc.wantError)
}
var debugOutput strings.Builder
for _, name := range debug.names() {
debugOutput.Write(debug.json[name])
}
for _, value := range tc.hiddenValues {
if strings.Contains(err.Error(), value) || strings.Contains(debugOutput.String(), value) {
t.Fatalf("retry diagnostic leaked %q", value)
}
}
})
} }
} }

View File

@@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"path" "path"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
@@ -365,8 +364,8 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi
} }
var retryPayload map[string]any var retryPayload map[string]any
if result.Retry != nil { if result.Retry != nil {
if strings.TrimSpace(result.Retry.ReasonCode) == "" || strings.TrimSpace(result.Retry.Message) == "" { if err := validateNormalizeRetry(result.Retry); err != nil {
return false, nil, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned retry directive with blank reason code or message", lane.ID)) return false, nil, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err))
} }
retryRemaining := attempt <= lane.Normalize.Retries retryRemaining := attempt <= lane.Normalize.Retries
retryPayload = map[string]any{ retryPayload = map[string]any{

View File

@@ -11,8 +11,6 @@ inputs:
messages: messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user
content_file: ./sharedassets/common-dnd-identity.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user

View File

@@ -119,15 +119,15 @@ func buildNormalizeContextMaterials(doc *source.SourceDocument, records []dnd.NP
windows, err := normalizeContextWindows(doc.Units, coalesceIntervals(intervals), cited) windows, err := normalizeContextWindows(doc.Units, coalesceIntervals(intervals), cited)
if err != nil { if err != nil {
return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: copy source metadata: %w", err) return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: invalid source metadata")
} }
candidateContent, err := json.Marshal(normalizeCandidateInput{NPCs: candidates}) candidateContent, err := json.Marshal(normalizeCandidateInput{NPCs: candidates})
if err != nil { if err != nil {
return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: encode candidates: %w", err) return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: invalid candidate material")
} }
transcriptContent, err := json.Marshal(normalizeTranscriptInput{Windows: windows}) transcriptContent, err := json.Marshal(normalizeTranscriptInput{Windows: windows})
if err != nil { if err != nil {
return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: encode transcript: %w", err) return normalizeContextMaterials{}, false, fmt.Errorf("build NPC normalization context: invalid transcript material")
} }
return normalizeContextMaterials{ return normalizeContextMaterials{
Candidates: newNormalizeInputMaterial("candidates", candidateContent), Candidates: newNormalizeInputMaterial("candidates", candidateContent),

View File

@@ -21,7 +21,6 @@ var promptAssetManifest = shared.PromptAssetManifest{
}, },
SharedFiles: []string{ SharedFiles: []string{
"common-dnd-system.md", "common-dnd-system.md",
"common-dnd-identity.md",
"common-dnd-transcript.md", "common-dnd-transcript.md",
}, },
} }

View File

@@ -2,6 +2,7 @@ package npcs
import ( import (
"context" "context"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -11,6 +12,12 @@ import (
) )
func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) { func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
if want := []string{"common-dnd-system.md", "common-dnd-transcript.md"}; !reflect.DeepEqual(promptAssetManifest.SharedFiles, want) {
t.Fatalf("shared prompt assets = %#v, want %#v", promptAssetManifest.SharedFiles, want)
}
if promptHash, err := scriptoriumPromptMetadata(); err != nil || promptHash == "" {
t.Fatalf("scriptoriumPromptMetadata() = %q, %v; want prompt fingerprint", promptHash, err)
}
registry := llm.NewAssetRegistry() registry := llm.NewAssetRegistry()
if err := RegisterPromptAssets(registry); err != nil { if err := RegisterPromptAssets(registry); err != nil {
t.Fatalf("RegisterPromptAssets() error = %v", err) t.Fatalf("RegisterPromptAssets() error = %v", err)
@@ -39,23 +46,36 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_normalize_llm.v1.json" { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_normalize_llm.v1.json" {
t.Fatalf("prepared prompt = %#v, want normalization prompt identity and schema", prepared) t.Fatalf("prepared prompt = %#v, want normalization prompt identity and schema", prepared)
} }
if len(prepared.Messages) != 6 { if len(prepared.Messages) != 5 {
t.Fatalf("prepared messages = %d, want 6", len(prepared.Messages)) t.Fatalf("prepared messages = %d, want 5", len(prepared.Messages))
} }
for _, index := range []int{1, 3} { for index, role := range []string{"system", "user", "user", "user", "user"} {
if prepared.Messages[index].Role != role {
t.Errorf("message %d role = %q, want %q", index, prepared.Messages[index].Role, role)
}
}
for _, index := range []int{0, 2} {
if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != scriptorium.CacheControlEphemeral { if cache := prepared.Messages[index].CacheControl; cache == nil || cache.Type != scriptorium.CacheControlEphemeral {
t.Errorf("message %d cache control = %#v, want ephemeral", index, cache) t.Errorf("message %d cache control = %#v, want ephemeral", index, cache)
} }
} }
for _, index := range []int{0, 2, 4, 5} { for _, index := range []int{1, 3, 4} {
if cache := prepared.Messages[index].CacheControl; cache != nil { if cache := prepared.Messages[index].CacheControl; cache != nil {
t.Errorf("message %d cache control = %#v, want nil", index, cache) t.Errorf("message %d cache control = %#v, want nil", index, cache)
} }
} }
if !strings.Contains(prepared.Messages[4].Content, `"Mira"`) || strings.Contains(prepared.Messages[4].Content, `"windows"`) { if !strings.Contains(prepared.Messages[3].Content, `"Mira"`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
t.Fatalf("candidate message = %q, want only rendered candidates", prepared.Messages[4].Content) t.Fatalf("candidate message = %q, want only rendered candidates", prepared.Messages[3].Content)
} }
if !strings.Contains(prepared.Messages[5].Content, `"windows"`) || strings.Contains(prepared.Messages[5].Content, `"Mira"`) { if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"Mira"`) {
t.Fatalf("transcript message = %q, want only rendered transcript", prepared.Messages[5].Content) t.Fatalf("transcript message = %q, want only rendered transcript", prepared.Messages[4].Content)
}
for index, message := range prepared.Messages {
if index != 3 && strings.Contains(message.Content, `"Mira"`) {
t.Errorf("message %d unexpectedly rendered candidate input", index)
}
if index != 4 && strings.Contains(message.Content, `"windows"`) {
t.Errorf("message %d unexpectedly rendered transcript input", index)
}
} }
} }

View File

@@ -136,17 +136,39 @@ func TestNormalizeInvalidStructuredOutputAndOperationalErrorsRemainDistinct(t *t
} }
} }
func TestNormalizeRejectsContextEncodingFailuresWithoutLeakingContent(t *testing.T) { func TestNormalizeRedactsContextMaterialFailures(t *testing.T) {
client := &recordingNPCNormalizerClient{} client := &recordingNPCNormalizerClient{}
normalizer := newNormalizer(t, client) normalizer := newNormalizer(t, client)
doc := semanticDocument() const (
doc.Units[0].Metadata = map[string]any{"invalid": math.NaN()} metadataKey = "normalizer-sensitive-metadata-key"
input := dnd.NPCList{NPCs: []dnd.NPC{ metadataValue = "normalizer-sensitive-metadata-value"
{Name: "Mira", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, sourceID = "normalizer-sensitive-source-id"
{Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, originPath = "file:///normalizer-sensitive-origin.json"
transcript = "normalizer-sensitive-transcript"
firstName = "Normalizer Sensitive"
secondName = "Normalizer Sensitive Alias"
)
doc := &source.SourceDocument{ID: sourceID, Units: []source.SourceUnit{
{ID: 10, Kind: "speech", Text: transcript, Metadata: map[string]any{metadataKey: math.NaN(), "value": metadataValue}},
{ID: 20, Kind: "speech", Text: "other context"},
}} }}
if _, err := normalizer.Normalize(context.Background(), normalizeRequestWithSource(input, doc)); err == nil || !strings.Contains(err.Error(), "build semantic context") || strings.Contains(err.Error(), doc.Units[0].Text) || len(client.requests) != 0 { input := dnd.NPCList{NPCs: []dnd.NPC{
t.Fatalf("Normalize() error = %v, calls = %d; want safe preparation error before completion", err, len(client.requests)) {Name: firstName, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: secondName, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
}}
request := normalizeRequestWithSource(input, doc)
request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(transcript), "sha256:test", originPath)
_, err := normalizer.Normalize(context.Background(), request)
if err == nil || !strings.Contains(err.Error(), "build NPC normalization context: invalid source metadata") {
t.Fatalf("Normalize() error = %v; want content-safe context-material failure", err)
}
for _, forbidden := range []string{metadataKey, metadataValue, transcript, firstName, secondName, sourceID, originPath, "float64", "non-finite"} {
if strings.Contains(err.Error(), forbidden) {
t.Fatalf("Normalize() error leaked %q: %v", forbidden, err)
}
}
if len(client.requests) != 0 {
t.Fatalf("completion calls = %d, want context failure before completion", len(client.requests))
} }
} }