Compare commits
4 Commits
d63e5c6852
...
7b077c269d
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b077c269d | |||
| 80ec939383 | |||
| d3c4d6f133 | |||
| 5ad661f95f |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
34
internal/framework/pipeline/normalize_retry.go
Normal file
34
internal/framework/pipeline/normalize_retry.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user