Make debug evidence opt-in and retain all candidates
This commit is contained in:
@@ -709,6 +709,17 @@ reported at finalization without erasing a completed transcript.
|
|||||||
|
|
||||||
## Stage 11 — Make Debug Persistence Explicit and Independent
|
## Stage 11 — Make Debug Persistence Explicit and Independent
|
||||||
|
|
||||||
|
**Completed 2026-09-24.** Debug directory creation and all current debug writer
|
||||||
|
paths are gated by `diagnostics.debug`; failures are reported safely to stderr
|
||||||
|
without aborting processing. The retained directory uses the supplied run ID,
|
||||||
|
old retention modes are gone, raw and normalized snapshots are redacted, and the
|
||||||
|
candidate ledger includes proposals not applied after filtering or interruption.
|
||||||
|
Tests cover explicit candidate capture, retained debug evidence, secret redaction,
|
||||||
|
and writer behavior. `go test ./internal/core/redaction
|
||||||
|
./internal/core/diagnostics ./internal/framework/runner
|
||||||
|
./internal/framework/processreport` and `git diff --check` passed. CLI package
|
||||||
|
compilation is deferred to Stage 12's replacement of the old report lifecycle.
|
||||||
|
|
||||||
**Work:** Refactor `core/diagnostics` run-directory creation and all CLI/module/
|
**Work:** Refactor `core/diagnostics` run-directory creation and all CLI/module/
|
||||||
proposal/validator writer wiring for H. Only debug creates the unique retained
|
proposal/validator writer wiring for H. Only debug creates the unique retained
|
||||||
run directory. Gate snapshots, interaction files, ledger, and full execution data
|
run directory. Gate snapshots, interaction files, ledger, and full execution data
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type processInvocation struct {
|
|||||||
GlossaryPath string
|
GlossaryPath string
|
||||||
OutputPath string
|
OutputPath string
|
||||||
ReportDir string
|
ReportDir string
|
||||||
|
RunID string
|
||||||
Config config.Config
|
Config config.Config
|
||||||
ConfigResult *ck.Result[config.Config]
|
ConfigResult *ck.Result[config.Config]
|
||||||
LLMRuntime *llm.Runtime
|
LLMRuntime *llm.Runtime
|
||||||
@@ -37,46 +38,65 @@ type processInvocation struct {
|
|||||||
ConfigVersion *int
|
ConfigVersion *int
|
||||||
}
|
}
|
||||||
|
|
||||||
var processRunner = func(ctx context.Context, inv processInvocation, stdout io.Writer) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) {
|
var processRunner = func(ctx context.Context, inv processInvocation, stdout, stderr io.Writer) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) {
|
||||||
runDir, err := diagnostics.NewRunDirectory(inv.Config.Diagnostics.WorkDir, "always", llm.ConfiguredSecrets(&inv.Config)...)
|
var runDir *diagnostics.RunDirectory
|
||||||
if err != nil {
|
if inv.Config.Diagnostics.Debug {
|
||||||
return nil, nil, nil, nil, fmt.Errorf("run_dir_creation: %w", err)
|
var err error
|
||||||
|
runDir, err = diagnostics.NewRunDirectory(inv.Config.Diagnostics.WorkDir, inv.RunID, llm.ConfiguredSecrets(&inv.Config)...)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "audita process: debug directory: %s\n", llm.RedactError(err, inv.Config))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugPath := ""
|
||||||
|
if runDir != nil {
|
||||||
|
debugPath = runDir.Path()
|
||||||
|
}
|
||||||
|
debugWrite := func(label string, write func(*diagnostics.RunDirectory) error) {
|
||||||
|
if runDir == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := write(runDir); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "audita process: debug %s: %s\n", label, llm.RedactError(err, inv.Config))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fail := func(phase string, err error, runOutput *runner.RunOutput) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) {
|
fail := func(phase string, err error, runOutput *runner.RunOutput) (*normalization.NormalizationSummary, *chunking.Summary, *runner.RunOutput, *diagnostics.RunDirectory, error) {
|
||||||
_ = runDir.WriteErrorLog(fmt.Sprintf("%s: %s", phase, llm.RedactError(err, inv.Config)))
|
debugWrite("error log", func(d *diagnostics.RunDirectory) error {
|
||||||
|
return d.WriteErrorLog(fmt.Sprintf("%s: %s", phase, llm.RedactError(err, inv.Config)))
|
||||||
|
})
|
||||||
return nil, nil, runOutput, runDir, fmt.Errorf("%s: %w", phase, err)
|
return nil, nil, runOutput, runDir, fmt.Errorf("%s: %w", phase, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runDir.WriteInvocationMetadata(diagnostics.InvocationMetadata{
|
debugWrite("invocation", func(d *diagnostics.RunDirectory) error {
|
||||||
Operation: "process",
|
return d.WriteInvocationMetadata(diagnostics.InvocationMetadata{
|
||||||
TranscriptPath: inv.TranscriptPath,
|
Operation: "process",
|
||||||
GlossaryPath: inv.GlossaryPath,
|
TranscriptPath: inv.TranscriptPath,
|
||||||
OutputPath: inv.OutputPath,
|
GlossaryPath: inv.GlossaryPath,
|
||||||
ConfigPath: inv.ConfigPath,
|
OutputPath: inv.OutputPath,
|
||||||
ConfigSource: inv.ConfigSource,
|
ConfigPath: inv.ConfigPath,
|
||||||
Overlay: inv.Overlay,
|
ConfigSource: inv.ConfigSource,
|
||||||
ConfigVersion: inv.ConfigVersion,
|
Overlay: inv.Overlay,
|
||||||
TranscriptDescription: inv.Config.Context.Description,
|
ConfigVersion: inv.ConfigVersion,
|
||||||
Modules: append([]string(nil), inv.Config.Pipeline.Modules...),
|
TranscriptDescription: inv.Config.Context.Description,
|
||||||
}); err != nil {
|
Modules: append([]string(nil), inv.Config.Pipeline.Modules...),
|
||||||
_ = runDir.WriteErrorLog(fmt.Sprintf("invocation_metadata: %v", err))
|
})
|
||||||
}
|
})
|
||||||
if inv.ConfigResult != nil {
|
if runDir != nil && inv.ConfigResult != nil {
|
||||||
var effective bytes.Buffer
|
var effective bytes.Buffer
|
||||||
if err := inv.ConfigResult.WriteEffective(&effective); err == nil {
|
if err := inv.ConfigResult.WriteEffective(&effective); err == nil {
|
||||||
err = runDir.WriteEffectiveConfig(effective.Bytes())
|
debugWrite("effective settings", func(d *diagnostics.RunDirectory) error { return d.WriteEffectiveConfig(effective.Bytes()) })
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
_ = runDir.WriteErrorLog(fmt.Sprintf("effective_config: %v", err))
|
|
||||||
}
|
}
|
||||||
if origins, err := llm.ExplanationYAML(inv.ConfigResult); err == nil {
|
if origins, err := llm.ExplanationYAML(inv.ConfigResult); err == nil {
|
||||||
_ = runDir.WriteYAMLArtifact(diagnostics.ArtifactConfigOrigins, origins)
|
debugWrite("configuration origins", func(d *diagnostics.RunDirectory) error {
|
||||||
|
return d.WriteYAMLArtifact(diagnostics.ArtifactConfigOrigins, origins)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if inv.LLMRuntime != nil {
|
if runDir != nil && inv.LLMRuntime != nil {
|
||||||
if profiles, err := inv.LLMRuntime.SelectedProfilesYAML(); err == nil {
|
if profiles, err := inv.LLMRuntime.SelectedProfilesYAML(); err == nil {
|
||||||
_ = runDir.WriteYAMLArtifact(diagnostics.ArtifactSelectedProfiles, profiles)
|
debugWrite("selected profiles", func(d *diagnostics.RunDirectory) error {
|
||||||
|
return d.WriteYAMLArtifact(diagnostics.ArtifactSelectedProfiles, profiles)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +120,9 @@ var processRunner = func(ctx context.Context, inv processInvocation, stdout io.W
|
|||||||
return fail("glossary_schema", err, nil)
|
return fail("glossary_schema", err, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runDir.WriteSourceTranscript(sourceTranscript, transcriptBytes); err != nil {
|
debugWrite("source transcript", func(d *diagnostics.RunDirectory) error {
|
||||||
_ = runDir.WriteErrorLog(fmt.Sprintf("source_artifact: %v", err))
|
return d.WriteSourceTranscript(sourceTranscript, transcriptBytes)
|
||||||
}
|
})
|
||||||
|
|
||||||
canonical := sourceToCanonicalTranscript(sourceTranscript)
|
canonical := sourceToCanonicalTranscript(sourceTranscript)
|
||||||
normalizer := normalization.NewNormalizer(normalization.NormalizationConfig{
|
normalizer := normalization.NewNormalizer(normalization.NormalizationConfig{
|
||||||
@@ -114,12 +134,8 @@ var processRunner = func(ctx context.Context, inv processInvocation, stdout io.W
|
|||||||
|
|
||||||
normalizedTranscript, normSummary := normalizer.Normalize(canonical)
|
normalizedTranscript, normSummary := normalizer.Normalize(canonical)
|
||||||
|
|
||||||
if err := runDir.WriteNormalizedTranscript(normalizedTranscript); err != nil {
|
debugWrite("normalized transcript", func(d *diagnostics.RunDirectory) error { return d.WriteNormalizedTranscript(normalizedTranscript) })
|
||||||
_ = runDir.WriteErrorLog(fmt.Sprintf("normalized_artifact: %v", err))
|
debugWrite("normalization summary", func(d *diagnostics.RunDirectory) error { return d.WriteNormalizationSummary(normSummary) })
|
||||||
}
|
|
||||||
if err := runDir.WriteNormalizationSummary(normSummary); err != nil {
|
|
||||||
_ = runDir.WriteErrorLog(fmt.Sprintf("normalization_summary: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute chunks after normalization
|
// Compute chunks after normalization
|
||||||
chunker := chunking.NewChunker(chunking.ChunkingConfig{
|
chunker := chunking.NewChunker(chunking.ChunkingConfig{
|
||||||
@@ -141,14 +157,12 @@ var processRunner = func(ctx context.Context, inv processInvocation, stdout io.W
|
|||||||
chunkSummary := chunking.ComputeSummary(sections, chunkConfig)
|
chunkSummary := chunking.ComputeSummary(sections, chunkConfig)
|
||||||
chunkDetailedSummary := chunking.ComputeDetailedSummary(sections, chunkConfig)
|
chunkDetailedSummary := chunking.ComputeDetailedSummary(sections, chunkConfig)
|
||||||
|
|
||||||
if err := runDir.WriteChunkingSummary(&chunkDetailedSummary); err != nil {
|
debugWrite("chunking summary", func(d *diagnostics.RunDirectory) error { return d.WriteChunkingSummary(&chunkDetailedSummary) })
|
||||||
_ = runDir.WriteErrorLog(fmt.Sprintf("chunking_summary: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
workingTranscript := normalizedTranscript
|
workingTranscript := normalizedTranscript
|
||||||
var runOutput *runner.RunOutput
|
var runOutput *runner.RunOutput
|
||||||
observations := llm.NewObservationCollector()
|
observations := llm.NewObservationCollector()
|
||||||
moduleFactory := modules.NewFactory(modules.Dependencies{Config: &inv.Config, Glossary: glossary, DiagnosticsDir: runDir.Path()})
|
moduleFactory := modules.NewFactory(modules.Dependencies{Config: &inv.Config, Glossary: glossary, DiagnosticsDir: debugPath})
|
||||||
if inv.LLMRuntime == nil {
|
if inv.LLMRuntime == nil {
|
||||||
return fail("runner_setup", fmt.Errorf("LLM runtime is required"), nil)
|
return fail("runner_setup", fmt.Errorf("LLM runtime is required"), nil)
|
||||||
}
|
}
|
||||||
@@ -173,9 +187,10 @@ var processRunner = func(ctx context.Context, inv processInvocation, stdout io.W
|
|||||||
ModuleSpecs: moduleSpecs,
|
ModuleSpecs: moduleSpecs,
|
||||||
|
|
||||||
ProposalLLMClient: proposalLLMClient,
|
ProposalLLMClient: proposalLLMClient,
|
||||||
ProposalDiagnosticsDir: runDir.Path(),
|
ProposalDiagnosticsDir: debugPath,
|
||||||
ValidationLLMClient: validationLLMClient,
|
ValidationLLMClient: validationLLMClient,
|
||||||
ValidationDiagnosticsDir: runDir.Path(),
|
ValidationDiagnosticsDir: debugPath,
|
||||||
|
CaptureCandidates: runDir != nil,
|
||||||
})
|
})
|
||||||
runnerResult.Observations = observations.Snapshot()
|
runnerResult.Observations = observations.Snapshot()
|
||||||
runnerResult.ObservationsAvailable = true
|
runnerResult.ObservationsAvailable = true
|
||||||
@@ -311,26 +326,24 @@ func runProcess(ctx context.Context, args []string, stdout, stderr io.Writer) in
|
|||||||
ConfigVersion: &version,
|
ConfigVersion: &version,
|
||||||
}
|
}
|
||||||
|
|
||||||
normSummary, chunkSummary, runOutput, runDir, runErr := processRunner(ctx, inv, stdout)
|
normSummary, chunkSummary, runOutput, runDir, runErr := processRunner(ctx, inv, stdout, stderr)
|
||||||
completedAt := time.Now().UTC()
|
completedAt := time.Now().UTC()
|
||||||
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
if runDir != nil && runOutput != nil {
|
if runDir != nil && runOutput != nil {
|
||||||
writeExecutionArtifacts(runDir, runOutput, inv.Config)
|
writeExecutionArtifacts(runDir, runOutput, inv.Config, stderr)
|
||||||
|
|
||||||
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactCorrectionLedger, processreport.BuildCorrectionLedger(processreport.CorrectionLedgerInput{
|
if debugErr := runDir.WriteJSONArtifact(diagnostics.ArtifactCorrectionLedger, processreport.BuildCorrectionLedger(processreport.CorrectionLedgerInput{
|
||||||
RunDirectoryPath: runDir.Path(),
|
RunDirectoryPath: runDir.Path(),
|
||||||
RunOutput: runOutput,
|
RunOutput: runOutput,
|
||||||
}))
|
})); debugErr != nil {
|
||||||
|
fmt.Fprintf(stderr, "audita process: debug correction ledger: %s\n", llm.RedactError(debugErr, inv.Config))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
errorPhase, errorMessage := extractErrorPhase(runErr)
|
errorPhase, errorMessage := extractErrorPhase(runErr)
|
||||||
errorMessage = llm.RedactError(fmt.Errorf("%s", errorMessage), inv.Config)
|
errorMessage = llm.RedactError(fmt.Errorf("%s", errorMessage), inv.Config)
|
||||||
report := processreport.Build(processReportInput("failed", inv, runDir, startedAt, completedAt, errorMessage, errorPhase, nil, nil, runOutput))
|
report := processreport.Build(processReportInput("failed", inv, runDir, startedAt, completedAt, errorMessage, errorPhase, nil, nil, runOutput))
|
||||||
|
|
||||||
if runDir != nil {
|
|
||||||
_ = runDir.WriteReport(report)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(stderr, "audita process: %s\n", llm.RedactError(runErr, inv.Config))
|
fmt.Fprintf(stderr, "audita process: %s\n", llm.RedactError(runErr, inv.Config))
|
||||||
if runDir != nil {
|
if runDir != nil {
|
||||||
fmt.Fprintf(stderr, "audita process: diagnostics: %s\n", runDir.Path())
|
fmt.Fprintf(stderr, "audita process: diagnostics: %s\n", runDir.Path())
|
||||||
@@ -339,29 +352,31 @@ func runProcess(ctx context.Context, args []string, stdout, stderr io.Writer) in
|
|||||||
}
|
}
|
||||||
|
|
||||||
if runDir != nil && runOutput != nil {
|
if runDir != nil && runOutput != nil {
|
||||||
writeExecutionArtifacts(runDir, runOutput, inv.Config)
|
writeExecutionArtifacts(runDir, runOutput, inv.Config, stderr)
|
||||||
|
|
||||||
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactCorrectionLedger, processreport.BuildCorrectionLedger(processreport.CorrectionLedgerInput{
|
if debugErr := runDir.WriteJSONArtifact(diagnostics.ArtifactCorrectionLedger, processreport.BuildCorrectionLedger(processreport.CorrectionLedgerInput{
|
||||||
RunDirectoryPath: runDir.Path(),
|
RunDirectoryPath: runDir.Path(),
|
||||||
RunOutput: runOutput,
|
RunOutput: runOutput,
|
||||||
}))
|
})); debugErr != nil {
|
||||||
|
fmt.Fprintf(stderr, "audita process: debug correction ledger: %s\n", llm.RedactError(debugErr, inv.Config))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
report := processreport.Build(processReportInput("success", inv, runDir, startedAt, completedAt, "", "", normSummary, chunkSummary, runOutput))
|
report := processreport.Build(processReportInput("success", inv, runDir, startedAt, completedAt, "", "", normSummary, chunkSummary, runOutput))
|
||||||
|
|
||||||
if runDir != nil {
|
|
||||||
_ = runDir.WriteReport(report)
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeExecutionArtifacts(runDir *diagnostics.RunDirectory, output *runner.RunOutput, cfg config.Config) {
|
func writeExecutionArtifacts(runDir *diagnostics.RunDirectory, output *runner.RunOutput, cfg config.Config, stderr io.Writer) {
|
||||||
if !output.ObservationsAvailable {
|
if !output.ObservationsAvailable {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactLLMExecution, processreport.BuildLLMExecution(output.Observations))
|
if err := runDir.WriteJSONArtifact(diagnostics.ArtifactLLMExecution, processreport.BuildLLMExecution(output.Observations)); err != nil {
|
||||||
_ = runDir.WriteJSONArtifact(diagnostics.ArtifactUtilizationSummary, processreport.BuildUtilization(output, cfg.Backends))
|
fmt.Fprintf(stderr, "audita process: debug model execution: %s\n", llm.RedactError(err, cfg))
|
||||||
|
}
|
||||||
|
if err := runDir.WriteJSONArtifact(diagnostics.ArtifactUtilizationSummary, processreport.BuildUtilization(output, cfg.Backends)); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "audita process: debug utilization: %s\n", llm.RedactError(err, cfg))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runConfig(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
func runConfig(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||||
|
|||||||
@@ -199,3 +199,30 @@ func TestFinalizationCollectsFailuresAndRetainsPublishedTranscript(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDebugRunDirectoryUsesSuppliedIDAndIsRetained(t *testing.T) {
|
||||||
|
parent := filepath.Join(t.TempDir(), "debug")
|
||||||
|
if _, err := os.Stat(parent); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("unexpected parent: %v", err)
|
||||||
|
}
|
||||||
|
run, err := NewRunDirectory(parent, "run-123", "secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if run.Path() != filepath.Join(parent, "run-123") {
|
||||||
|
t.Fatalf("run ID changed: %s", run.Path())
|
||||||
|
}
|
||||||
|
if _, err := NewRunDirectory(parent, "run-123"); err == nil {
|
||||||
|
t.Fatal("duplicate run ID accepted")
|
||||||
|
}
|
||||||
|
if err := run.WriteJSONArtifact(ArtifactCorrectionLedger, map[string]string{"proposal": "secret"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(filepath.Join(run.Path(), ArtifactCorrectionLedger))
|
||||||
|
if err != nil || strings.Contains(string(contents), "secret") {
|
||||||
|
t.Fatalf("debug artifact: %s %v", contents, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(run.Path()); err != nil {
|
||||||
|
t.Fatalf("debug directory removed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
package diagnostics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestShouldRetainRunDirectoryMatrix(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input RetentionDecisionInput
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "always success keeps", input: RetentionDecisionInput{RetentionMode: "always", RunSucceeded: true}, want: true},
|
|
||||||
{name: "always failure keeps", input: RetentionDecisionInput{RetentionMode: "always", RunSucceeded: false}, want: true},
|
|
||||||
{name: "never success keeps", input: RetentionDecisionInput{RetentionMode: "never", RunSucceeded: true}, want: true},
|
|
||||||
{name: "never failure keeps", input: RetentionDecisionInput{RetentionMode: "never", RunSucceeded: false}, want: true},
|
|
||||||
{name: "auto success no skips removes", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: true, HasSkippedCorrections: false}, want: false},
|
|
||||||
{name: "auto success skips keeps", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: true, HasSkippedCorrections: true}, want: true},
|
|
||||||
{name: "auto failure keeps", input: RetentionDecisionInput{RetentionMode: "auto", RunSucceeded: false}, want: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := ShouldRetainRunDirectory(tc.input)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Fatalf("unexpected retain decision: got=%v want=%v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyRetentionRemovesWhenDecisionSaysRemove(t *testing.T) {
|
|
||||||
workDir := t.TempDir()
|
|
||||||
runPath := filepath.Join(workDir, "run-test")
|
|
||||||
if err := os.Mkdir(runPath, 0o755); err != nil {
|
|
||||||
t.Fatalf("mkdir run path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
runDir := &RunDirectory{path: runPath, retention: "auto"}
|
|
||||||
err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasSkippedCorrections: false})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ApplyRetention failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(runPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("expected run directory removed, stat err=%v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyRetentionReturnsRemovalError(t *testing.T) {
|
|
||||||
parent := t.TempDir()
|
|
||||||
runPath := filepath.Join(parent, "run-test")
|
|
||||||
if err := os.Mkdir(runPath, 0o755); err != nil {
|
|
||||||
t.Fatalf("mkdir run path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make parent non-writable so removing child fails.
|
|
||||||
if err := os.Chmod(parent, 0o500); err != nil {
|
|
||||||
t.Fatalf("chmod parent: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = os.Chmod(parent, 0o700)
|
|
||||||
})
|
|
||||||
|
|
||||||
runDir := &RunDirectory{path: runPath, retention: "auto"}
|
|
||||||
err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasSkippedCorrections: false})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected removal error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package diagnostics
|
package diagnostics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
|
"gitea.maximumdirect.net/eric/audita/internal/core/chunking"
|
||||||
@@ -16,42 +18,15 @@ import (
|
|||||||
type RunDirectory struct {
|
type RunDirectory struct {
|
||||||
redactor redaction.Redactor
|
redactor redaction.Redactor
|
||||||
path string
|
path string
|
||||||
retention string
|
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type RetentionDecisionInput struct {
|
|
||||||
RetentionMode string
|
|
||||||
RunSucceeded bool
|
|
||||||
HasSkippedCorrections bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func ShouldRetainRunDirectory(input RetentionDecisionInput) bool {
|
|
||||||
// Failed runs are always retained.
|
|
||||||
if !input.RunSucceeded {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch input.RetentionMode {
|
|
||||||
case "always":
|
|
||||||
return true
|
|
||||||
case "never":
|
|
||||||
return true
|
|
||||||
case "auto":
|
|
||||||
return input.HasSkippedCorrections
|
|
||||||
default:
|
|
||||||
// Be conservative for unknown values.
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvocationMetadata captures non-secret invocation details for diagnostics.
|
// InvocationMetadata captures non-secret invocation details for diagnostics.
|
||||||
type InvocationMetadata struct {
|
type InvocationMetadata struct {
|
||||||
Operation string `json:"operation"`
|
Operation string `json:"operation"`
|
||||||
TranscriptPath string `json:"transcript_path"`
|
TranscriptPath string `json:"transcript_path"`
|
||||||
GlossaryPath string `json:"glossary_path"`
|
GlossaryPath string `json:"glossary_path"`
|
||||||
OutputPath string `json:"output_path,omitempty"`
|
OutputPath string `json:"output_path,omitempty"`
|
||||||
ReportJSONPath string `json:"report_json_path,omitempty"`
|
|
||||||
ConfigPath string `json:"config_path,omitempty"`
|
ConfigPath string `json:"config_path,omitempty"`
|
||||||
ConfigSource string `json:"config_source,omitempty"`
|
ConfigSource string `json:"config_source,omitempty"`
|
||||||
Overlay string `json:"overlay,omitempty"`
|
Overlay string `json:"overlay,omitempty"`
|
||||||
@@ -63,9 +38,12 @@ type InvocationMetadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewRunDirectory creates a new run directory under the configured work dir
|
// NewRunDirectory creates a new run directory under the configured work dir
|
||||||
func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirectory, error) {
|
func NewRunDirectory(workDir, runID string, secrets ...string) (*RunDirectory, error) {
|
||||||
if workDir == "" {
|
if strings.TrimSpace(workDir) == "" {
|
||||||
workDir = ".audita-runs"
|
return nil, fmt.Errorf("debug work directory is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(runID) == "" || filepath.Base(runID) != runID || runID == "." || runID == ".." {
|
||||||
|
return nil, fmt.Errorf("valid run ID is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create work directory if it doesn't exist
|
// Create work directory if it doesn't exist
|
||||||
@@ -73,8 +51,6 @@ func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirector
|
|||||||
return nil, fmt.Errorf("failed to create work directory %q: %w", workDir, err)
|
return nil, fmt.Errorf("failed to create work directory %q: %w", workDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a unique run directory identifier.
|
|
||||||
runID := fmt.Sprintf("run-%d", time.Now().UTC().UnixNano())
|
|
||||||
runPath := filepath.Join(workDir, runID)
|
runPath := filepath.Join(workDir, runID)
|
||||||
|
|
||||||
if err := os.Mkdir(runPath, 0o755); err != nil {
|
if err := os.Mkdir(runPath, 0o755); err != nil {
|
||||||
@@ -84,7 +60,6 @@ func NewRunDirectory(workDir, retention string, secrets ...string) (*RunDirector
|
|||||||
return &RunDirectory{
|
return &RunDirectory{
|
||||||
path: runPath,
|
path: runPath,
|
||||||
redactor: redaction.New(secrets),
|
redactor: redaction.New(secrets),
|
||||||
retention: retention,
|
|
||||||
createdAt: time.Now().UTC(),
|
createdAt: time.Now().UTC(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -126,9 +101,19 @@ func (r *RunDirectory) WriteEffectiveConfig(effectiveYAML []byte) error {
|
|||||||
|
|
||||||
// WriteSourceTranscript writes the source transcript artifact
|
// WriteSourceTranscript writes the source transcript artifact
|
||||||
func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript, raw []byte) error {
|
func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript, raw []byte) error {
|
||||||
// Write raw source for reference
|
// Preserve the source snapshot without copying unredacted input bytes.
|
||||||
|
var source any
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&source); err != nil {
|
||||||
|
return fmt.Errorf("decode source snapshot: %w", err)
|
||||||
|
}
|
||||||
|
sourceBytes, err := r.redactor.JSON(source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("redact source snapshot: %w", err)
|
||||||
|
}
|
||||||
sourcePath := filepath.Join(r.path, ArtifactSourceTranscript)
|
sourcePath := filepath.Join(r.path, ArtifactSourceTranscript)
|
||||||
if err := os.WriteFile(sourcePath, raw, 0o644); err != nil {
|
if err := os.WriteFile(sourcePath, append(sourceBytes, '\n'), 0o644); err != nil {
|
||||||
return fmt.Errorf("failed to write source transcript: %w", err)
|
return fmt.Errorf("failed to write source transcript: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +134,7 @@ func (r *RunDirectory) WriteSourceTranscript(transcript *schema.SourceTranscript
|
|||||||
// WriteNormalizedTranscript writes the normalized transcript artifact
|
// WriteNormalizedTranscript writes the normalized transcript artifact
|
||||||
func (r *RunDirectory) WriteNormalizedTranscript(transcript *schema.Transcript) error {
|
func (r *RunDirectory) WriteNormalizedTranscript(transcript *schema.Transcript) error {
|
||||||
normalizedPath := filepath.Join(r.path, ArtifactNormalizedTranscript)
|
normalizedPath := filepath.Join(r.path, ArtifactNormalizedTranscript)
|
||||||
bytes, err := schema.TranscriptToJSON(transcript)
|
bytes, err := r.redactor.JSON(transcript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to serialize normalized transcript: %w", err)
|
return fmt.Errorf("failed to serialize normalized transcript: %w", err)
|
||||||
}
|
}
|
||||||
@@ -218,15 +203,3 @@ func (r *RunDirectory) WriteYAMLArtifact(name string, payload []byte) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RunDirectory) ApplyRetention(input RetentionDecisionInput) error {
|
|
||||||
decision := input
|
|
||||||
if decision.RetentionMode == "" {
|
|
||||||
decision.RetentionMode = r.retention
|
|
||||||
}
|
|
||||||
|
|
||||||
if ShouldRetainRunDirectory(decision) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return os.RemoveAll(r.path)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/audita/internal/core/diagnostics"
|
"gitea.maximumdirect.net/eric/audita/internal/core/diagnostics"
|
||||||
"gitea.maximumdirect.net/eric/audita/internal/core/redaction"
|
"gitea.maximumdirect.net/eric/audita/internal/core/redaction"
|
||||||
"gitea.maximumdirect.net/eric/audita/internal/core/reporting"
|
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStringRedactionPreservesSerializationAndDomainData(t *testing.T) {
|
func TestStringRedactionPreservesSerializationAndDomainData(t *testing.T) {
|
||||||
@@ -51,37 +51,26 @@ func TestStringRedactionPreservesSerializationAndDomainData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReportWritersRedactStringsAndFailClosed(t *testing.T) {
|
func TestDebugWritersRedactSourceAndNormalizedSnapshots(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
version := 2
|
run, err := diagnostics.NewRunDirectory(dir, "run-test", "2")
|
||||||
report := reporting.ProcessReport{ErrorMessage: "echo:2"}
|
|
||||||
report.ReportMetadata.ConfigVersion = &version
|
|
||||||
run, err := diagnostics.NewRunDirectory(dir, "always", "2")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
external := filepath.Join(dir, "external.json")
|
if err := run.WriteSourceTranscript(&schema.SourceTranscript{}, []byte(`{"segments":[{"text":"echo:2","id":2}]}`)); err != nil {
|
||||||
if err := reporting.WriteProcessReport(external, report, "2"); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := run.WriteReport(report); err != nil {
|
if err := run.WriteNormalizedTranscript(&schema.Transcript{Segments: []schema.Segment{{ID: 2, Text: "echo:2"}}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, path := range []string{external, filepath.Join(run.Path(), diagnostics.ArtifactReport)} {
|
for _, path := range []string{filepath.Join(run.Path(), diagnostics.ArtifactSourceTranscript), filepath.Join(run.Path(), diagnostics.ArtifactNormalizedTranscript)} {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var got reporting.ProcessReport
|
if strings.Contains(string(raw), "echo:2") || !strings.Contains(string(raw), "echo:[REDACTED]") || !strings.Contains(string(raw), `"id": 2`) {
|
||||||
if err := json.Unmarshal(raw, &got); err != nil {
|
t.Fatalf("snapshot: %s", raw)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
if got.ErrorMessage != "echo:[REDACTED]" || got.ReportMetadata.ConfigVersion == nil || *got.ReportMetadata.ConfigVersion != 2 {
|
|
||||||
t.Fatalf("report: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if report.ErrorMessage != "echo:2" {
|
|
||||||
t.Fatal("mutated input report")
|
|
||||||
}
|
}
|
||||||
if err := run.WriteJSONArtifact("invalid.json", map[string]any{"secret": "echo:2", "invalid": math.NaN()}); err == nil {
|
if err := run.WriteJSONArtifact("invalid.json", map[string]any{"secret": "echo:2", "invalid": math.NaN()}); err == nil {
|
||||||
t.Fatal("expected serialization failure")
|
t.Fatal("expected serialization failure")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package processreport
|
package processreport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
@@ -9,10 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
correctionDispositionApplied = "applied"
|
correctionDispositionApplied = "applied"
|
||||||
correctionDispositionRejected = "rejected"
|
correctionDispositionRejected = "rejected"
|
||||||
correctionDispositionSkipped = "skipped"
|
correctionDispositionSkipped = "skipped"
|
||||||
correctionDispositionFailed = "failed"
|
correctionDispositionFailed = "failed"
|
||||||
|
correctionDispositionInterrupted = "interrupted"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CorrectionLedgerInput struct {
|
type CorrectionLedgerInput struct {
|
||||||
@@ -55,6 +57,8 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries := make([]CorrectionLedgerEntry, 0)
|
entries := make([]CorrectionLedgerEntry, 0)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
identity := func(instance string, index int) string { return instance + "\x00" + fmt.Sprint(index) }
|
||||||
for _, module := range runOutput.ModuleResults {
|
for _, module := range runOutput.ModuleResults {
|
||||||
decisionsByProposal := make(map[int][]runner.ValidatorDecisionRecord)
|
decisionsByProposal := make(map[int][]runner.ValidatorDecisionRecord)
|
||||||
for _, decision := range module.ValidatorDecisions {
|
for _, decision := range module.ValidatorDecisions {
|
||||||
@@ -62,6 +66,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, change := range module.AppliedChanges {
|
for _, change := range module.AppliedChanges {
|
||||||
|
seen[identity(module.ModuleInstance, change.ProposalIndex)] = true
|
||||||
entries = append(entries, CorrectionLedgerEntry{
|
entries = append(entries, CorrectionLedgerEntry{
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
ModuleKey: module.ModuleKey,
|
ModuleKey: module.ModuleKey,
|
||||||
@@ -78,6 +83,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, change := range module.SkippedChanges {
|
for _, change := range module.SkippedChanges {
|
||||||
|
seen[identity(module.ModuleInstance, change.ProposalIndex)] = true
|
||||||
entries = append(entries, CorrectionLedgerEntry{
|
entries = append(entries, CorrectionLedgerEntry{
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
ModuleKey: module.ModuleKey,
|
ModuleKey: module.ModuleKey,
|
||||||
@@ -95,6 +101,7 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, rejection := range module.ValidatorRejected {
|
for _, rejection := range module.ValidatorRejected {
|
||||||
|
seen[identity(module.ModuleInstance, rejection.ProposalIndex)] = true
|
||||||
entries = append(entries, CorrectionLedgerEntry{
|
entries = append(entries, CorrectionLedgerEntry{
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
ModuleKey: module.ModuleKey,
|
ModuleKey: module.ModuleKey,
|
||||||
@@ -122,6 +129,34 @@ func BuildCorrectionLedger(input CorrectionLedgerInput) []CorrectionLedgerEntry
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, candidate := range runOutput.Candidates {
|
||||||
|
if seen[identity(candidate.ModuleInstance, candidate.ProposalIndex)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var decisions []runner.ValidatorDecisionRecord
|
||||||
|
policy := ""
|
||||||
|
for _, module := range runOutput.ModuleResults {
|
||||||
|
if module.ModuleInstance != candidate.ModuleInstance {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
policy = string(module.ReplacementPolicy)
|
||||||
|
for _, decision := range module.ValidatorDecisions {
|
||||||
|
if decision.ProposalIndex == candidate.ProposalIndex {
|
||||||
|
decisions = append(decisions, decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
entries = append(entries, CorrectionLedgerEntry{
|
||||||
|
RunID: runID, ModuleKey: candidate.ModuleKey, ModuleInstance: candidate.ModuleInstance,
|
||||||
|
ProposalIndex: candidate.ProposalIndex, SegmentID: candidate.TargetSegmentID,
|
||||||
|
OriginalText: candidate.OriginalText, ProposedCorrectedText: candidate.CorrectedText,
|
||||||
|
ReplacementPolicy: policy, Disposition: correctionDispositionInterrupted,
|
||||||
|
DispositionReasonCode: "not_applied", DispositionMessage: "candidate did not reach application",
|
||||||
|
DeterministicValidatorResults: filterLedgerDecisions(decisions, false),
|
||||||
|
LLMValidatorResults: filterLedgerDecisions(decisions, true),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
sort.SliceStable(entries, func(i, j int) bool {
|
sort.SliceStable(entries, func(i, j int) bool {
|
||||||
if entries[i].ModuleInstance != entries[j].ModuleInstance {
|
if entries[i].ModuleInstance != entries[j].ModuleInstance {
|
||||||
|
|||||||
@@ -126,3 +126,25 @@ func TestHasSkippedCorrectionsIncludesApplicationSkipsAndValidatorRejections(t *
|
|||||||
t.Fatal("validator rejections should count as skipped corrections")
|
t.Fatal("validator rejections should count as skipped corrections")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCorrectionLedgerIncludesUnappliedCandidateAfterFatalValidation(t *testing.T) {
|
||||||
|
output := &runner.RunOutput{
|
||||||
|
Candidates: []proposals.EnrichedCorrectionProposal{{CorrectionProposal: proposals.CorrectionProposal{TargetSegmentID: 7, OriginalText: "old", CorrectedText: "new"}, ProposalMetadata: proposals.ProposalMetadata{ModuleKey: "grammar", ModuleInstance: "grammar", ProposalIndex: 2}}},
|
||||||
|
ModuleResults: []runner.ModuleResult{{ModuleKey: "grammar", ModuleInstance: "grammar", Status: runner.ModuleStatusFailed,
|
||||||
|
ValidatorDecisions: []runner.ValidatorDecisionRecord{{ValidatorName: "spoken_form_plausibility", ProposalIndex: 2, Approved: false, ReasonCode: "transport_failed"}}}},
|
||||||
|
}
|
||||||
|
ledger := BuildCorrectionLedger(CorrectionLedgerInput{RunOutput: output})
|
||||||
|
if len(ledger) != 2 {
|
||||||
|
t.Fatalf("missing candidate: %+v", ledger)
|
||||||
|
}
|
||||||
|
for _, entry := range ledger {
|
||||||
|
if entry.ProposalIndex != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.Disposition != correctionDispositionInterrupted || entry.OriginalText != "old" || len(entry.LLMValidatorResults) != 1 {
|
||||||
|
t.Fatalf("candidate evidence lost: %+v", entry)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("candidate missing")
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,20 +94,22 @@ type RunInput struct {
|
|||||||
ProposalDiagnosticsDir string
|
ProposalDiagnosticsDir string
|
||||||
ValidationLLMClient contracts.StructuredLLMClient
|
ValidationLLMClient contracts.StructuredLLMClient
|
||||||
ValidationDiagnosticsDir string
|
ValidationDiagnosticsDir string
|
||||||
|
CaptureCandidates bool
|
||||||
timings *validatorTimingCollector
|
timings *validatorTimingCollector
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunOutput is the deterministic runner output.
|
// RunOutput is the deterministic runner output.
|
||||||
type RunOutput struct {
|
type RunOutput struct {
|
||||||
FinalTranscript *schema.Transcript `json:"-"`
|
FinalTranscript *schema.Transcript `json:"-"`
|
||||||
ModuleResults []ModuleResult `json:"module_results"`
|
ModuleResults []ModuleResult `json:"module_results"`
|
||||||
Sections []contracts.SectionMetadata `json:"sections"`
|
Sections []contracts.SectionMetadata `json:"sections"`
|
||||||
WorkUnits []WorkUnitOutcome `json:"work_units"`
|
WorkUnits []WorkUnitOutcome `json:"work_units"`
|
||||||
Observations llm.ObservationSnapshot `json:"-"`
|
Candidates []proposals.EnrichedCorrectionProposal `json:"-"`
|
||||||
ObservationsAvailable bool `json:"-"`
|
Observations llm.ObservationSnapshot `json:"-"`
|
||||||
LLMSummary llm.ExecutionSummary `json:"-"`
|
ObservationsAvailable bool `json:"-"`
|
||||||
RunWallTimeMS int64 `json:"-"`
|
LLMSummary llm.ExecutionSummary `json:"-"`
|
||||||
ValidatorTimings []ValidatorTiming `json:"-"`
|
RunWallTimeMS int64 `json:"-"`
|
||||||
|
ValidatorTimings []ValidatorTiming `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(factory ModuleFactory) *Runner {
|
func New(factory ModuleFactory) *Runner {
|
||||||
@@ -136,6 +138,7 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
|
|||||||
}
|
}
|
||||||
inventory := make([]contracts.SectionMetadata, len(sections))
|
inventory := make([]contracts.SectionMetadata, len(sections))
|
||||||
workUnits := make([]WorkUnitOutcome, 0, len(input.ModuleSpecs)*len(sections))
|
workUnits := make([]WorkUnitOutcome, 0, len(input.ModuleSpecs)*len(sections))
|
||||||
|
var candidates []proposals.EnrichedCorrectionProposal
|
||||||
for i, section := range sections {
|
for i, section := range sections {
|
||||||
inventory[i] = contracts.SectionMetadataFromSection(section)
|
inventory[i] = contracts.SectionMetadataFromSection(section)
|
||||||
}
|
}
|
||||||
@@ -151,7 +154,7 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
|
|||||||
snapshot := func() RunOutput {
|
snapshot := func() RunOutput {
|
||||||
return RunOutput{
|
return RunOutput{
|
||||||
FinalTranscript: transcriptFromSections(sections), ModuleResults: results,
|
FinalTranscript: transcriptFromSections(sections), ModuleResults: results,
|
||||||
Sections: inventory, WorkUnits: workUnits,
|
Sections: inventory, WorkUnits: workUnits, Candidates: candidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +188,9 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
|
|||||||
ValidationDiagnosticsDir: input.ValidationDiagnosticsDir,
|
ValidationDiagnosticsDir: input.ValidationDiagnosticsDir,
|
||||||
Timings: input.timings,
|
Timings: input.timings,
|
||||||
})
|
})
|
||||||
|
if input.CaptureCandidates {
|
||||||
|
candidates = append(candidates, pipelineResult.Candidates...)
|
||||||
|
}
|
||||||
if pipelineErr != nil {
|
if pipelineErr != nil {
|
||||||
failed := ModuleResult{
|
failed := ModuleResult{
|
||||||
ModuleKey: spec.ModuleKey,
|
ModuleKey: spec.ModuleKey,
|
||||||
@@ -321,6 +327,7 @@ type sectionValidationResult struct {
|
|||||||
|
|
||||||
type modulePipelineResult struct {
|
type modulePipelineResult struct {
|
||||||
ProposalCount int
|
ProposalCount int
|
||||||
|
Candidates []proposals.EnrichedCorrectionProposal
|
||||||
Approved []proposals.EnrichedCorrectionProposal
|
Approved []proposals.EnrichedCorrectionProposal
|
||||||
Operations []outcomes.Operation
|
Operations []outcomes.Operation
|
||||||
WorkUnits []WorkUnitOutcome
|
WorkUnits []WorkUnitOutcome
|
||||||
@@ -473,6 +480,7 @@ func runModulePipeline(ctx context.Context, input collectSectionProposalsInput)
|
|||||||
}
|
}
|
||||||
nextProposalIndex += len(sectionEnriched)
|
nextProposalIndex += len(sectionEnriched)
|
||||||
out.ProposalCount += len(sectionEnriched)
|
out.ProposalCount += len(sectionEnriched)
|
||||||
|
out.Candidates = append(out.Candidates, sectionEnriched...)
|
||||||
for operationIndex := range out.WorkUnits[nextSectionToProcess].Operations {
|
for operationIndex := range out.WorkUnits[nextSectionToProcess].Operations {
|
||||||
operation := &out.WorkUnits[nextSectionToProcess].Operations[operationIndex]
|
operation := &out.WorkUnits[nextSectionToProcess].Operations[operationIndex]
|
||||||
if operation.Reference.Kind == outcomes.OperationProposal && operation.Succeeded {
|
if operation.Reference.Kind == outcomes.OperationProposal && operation.Succeeded {
|
||||||
|
|||||||
@@ -128,6 +128,31 @@ func TestFatalValidatorNeverReturnsUncheckedCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCandidateCaptureIsExplicitAndIncludesFilteredProposals(t *testing.T) {
|
||||||
|
module := fakeModule{key: "grammar", policy: proposals.ReplacementPolicyRequireUnique,
|
||||||
|
validators: []contracts.Validator{validators.ProposalShapeValidator{}},
|
||||||
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
||||||
|
return []proposals.CorrectionProposal{{TargetSegmentID: 0, OriginalText: "bad", CorrectedText: "good", Confidence: 1}}, nil
|
||||||
|
}}
|
||||||
|
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{"grammar": module}})
|
||||||
|
input := RunInput{Transcript: &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "bad"}}}, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "grammar", InstanceName: "grammar"}}}
|
||||||
|
without, err := r.Run(context.Background(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(without.Candidates) != 0 {
|
||||||
|
t.Fatalf("debug-off candidate capture: %+v", without.Candidates)
|
||||||
|
}
|
||||||
|
input.CaptureCandidates = true
|
||||||
|
with, err := r.Run(context.Background(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(with.Candidates) != 1 || with.Candidates[0].ProposalIndex != 0 || len(with.ModuleResults[0].ValidatorRejected) != 1 {
|
||||||
|
t.Fatalf("filtered candidate missing from debug evidence: %+v", with)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m outcomeModule) Propose(_ context.Context, req contracts.ProposalRequest) (contracts.ProposalResult, error) {
|
func (m outcomeModule) Propose(_ context.Context, req contracts.ProposalRequest) (contracts.ProposalResult, error) {
|
||||||
return m.proposeResult(req)
|
return m.proposeResult(req)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user