Add utilization diagnostics and correction ledger
This commit is contained in:
157
internal/cli/review_artifacts.go
Normal file
157
internal/cli/review_artifacts.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/framework/runner"
|
||||
)
|
||||
|
||||
const (
|
||||
correctionDispositionApplied = "applied"
|
||||
correctionDispositionRejected = "rejected"
|
||||
correctionDispositionSkipped = "skipped"
|
||||
correctionDispositionFailed = "failed"
|
||||
)
|
||||
|
||||
type correctionLedgerEntry struct {
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
ProposalIndex int `json:"proposal_index"`
|
||||
SegmentID int `json:"segment_id,omitempty"`
|
||||
OriginalText string `json:"original_text,omitempty"`
|
||||
ProposedCorrectedText string `json:"proposed_corrected_text,omitempty"`
|
||||
AppliedCorrectedText string `json:"applied_corrected_text,omitempty"`
|
||||
ReplacementPolicy string `json:"replacement_policy,omitempty"`
|
||||
Disposition string `json:"disposition"`
|
||||
DispositionReasonCode string `json:"disposition_reason_code,omitempty"`
|
||||
DispositionMessage string `json:"disposition_message,omitempty"`
|
||||
DeterministicValidatorResults []ledgerValidatorDecisionRecord `json:"deterministic_validator_decisions,omitempty"`
|
||||
LLMValidatorResults []ledgerValidatorDecisionRecord `json:"llm_validator_decisions,omitempty"`
|
||||
}
|
||||
|
||||
type ledgerValidatorDecisionRecord struct {
|
||||
ValidatorKey string `json:"validator_key"`
|
||||
Approved bool `json:"approved"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func buildCorrectionLedger(runDirPath string, runOutput *runner.RunOutput) []correctionLedgerEntry {
|
||||
if runOutput == nil || len(runOutput.ModuleResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
runID := ""
|
||||
if runDirPath != "" {
|
||||
runID = filepath.Base(runDirPath)
|
||||
}
|
||||
|
||||
entries := make([]correctionLedgerEntry, 0)
|
||||
llmBacked := map[string]bool{
|
||||
"spoken_form_plausibility": true,
|
||||
"meaning_reversal_review": true,
|
||||
"editorial_review": true,
|
||||
"grammar_review": true,
|
||||
"spoken_word_review": true,
|
||||
}
|
||||
|
||||
for _, module := range runOutput.ModuleResults {
|
||||
decisionsByProposal := make(map[int][]runner.ValidatorDecisionRecord)
|
||||
for _, decision := range module.ValidatorDecisions {
|
||||
decisionsByProposal[decision.ProposalIndex] = append(decisionsByProposal[decision.ProposalIndex], decision)
|
||||
}
|
||||
|
||||
for _, change := range module.AppliedChanges {
|
||||
entries = append(entries, correctionLedgerEntry{
|
||||
RunID: runID,
|
||||
ModuleKey: module.ModuleKey,
|
||||
ModuleInstance: module.ModuleInstance,
|
||||
ProposalIndex: change.ProposalIndex,
|
||||
SegmentID: change.TargetSegmentID,
|
||||
OriginalText: change.OriginalText,
|
||||
ProposedCorrectedText: change.CorrectedText,
|
||||
AppliedCorrectedText: change.CorrectedText,
|
||||
ReplacementPolicy: string(module.ReplacementPolicy),
|
||||
Disposition: correctionDispositionApplied,
|
||||
DeterministicValidatorResults: filterLedgerDecisions(decisionsByProposal[change.ProposalIndex], false, llmBacked),
|
||||
LLMValidatorResults: filterLedgerDecisions(decisionsByProposal[change.ProposalIndex], true, llmBacked),
|
||||
})
|
||||
}
|
||||
for _, change := range module.SkippedChanges {
|
||||
entries = append(entries, correctionLedgerEntry{
|
||||
RunID: runID,
|
||||
ModuleKey: module.ModuleKey,
|
||||
ModuleInstance: module.ModuleInstance,
|
||||
ProposalIndex: change.ProposalIndex,
|
||||
SegmentID: change.TargetSegmentID,
|
||||
OriginalText: change.OriginalText,
|
||||
ProposedCorrectedText: change.CorrectedText,
|
||||
ReplacementPolicy: string(module.ReplacementPolicy),
|
||||
Disposition: correctionDispositionSkipped,
|
||||
DispositionReasonCode: string(change.SkipReason),
|
||||
DispositionMessage: change.Message,
|
||||
DeterministicValidatorResults: filterLedgerDecisions(decisionsByProposal[change.ProposalIndex], false, llmBacked),
|
||||
LLMValidatorResults: filterLedgerDecisions(decisionsByProposal[change.ProposalIndex], true, llmBacked),
|
||||
})
|
||||
}
|
||||
for _, rejection := range module.ValidatorRejected {
|
||||
entries = append(entries, correctionLedgerEntry{
|
||||
RunID: runID,
|
||||
ModuleKey: module.ModuleKey,
|
||||
ModuleInstance: module.ModuleInstance,
|
||||
ProposalIndex: rejection.ProposalIndex,
|
||||
SegmentID: rejection.TargetSegmentID,
|
||||
OriginalText: rejection.OriginalText,
|
||||
ProposedCorrectedText: rejection.CorrectedText,
|
||||
ReplacementPolicy: string(module.ReplacementPolicy),
|
||||
Disposition: correctionDispositionRejected,
|
||||
DispositionReasonCode: rejection.ReasonCode,
|
||||
DispositionMessage: rejection.Message,
|
||||
DeterministicValidatorResults: filterLedgerDecisions(decisionsByProposal[rejection.ProposalIndex], false, llmBacked),
|
||||
LLMValidatorResults: filterLedgerDecisions(decisionsByProposal[rejection.ProposalIndex], true, llmBacked),
|
||||
})
|
||||
}
|
||||
if module.Status == runner.ModuleStatusFailed {
|
||||
entries = append(entries, correctionLedgerEntry{
|
||||
RunID: runID,
|
||||
ModuleKey: module.ModuleKey,
|
||||
ModuleInstance: module.ModuleInstance,
|
||||
Disposition: correctionDispositionFailed,
|
||||
DispositionReasonCode: "module_failed",
|
||||
DispositionMessage: module.ErrorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].ModuleInstance != entries[j].ModuleInstance {
|
||||
return entries[i].ModuleInstance < entries[j].ModuleInstance
|
||||
}
|
||||
if entries[i].ProposalIndex != entries[j].ProposalIndex {
|
||||
return entries[i].ProposalIndex < entries[j].ProposalIndex
|
||||
}
|
||||
return entries[i].Disposition < entries[j].Disposition
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func filterLedgerDecisions(in []runner.ValidatorDecisionRecord, wantLLM bool, llmBacked map[string]bool) []ledgerValidatorDecisionRecord {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ledgerValidatorDecisionRecord, 0, len(in))
|
||||
for _, decision := range in {
|
||||
if llmBacked[decision.ValidatorName] != wantLLM {
|
||||
continue
|
||||
}
|
||||
out = append(out, ledgerValidatorDecisionRecord{
|
||||
ValidatorKey: decision.ValidatorName,
|
||||
Approved: decision.Approved,
|
||||
ReasonCode: decision.ReasonCode,
|
||||
Message: decision.Message,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -241,10 +241,15 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio
|
||||
runCtx, cancelRun := processRunnerContext()
|
||||
defer cancelRun()
|
||||
runnerResult, runErr := runner.New(moduleFactory).Run(runCtx, runner.RunInput{
|
||||
Config: &inv.Config,
|
||||
Transcript: normalizedTranscript,
|
||||
Glossary: glossary,
|
||||
ModuleSpecs: moduleSpecs,
|
||||
Config: &inv.Config,
|
||||
Transcript: normalizedTranscript,
|
||||
Glossary: glossary,
|
||||
ModuleSpecs: moduleSpecs,
|
||||
EffectiveConcurrency: runner.EffectiveConcurrencyLimits{
|
||||
TotalLLM: inv.Config.TotalLLMConcurrency,
|
||||
ProposalLLM: inv.Config.EffectiveProposalLLMConcurrency(),
|
||||
ValidationLLM: inv.Config.EffectiveValidationLLMConcurrency(),
|
||||
},
|
||||
ProposalLLMClient: proposalLLMClient,
|
||||
ProposalLLMScheduler: proposalScheduler,
|
||||
ProposalDiagnosticsDir: runDir.Path(),
|
||||
@@ -523,6 +528,12 @@ func runProcess(args []string, stdout, stderr io.Writer) int {
|
||||
completedAt := time.Now().UTC()
|
||||
|
||||
if runErr != nil {
|
||||
if runDir != nil && runOutput != nil {
|
||||
if runOutput.Utilization != nil {
|
||||
_ = runDir.WriteJSONArtifact("utilization-diagnostics.json", runOutput.Utilization)
|
||||
}
|
||||
_ = runDir.WriteJSONArtifact("correction-ledger.json", buildCorrectionLedger(runDir.Path(), runOutput))
|
||||
}
|
||||
errorPhase, errorMessage := extractErrorPhase(runErr)
|
||||
report := buildProcessReport("failed", inv, runDir, startedAt, completedAt, errorMessage, errorPhase, nil, nil, runOutput)
|
||||
|
||||
@@ -546,6 +557,13 @@ func runProcess(args []string, stdout, stderr io.Writer) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
if runDir != nil && runOutput != nil {
|
||||
if runOutput.Utilization != nil {
|
||||
_ = runDir.WriteJSONArtifact("utilization-diagnostics.json", runOutput.Utilization)
|
||||
}
|
||||
_ = runDir.WriteJSONArtifact("correction-ledger.json", buildCorrectionLedger(runDir.Path(), runOutput))
|
||||
}
|
||||
|
||||
report := buildProcessReport("success", inv, runDir, startedAt, completedAt, "", "", normSummary, chunkSummary, runOutput)
|
||||
|
||||
if strings.TrimSpace(inv.ReportJSONPath) != "" {
|
||||
@@ -732,6 +750,8 @@ func buildProcessReport(status string, inv processInvocation, runDir *diagnostic
|
||||
NormalizedTranscriptPath: filepath.Join(diagnosticsDir, "normalized-transcript.json"),
|
||||
NormalizationSummaryPath: filepath.Join(diagnosticsDir, "normalization-summary.json"),
|
||||
ChunkingSummaryPath: filepath.Join(diagnosticsDir, "chunking-summary.json"),
|
||||
UtilizationSummaryPath: filepath.Join(diagnosticsDir, "utilization-diagnostics.json"),
|
||||
CorrectionLedgerPath: filepath.Join(diagnosticsDir, "correction-ledger.json"),
|
||||
InvocationMetadataPath: filepath.Join(diagnosticsDir, "invocation.json"),
|
||||
RedactedEffectiveConfigPath: filepath.Join(diagnosticsDir, "effective-config.json"),
|
||||
}
|
||||
|
||||
@@ -1239,6 +1239,9 @@ func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) {
|
||||
if report.Diagnostics.DirectoryPath == "" || report.Diagnostics.ErrorLogPath == "" {
|
||||
t.Fatalf("expected diagnostics directory and error log references in failed report: %+v", report.Diagnostics)
|
||||
}
|
||||
if report.Diagnostics.UtilizationSummaryPath == "" || report.Diagnostics.CorrectionLedgerPath == "" {
|
||||
t.Fatalf("expected review artifact paths in failed report diagnostics: %+v", report.Diagnostics)
|
||||
}
|
||||
if _, err := os.Stat(report.Diagnostics.ErrorLogPath); err != nil {
|
||||
t.Fatalf("expected error log file at reported path: %v", err)
|
||||
}
|
||||
@@ -1312,6 +1315,153 @@ func TestRunProcessReportJSONAndRunDirReportShareDiagnosticsMetadata(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProcessWritesUtilizationAndCorrectionLedgerArtifacts(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
workDir := t.TempDir()
|
||||
reportPath := filepath.Join(t.TempDir(), "report.json")
|
||||
exitCode := Run([]string{
|
||||
"process",
|
||||
fixturePath("tiny_transcript.json"),
|
||||
"--glossary",
|
||||
fixturePath("tiny_glossary.yaml"),
|
||||
"--modules",
|
||||
"grammar",
|
||||
"--work-dir",
|
||||
workDir,
|
||||
"--work-dir-retention",
|
||||
"always",
|
||||
"--report-json",
|
||||
reportPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
report := readProcessReport(t, reportPath)
|
||||
if report.Diagnostics == nil {
|
||||
t.Fatalf("expected diagnostics metadata")
|
||||
}
|
||||
if report.Diagnostics.UtilizationSummaryPath == "" || report.Diagnostics.CorrectionLedgerPath == "" {
|
||||
t.Fatalf("expected utilization and correction ledger paths, got %+v", report.Diagnostics)
|
||||
}
|
||||
if _, err := os.Stat(report.Diagnostics.UtilizationSummaryPath); err != nil {
|
||||
t.Fatalf("expected utilization diagnostics artifact: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(report.Diagnostics.CorrectionLedgerPath); err != nil {
|
||||
t.Fatalf("expected correction ledger artifact: %v", err)
|
||||
}
|
||||
|
||||
var utilization struct {
|
||||
EffectiveConcurrency struct {
|
||||
TotalLLM int `json:"total_llm"`
|
||||
ProposalLLM int `json:"proposal_llm"`
|
||||
ValidationLLM int `json:"validation_llm"`
|
||||
} `json:"effective_concurrency"`
|
||||
Modules []struct {
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
} `json:"modules"`
|
||||
LLMCalls struct {
|
||||
TotalProposalCalls int `json:"total_proposal_calls"`
|
||||
TotalValidationCalls int `json:"total_validation_calls"`
|
||||
} `json:"llm_calls"`
|
||||
}
|
||||
if err := json.Unmarshal(readFile(t, report.Diagnostics.UtilizationSummaryPath), &utilization); err != nil {
|
||||
t.Fatalf("unmarshal utilization artifact: %v", err)
|
||||
}
|
||||
if utilization.EffectiveConcurrency.TotalLLM <= 0 || utilization.EffectiveConcurrency.ProposalLLM <= 0 || utilization.EffectiveConcurrency.ValidationLLM <= 0 {
|
||||
t.Fatalf("expected positive effective concurrency limits, got %+v", utilization.EffectiveConcurrency)
|
||||
}
|
||||
if len(utilization.Modules) == 0 || utilization.Modules[0].ModuleInstance == "" {
|
||||
t.Fatalf("expected module-level timing summaries, got %+v", utilization.Modules)
|
||||
}
|
||||
if utilization.LLMCalls.TotalProposalCalls == 0 {
|
||||
t.Fatalf("expected proposal LLM calls to be recorded")
|
||||
}
|
||||
|
||||
var ledger []map[string]any
|
||||
if err := json.Unmarshal(readFile(t, report.Diagnostics.CorrectionLedgerPath), &ledger); err != nil {
|
||||
t.Fatalf("unmarshal correction ledger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProcessCorrectionLedgerKeepsValidatorRejectionDistinctFromSkip(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
workDir := t.TempDir()
|
||||
reportPath := filepath.Join(t.TempDir(), "report.json")
|
||||
proposalClient := &fakeStructuredLLMClient{
|
||||
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
||||
{
|
||||
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
||||
{
|
||||
TargetSegmentID: 1,
|
||||
OriginalText: "hello",
|
||||
CorrectedText: "hi",
|
||||
Confidence: 0.99,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
processProposalLLMClient = proposalClient
|
||||
processValidationLLMClient = &fakeStructuredLLMClient{
|
||||
validationResponses: []validators.LLMValidationResponse{
|
||||
{
|
||||
Validations: []validators.LLMValidationDecision{
|
||||
{CorrectionIndex: 0, Approved: false, Confidence: 0.1, Reason: "reject"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
processProposalLLMClient = nil
|
||||
processValidationLLMClient = nil
|
||||
})
|
||||
|
||||
exitCode := Run([]string{
|
||||
"process",
|
||||
fixturePath("tiny_transcript.json"),
|
||||
"--glossary",
|
||||
fixturePath("tiny_glossary.yaml"),
|
||||
"--modules",
|
||||
"grammar",
|
||||
"--work-dir",
|
||||
workDir,
|
||||
"--work-dir-retention",
|
||||
"always",
|
||||
"--report-json",
|
||||
reportPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
report := readProcessReport(t, reportPath)
|
||||
var ledger []struct {
|
||||
Disposition string `json:"disposition"`
|
||||
DispositionReasonCode string `json:"disposition_reason_code"`
|
||||
ModuleInstance string `json:"module_instance"`
|
||||
}
|
||||
if err := json.Unmarshal(readFile(t, report.Diagnostics.CorrectionLedgerPath), &ledger); err != nil {
|
||||
t.Fatalf("unmarshal correction ledger: %v", err)
|
||||
}
|
||||
foundRejected := false
|
||||
for _, entry := range ledger {
|
||||
if entry.ModuleInstance == "grammar" && entry.Disposition == "rejected" {
|
||||
foundRejected = true
|
||||
if entry.DispositionReasonCode == "" {
|
||||
t.Fatalf("expected rejection reason code in ledger entry")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundRejected {
|
||||
t.Fatalf("expected rejected ledger entry, got %+v", ledger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProcessReportJSONIncludesConfigVersionWhenConfigFileUsed(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -2547,6 +2697,49 @@ func TestRunProcessDefaultFullPipelineAggregatesSkipsAndAutoRetentionKeepsRunDir
|
||||
if runDirReport.ModulesSummary == nil || runDirReport.ModulesSummary.TotalSkippedChanges != 2 {
|
||||
t.Fatalf("expected skipped totals in retained run-dir report, got %+v", runDirReport.ModulesSummary)
|
||||
}
|
||||
if runDirReport.Diagnostics == nil {
|
||||
t.Fatalf("expected diagnostics metadata in run-dir report")
|
||||
}
|
||||
var utilization struct {
|
||||
RunTiming struct {
|
||||
SchedulerQueueWaitMS int64 `json:"scheduler_queue_wait_ms"`
|
||||
LLMExecutionTimeMS int64 `json:"llm_execution_time_ms"`
|
||||
DeterministicValidationMS int64 `json:"deterministic_validation_time_ms"`
|
||||
} `json:"run_timing"`
|
||||
Validators []struct {
|
||||
ValidatorKey string `json:"validator_key"`
|
||||
} `json:"validators"`
|
||||
}
|
||||
if err := json.Unmarshal(readFile(t, runDirReport.Diagnostics.UtilizationSummaryPath), &utilization); err != nil {
|
||||
t.Fatalf("unmarshal utilization diagnostics: %v", err)
|
||||
}
|
||||
if utilization.RunTiming.LLMExecutionTimeMS < 0 || utilization.RunTiming.SchedulerQueueWaitMS < 0 || utilization.RunTiming.DeterministicValidationMS < 0 {
|
||||
t.Fatalf("expected non-negative run timing fields, got %+v", utilization.RunTiming)
|
||||
}
|
||||
if len(utilization.Validators) == 0 {
|
||||
t.Fatalf("expected per-validator timing summaries")
|
||||
}
|
||||
|
||||
var ledger []struct {
|
||||
Disposition string `json:"disposition"`
|
||||
}
|
||||
if err := json.Unmarshal(readFile(t, runDirReport.Diagnostics.CorrectionLedgerPath), &ledger); err != nil {
|
||||
t.Fatalf("unmarshal correction ledger: %v", err)
|
||||
}
|
||||
foundSkipped := false
|
||||
foundRejected := false
|
||||
for _, entry := range ledger {
|
||||
if entry.Disposition == "skipped" {
|
||||
foundSkipped = true
|
||||
}
|
||||
if entry.Disposition == "rejected" {
|
||||
foundRejected = true
|
||||
}
|
||||
}
|
||||
if !foundSkipped || !foundRejected {
|
||||
t.Fatalf("expected skipped and rejected ledger dispositions, got %+v", ledger)
|
||||
}
|
||||
|
||||
diagFiles, globErr := filepath.Glob(filepath.Join(runDir, "*", "*response-payload.json"))
|
||||
if globErr != nil {
|
||||
t.Fatalf("glob diagnostics payloads: %v", globErr)
|
||||
|
||||
@@ -22,18 +22,22 @@ const (
|
||||
// ConfigureSubprocessTestHooksFromEnv enables deterministic test-only hooks for
|
||||
// subprocess integration tests that run through the Go test binary helper path.
|
||||
func ConfigureSubprocessTestHooksFromEnv() {
|
||||
mode := strings.TrimSpace(os.Getenv(subprocessTestLLMModeEnv))
|
||||
timeoutMSRaw := strings.TrimSpace(os.Getenv(subprocessTestRunTimeoutMSEnv))
|
||||
// Only activate in explicit subprocess test mode.
|
||||
if mode == "" && timeoutMSRaw == "" {
|
||||
return
|
||||
}
|
||||
if !shouldUseNoOpLLMClientForTests() {
|
||||
return
|
||||
}
|
||||
|
||||
mode := strings.TrimSpace(os.Getenv(subprocessTestLLMModeEnv))
|
||||
if mode != "" {
|
||||
client := &subprocessTestLLMClient{mode: mode}
|
||||
processProposalLLMClient = client
|
||||
processValidationLLMClient = client
|
||||
}
|
||||
|
||||
timeoutMSRaw := strings.TrimSpace(os.Getenv(subprocessTestRunTimeoutMSEnv))
|
||||
if timeoutMSRaw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user