4355 lines
154 KiB
Go
4355 lines
154 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
|
"gitea.maximumdirect.net/eric/audita/internal/core/normalization"
|
|
"gitea.maximumdirect.net/eric/audita/internal/core/reporting"
|
|
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/llm"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/proposal_generation"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/proposals"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/runner"
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/validators"
|
|
"gitea.maximumdirect.net/eric/audita/internal/testsupport"
|
|
)
|
|
|
|
func TestRunRootHelp(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"--help"}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
if !strings.Contains(stdout.String(), "audita <command> [options]") {
|
|
t.Fatalf("expected root usage in stdout, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessHelpListsExpectedFlags(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"process", "--help"}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
|
|
for _, expectedFlag := range []string{
|
|
"--config",
|
|
"--glossary",
|
|
"--output",
|
|
"--output-schema",
|
|
"--report-json",
|
|
"--modules",
|
|
"--llm-api-key",
|
|
"--validation-llm-api-key",
|
|
"--model",
|
|
"--validation-model",
|
|
"--base-url",
|
|
"--validation-base-url",
|
|
"--llm-timeout-seconds",
|
|
"--total-llm-concurrency",
|
|
"--proposal-llm-concurrency",
|
|
"--llm-concurrency",
|
|
"--validation-llm-timeout-seconds",
|
|
"--validation-max-prompt-tokens",
|
|
"--target-sections",
|
|
"--max-retries",
|
|
"--validation-max-retries",
|
|
"--validation-llm-concurrency",
|
|
"--max-section-tokens",
|
|
"--min-section-tokens",
|
|
"--glossary-confidence-threshold",
|
|
"--grammar-confidence-threshold",
|
|
"--homophones-confidence-threshold",
|
|
"--spoken-word-confidence-threshold",
|
|
"--normalize-max-segment-gap",
|
|
"--normalize-ellipsis-gap",
|
|
"--normalize-max-segment-duration",
|
|
"--normalize-max-segment-tokens",
|
|
"--transcript-description",
|
|
"--work-dir",
|
|
"--work-dir-retention",
|
|
} {
|
|
if !strings.Contains(stdout.String(), expectedFlag) {
|
|
t.Fatalf("expected process help to include %q", expectedFlag)
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunConfigValidateSuccess(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\n")
|
|
|
|
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "config is valid") {
|
|
t.Fatalf("expected success message, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunConfigValidateInvalidVersion(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 999\n")
|
|
|
|
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected failure for unsupported config version")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "unsupported config version") {
|
|
t.Fatalf("expected version error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunConfigValidateUnknownField(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\nunknown_field: true\n")
|
|
|
|
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected failure for unknown field")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "field unknown_field not found") {
|
|
t.Fatalf("expected unknown-field error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunConfigValidateUnsupportedModuleKey(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\npipeline:\n modules: [made_up]\n")
|
|
|
|
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected failure for unsupported module key")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "unsupported module key") {
|
|
t.Fatalf("expected unsupported module key error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunConfigPrintEffectiveOutputsRedactedJSON(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
secret := "super-secret-api-key"
|
|
t.Setenv("AUDITA_LLM_API_KEY", secret)
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\n")
|
|
|
|
exitCode := Run([]string{"config", "print-effective", "--config", cfgPath}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), secret) {
|
|
t.Fatalf("print-effective leaked secret")
|
|
}
|
|
|
|
var out map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
|
t.Fatalf("expected valid JSON output, got error: %v output=%q", err, stdout.String())
|
|
}
|
|
primary, ok := out["PrimaryLLM"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected PrimaryLLM object, got %#v", out["PrimaryLLM"])
|
|
}
|
|
if got := primary["APIKey"]; got != "[REDACTED]" {
|
|
t.Fatalf("expected redacted API key, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestRunConfigPrintEffectiveAppliesFileThenEnvironment(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\nllm:\n proposal:\n model: file-model\n")
|
|
t.Setenv("AUDITA_MODEL", "env-model")
|
|
|
|
exitCode := Run([]string{"config", "print-effective", "--config", cfgPath}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
var out struct {
|
|
PrimaryLLM struct {
|
|
Model string `json:"Model"`
|
|
} `json:"PrimaryLLM"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
|
t.Fatalf("expected valid JSON output, got error: %v output=%q", err, stdout.String())
|
|
}
|
|
if out.PrimaryLLM.Model != "env-model" {
|
|
t.Fatalf("expected env model override in print-effective output, got %q", out.PrimaryLLM.Model)
|
|
}
|
|
}
|
|
|
|
func TestRunConfigValidateIgnoresEnvironmentOverrides(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\n")
|
|
t.Setenv("AUDITA_MODULES", "made_up")
|
|
|
|
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success because config validate is file-only, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "config is valid") {
|
|
t.Fatalf("expected success message, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunConfigCommandDoesNotRequireTranscriptOrGlossary(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\n")
|
|
|
|
exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success without transcript/glossary args, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessConfigFlagMissingFileFails(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--config",
|
|
filepath.Join(t.TempDir(), "missing.yaml"),
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected failure for missing --config file")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "config file not found") {
|
|
t.Fatalf("expected missing config file error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessAUDITAConfigMissingFileFails(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
t.Setenv("AUDITA_CONFIG", filepath.Join(t.TempDir(), "missing-env.yaml"))
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected failure for missing AUDITA_CONFIG file")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "config file not found") {
|
|
t.Fatalf("expected missing config file error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessConfigFileAffectsRuntimeAndInvocationMetadata(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
cfgPath := writeFile(t, "config.yml", `
|
|
version: 1
|
|
pipeline:
|
|
modules: [grammar]
|
|
context:
|
|
description: "config file transcript context"
|
|
diagnostics:
|
|
work_dir: `+workDir+`
|
|
retention: always
|
|
`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--config",
|
|
cfgPath,
|
|
"--output",
|
|
outputPath,
|
|
"--report-json",
|
|
reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
configBytes := readFile(t, filepath.Join(runPath, "effective-config.json"))
|
|
var effectiveConfig struct {
|
|
Modules []string `json:"Modules"`
|
|
TranscriptDescription string `json:"TranscriptDescription"`
|
|
}
|
|
if err := json.Unmarshal(configBytes, &effectiveConfig); err != nil {
|
|
t.Fatalf("failed to parse effective config metadata: %v", err)
|
|
}
|
|
if strings.Join(effectiveConfig.Modules, ",") != "grammar" {
|
|
t.Fatalf("expected grammar module from config file, got %#v", effectiveConfig.Modules)
|
|
}
|
|
if effectiveConfig.TranscriptDescription != "config file transcript context" {
|
|
t.Fatalf("unexpected transcript description from config file: %q", effectiveConfig.TranscriptDescription)
|
|
}
|
|
|
|
invocationBytes := readFile(t, filepath.Join(runPath, "invocation.json"))
|
|
var invocation struct {
|
|
ConfigPath string `json:"config_path"`
|
|
ConfigSource string `json:"config_source"`
|
|
}
|
|
if err := json.Unmarshal(invocationBytes, &invocation); err != nil {
|
|
t.Fatalf("failed to parse invocation metadata: %v", err)
|
|
}
|
|
if invocation.ConfigPath != cfgPath {
|
|
t.Fatalf("unexpected invocation config_path: %q", invocation.ConfigPath)
|
|
}
|
|
if invocation.ConfigSource != "flag" {
|
|
t.Fatalf("unexpected invocation config_source: %q", invocation.ConfigSource)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessEnvOverridesConfigFile(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.PrimaryLLM.Model != "env-model" {
|
|
t.Fatalf("expected env model override, got %q", req.Config.PrimaryLLM.Model)
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil },
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
t.Setenv("AUDITA_MODEL", "env-model")
|
|
cfgPath := writeFile(t, "config.yml", `
|
|
version: 1
|
|
pipeline:
|
|
modules: [grammar]
|
|
llm:
|
|
proposal:
|
|
model: file-model
|
|
`)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--config", cfgPath,
|
|
"--modules", "grammar",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessCLIOverridesEnvAndConfigFile(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.PrimaryLLM.Model != "cli-model" {
|
|
t.Fatalf("expected CLI model override, got %q", req.Config.PrimaryLLM.Model)
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil },
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
t.Setenv("AUDITA_MODEL", "env-model")
|
|
cfgPath := writeFile(t, "config.yml", `
|
|
version: 1
|
|
pipeline:
|
|
modules: [grammar]
|
|
llm:
|
|
proposal:
|
|
model: file-model
|
|
`)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--config", cfgPath,
|
|
"--modules", "grammar",
|
|
"--model", "cli-model",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessConfigAPIKeyEnvResolvesAndRedactsEffectiveConfig(t *testing.T) {
|
|
secret := "secret-from-config-env"
|
|
t.Setenv("PROPOSAL_KEY_FROM_CONFIG", secret)
|
|
|
|
workDir := t.TempDir()
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
cfgPath := writeFile(t, "config.yml", `
|
|
version: 1
|
|
pipeline:
|
|
modules: [grammar]
|
|
llm:
|
|
proposal:
|
|
api_key_env: PROPOSAL_KEY_FROM_CONFIG
|
|
diagnostics:
|
|
work_dir: `+workDir+`
|
|
retention: always
|
|
`)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--config", cfgPath,
|
|
"--output", outputPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
configBytes := readFile(t, filepath.Join(runPath, "effective-config.json"))
|
|
if strings.Contains(string(configBytes), secret) {
|
|
t.Fatalf("effective config artifact leaked API key from api_key_env")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessTranscriptDescriptionCLIOverridesConfigFileContextDescription(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.TranscriptDescription != "cli transcript description" {
|
|
t.Fatalf("expected CLI transcript description to override config file, got %q", req.Config.TranscriptDescription)
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil },
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
cfgPath := writeFile(t, "config.yml", `
|
|
version: 1
|
|
pipeline:
|
|
modules: [grammar]
|
|
context:
|
|
description: "file transcript description"
|
|
`)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--config", cfgPath,
|
|
"--modules", "grammar",
|
|
"--transcript-description", "cli transcript description",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessMissingTranscriptPath(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"process", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "expected exactly 1 transcript JSON path argument") {
|
|
t.Fatalf("expected missing transcript error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessMissingGlossary(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"process", fixturePath("tiny_transcript.json")}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "--glossary is required") {
|
|
t.Fatalf("expected missing glossary error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessAcceptsBothTranscriptTopLevelForms(t *testing.T) {
|
|
testCases := []string{
|
|
schemaFixturePath("transcript_bare_array.json"),
|
|
schemaFixturePath("transcript_object.json"),
|
|
}
|
|
|
|
for _, transcriptPath := range testCases {
|
|
t.Run(filepath.Base(transcriptPath), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(stdout.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("expected normalized transcript JSON output, got %v", err)
|
|
}
|
|
if len(parsed.Segments) == 0 {
|
|
t.Fatalf("expected at least one output segment")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunProcessOutputToFileLeavesStdoutEmpty(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := fixturePath("tiny_transcript.json")
|
|
glossaryPath := fixturePath("tiny_glossary.yaml")
|
|
outputPath := filepath.Join(t.TempDir(), "normalized.json")
|
|
|
|
exitCode := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output", outputPath}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
|
}
|
|
|
|
outputBytes := readFile(t, outputPath)
|
|
if !json.Valid(outputBytes) {
|
|
t.Fatalf("expected valid JSON output file, got %q", string(outputBytes))
|
|
}
|
|
}
|
|
|
|
func TestRunProcessInvalidTranscriptSchema(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"process", schemaFixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code for invalid transcript schema")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "transcript_schema") {
|
|
t.Fatalf("expected transcript schema phase in stderr, got %q", stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "speaker") {
|
|
t.Fatalf("expected speaker validation details, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessInvalidGlossarySchema(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"process", fixturePath("tiny_transcript.json"), "--glossary", schemaFixturePath("glossary_missing_fields.yaml")}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code for invalid glossary schema")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "glossary_schema") {
|
|
t.Fatalf("expected glossary schema phase in stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessCLIOverridesEnvironment(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"},
|
|
{"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"}
|
|
]`)
|
|
|
|
t.Setenv("AUDITA_NORMALIZE_MAX_SEGMENT_GAP", "0")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--normalize-max-segment-gap",
|
|
"2.0",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
transcript, err := schema.ParseTranscriptJSON(stdout.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("expected valid transcript JSON output: %v", err)
|
|
}
|
|
if len(transcript.Segments) != 1 {
|
|
t.Fatalf("expected merged output from CLI override, got %d segments", len(transcript.Segments))
|
|
}
|
|
}
|
|
|
|
func TestRunProcessTranscriptDescriptionDefaultEmpty(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.TranscriptDescription != "" {
|
|
t.Fatalf("expected default transcript description to be empty, got %q", req.Config.TranscriptDescription)
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil },
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "grammar",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessTranscriptDescriptionCLIOverrideAndTrim(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.TranscriptDescription != "speaker background context" {
|
|
t.Fatalf("expected trimmed transcript description, got %q", req.Config.TranscriptDescription)
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, nil },
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "grammar",
|
|
"--transcript-description", " speaker background context ",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessRejectsOverlyLongTranscriptDescription(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
tooLong := strings.Repeat("a", config.DefaultTranscriptDescriptionMaxChars+1)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--transcript-description", tooLong,
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code for overly long transcript description")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "transcript description must be 500 characters or fewer") {
|
|
t.Fatalf("expected transcript description length validation error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessRejectsValidationConcurrencyAboveTotalConcurrency(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--total-llm-concurrency",
|
|
"1",
|
|
"--validation-llm-concurrency",
|
|
"2",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code for invalid llm concurrency combination")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "invalid CLI configuration") {
|
|
t.Fatalf("expected invalid CLI configuration error, got %q", stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "validation llm concurrency must be less than or equal to total llm concurrency") {
|
|
t.Fatalf("expected validation/total concurrency error details, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessTotalLLMConcurrencyDrivesEffectiveValidationConcurrencyWhenUnset(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.TotalLLMConcurrency != 4 {
|
|
t.Fatalf("expected total llm concurrency 4, got %d", req.Config.TotalLLMConcurrency)
|
|
}
|
|
if req.Config.ProposalLLMConcurrency != 4 {
|
|
t.Fatalf("expected proposal llm concurrency to inherit total 4, got %d", req.Config.ProposalLLMConcurrency)
|
|
}
|
|
if req.Config.ValidationLLMConcurrency != nil {
|
|
t.Fatalf("expected validation concurrency unset, got %#v", req.Config.ValidationLLMConcurrency)
|
|
}
|
|
if req.Config.EffectiveValidationLLMConcurrency() != 4 {
|
|
t.Fatalf("expected inherited effective validation concurrency 4, got %d", req.Config.EffectiveValidationLLMConcurrency())
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--modules",
|
|
"grammar",
|
|
"--total-llm-concurrency",
|
|
"4",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessRejectsProposalConcurrencyAboveTotalConcurrency(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--total-llm-concurrency",
|
|
"1",
|
|
"--proposal-llm-concurrency",
|
|
"2",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code for invalid llm concurrency combination")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "invalid CLI configuration") {
|
|
t.Fatalf("expected invalid CLI configuration error, got %q", stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "proposal llm concurrency must be less than or equal to total llm concurrency") {
|
|
t.Fatalf("expected proposal/total concurrency error details, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessLegacyLLMConcurrencyAliasSetsTotalAndProposal(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.TotalLLMConcurrency != 3 {
|
|
t.Fatalf("expected total llm concurrency 3, got %d", req.Config.TotalLLMConcurrency)
|
|
}
|
|
if req.Config.ProposalLLMConcurrency != 3 {
|
|
t.Fatalf("expected proposal llm concurrency inherited from alias, got %d", req.Config.ProposalLLMConcurrency)
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--modules",
|
|
"grammar",
|
|
"--llm-concurrency",
|
|
"3",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessLLMConcurrencyFlagsOverrideEnvironment(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.TotalLLMConcurrency != 4 {
|
|
t.Fatalf("expected cli total llm concurrency 4, got %d", req.Config.TotalLLMConcurrency)
|
|
}
|
|
if req.Config.ProposalLLMConcurrency != 3 {
|
|
t.Fatalf("expected cli proposal llm concurrency 3, got %d", req.Config.ProposalLLMConcurrency)
|
|
}
|
|
if req.Config.ValidationLLMConcurrency == nil || *req.Config.ValidationLLMConcurrency != 2 {
|
|
t.Fatalf("expected env validation llm concurrency 2, got %#v", req.Config.ValidationLLMConcurrency)
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
t.Setenv("AUDITA_TOTAL_LLM_CONCURRENCY", "2")
|
|
t.Setenv("AUDITA_PROPOSAL_LLM_CONCURRENCY", "2")
|
|
t.Setenv("AUDITA_VALIDATION_LLM_CONCURRENCY", "2")
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--modules",
|
|
"grammar",
|
|
"--total-llm-concurrency",
|
|
"4",
|
|
"--proposal-llm-concurrency",
|
|
"3",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessAcceptsLLMConcurrencyEnvironmentVariables(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{
|
|
key: "grammar",
|
|
policy: proposals.ReplacementPolicyRequireUnique,
|
|
validators: []contracts.Validator{
|
|
fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
if req.Config == nil {
|
|
t.Fatal("expected config in validation request")
|
|
}
|
|
if req.Config.TotalLLMConcurrency != 5 {
|
|
t.Fatalf("expected env total llm concurrency 5, got %d", req.Config.TotalLLMConcurrency)
|
|
}
|
|
if req.Config.ProposalLLMConcurrency != 3 {
|
|
t.Fatalf("expected env proposal llm concurrency 3, got %d", req.Config.ProposalLLMConcurrency)
|
|
}
|
|
if req.Config.ValidationLLMConcurrency == nil || *req.Config.ValidationLLMConcurrency != 2 {
|
|
t.Fatalf("expected env validation llm concurrency 2, got %#v", req.Config.ValidationLLMConcurrency)
|
|
}
|
|
return validators.Result{ValidatorName: "capture-config", Decisions: nil}, nil
|
|
}},
|
|
},
|
|
proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
t.Setenv("AUDITA_TOTAL_LLM_CONCURRENCY", "5")
|
|
t.Setenv("AUDITA_PROPOSAL_LLM_CONCURRENCY", "3")
|
|
t.Setenv("AUDITA_VALIDATION_LLM_CONCURRENCY", "2")
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--modules",
|
|
"grammar",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestComposeSchedulersEnforcesStricterSubcap(t *testing.T) {
|
|
global, err := llm.NewScheduler(3)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler(global): %v", err)
|
|
}
|
|
subcap, err := llm.NewScheduler(1)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler(subcap): %v", err)
|
|
}
|
|
composed := composeSchedulers(global, subcap)
|
|
if composed == nil {
|
|
t.Fatal("expected composed scheduler")
|
|
}
|
|
|
|
var inFlight int32
|
|
var maxInFlight int32
|
|
release := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 6; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
runErr := composed.Run(context.Background(), func(context.Context) error {
|
|
current := atomic.AddInt32(&inFlight, 1)
|
|
for {
|
|
prior := atomic.LoadInt32(&maxInFlight)
|
|
if current <= prior || atomic.CompareAndSwapInt32(&maxInFlight, prior, current) {
|
|
break
|
|
}
|
|
}
|
|
<-release
|
|
atomic.AddInt32(&inFlight, -1)
|
|
return nil
|
|
})
|
|
if runErr != nil {
|
|
t.Errorf("scheduler run error: %v", runErr)
|
|
}
|
|
}()
|
|
}
|
|
waitForAtomicAtLeast(t, &maxInFlight, 1)
|
|
close(release)
|
|
wg.Wait()
|
|
|
|
if maxInFlight > 1 {
|
|
t.Fatalf("expected stricter subcap to govern composed scheduler, got max in-flight %d", maxInFlight)
|
|
}
|
|
}
|
|
|
|
func TestComposedProposalAndValidationSchedulersShareGlobalTotalCap(t *testing.T) {
|
|
global, err := llm.NewScheduler(2)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler(global): %v", err)
|
|
}
|
|
proposalSubcap, err := llm.NewScheduler(2)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler(proposalSubcap): %v", err)
|
|
}
|
|
validationSubcap, err := llm.NewScheduler(2)
|
|
if err != nil {
|
|
t.Fatalf("NewScheduler(validationSubcap): %v", err)
|
|
}
|
|
proposalScheduler := composeSchedulers(global, proposalSubcap)
|
|
validationScheduler := composeSchedulers(global, validationSubcap)
|
|
|
|
var inFlight int32
|
|
var maxInFlight int32
|
|
release := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 12; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
scheduler := proposalScheduler
|
|
if idx%2 == 1 {
|
|
scheduler = validationScheduler
|
|
}
|
|
runErr := scheduler.Run(context.Background(), func(context.Context) error {
|
|
current := atomic.AddInt32(&inFlight, 1)
|
|
for {
|
|
prior := atomic.LoadInt32(&maxInFlight)
|
|
if current <= prior || atomic.CompareAndSwapInt32(&maxInFlight, prior, current) {
|
|
break
|
|
}
|
|
}
|
|
<-release
|
|
atomic.AddInt32(&inFlight, -1)
|
|
return nil
|
|
})
|
|
if runErr != nil {
|
|
t.Errorf("scheduler run error: %v", runErr)
|
|
}
|
|
}(i)
|
|
}
|
|
waitForAtomicAtLeast(t, &maxInFlight, 2)
|
|
close(release)
|
|
wg.Wait()
|
|
|
|
if maxInFlight > 2 {
|
|
t.Fatalf("expected combined proposal+validation concurrency <= global total cap (2), got %d", maxInFlight)
|
|
}
|
|
if maxInFlight < 2 {
|
|
t.Fatalf("expected observed combined concurrency of at least 2, got %d", maxInFlight)
|
|
}
|
|
}
|
|
|
|
func waitForAtomicAtLeast(t *testing.T, value *int32, want int32) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(300 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if atomic.LoadInt32(value) >= want {
|
|
return
|
|
}
|
|
runtime.Gosched()
|
|
}
|
|
t.Fatalf("timed out waiting for value >= %d (got %d)", want, atomic.LoadInt32(value))
|
|
}
|
|
|
|
func TestRunProcessReportJSONSuccessIncludesNormalizationSummary(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"},
|
|
{"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"}
|
|
]`)
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "normalized.json")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--output",
|
|
outputPath,
|
|
"--report-json",
|
|
reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "success" {
|
|
t.Fatalf("expected success status, got %q", report.Status)
|
|
}
|
|
if report.ReportMetadata.ReportSchemaName != reporting.DefaultProcessReportSchemaName ||
|
|
report.ReportMetadata.ReportSchemaVersion != reporting.DefaultProcessReportSchemaVersion {
|
|
t.Fatalf("unexpected report schema metadata: %+v", report.ReportMetadata)
|
|
}
|
|
if report.ReportMetadata.OutputSchema != "bare-segments" {
|
|
t.Fatalf("expected bare-segments output schema metadata, got %q", report.ReportMetadata.OutputSchema)
|
|
}
|
|
if report.ReportMetadata.ConfigVersion != nil {
|
|
t.Fatalf("expected nil config version without config file, got %v", *report.ReportMetadata.ConfigVersion)
|
|
}
|
|
if report.Operation != "process" {
|
|
t.Fatalf("expected operation process, got %q", report.Operation)
|
|
}
|
|
if report.InputSegmentCount == nil || *report.InputSegmentCount != 2 {
|
|
t.Fatalf("expected input segment count 2, got %v", report.InputSegmentCount)
|
|
}
|
|
if report.NormalizedSegmentCount == nil || *report.NormalizedSegmentCount != 1 {
|
|
t.Fatalf("expected normalized segment count 1, got %v", report.NormalizedSegmentCount)
|
|
}
|
|
if report.NormalizationMerges == nil || *report.NormalizationMerges != 1 {
|
|
t.Fatalf("expected 1 merge, got %v", report.NormalizationMerges)
|
|
}
|
|
if report.Diagnostics == nil {
|
|
t.Fatalf("expected diagnostics metadata in success report")
|
|
}
|
|
if report.Diagnostics.DirectoryPath == "" {
|
|
t.Fatalf("expected diagnostics directory path in success report")
|
|
}
|
|
if report.Diagnostics.SourceTranscriptPath == "" ||
|
|
report.Diagnostics.ParsedSourceTranscriptPath == "" ||
|
|
report.Diagnostics.NormalizedTranscriptPath == "" ||
|
|
report.Diagnostics.NormalizationSummaryPath == "" ||
|
|
report.Diagnostics.ChunkingSummaryPath == "" ||
|
|
report.Diagnostics.InvocationMetadataPath == "" ||
|
|
report.Diagnostics.RedactedEffectiveConfigPath == "" {
|
|
t.Fatalf("expected populated diagnostics artifact references in success report: %+v", report.Diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
workDir := t.TempDir()
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("malformed_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--report-json",
|
|
reportPath,
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "failed" {
|
|
t.Fatalf("expected failed status, got %q", report.Status)
|
|
}
|
|
if report.ErrorPhase != "transcript_schema" {
|
|
t.Fatalf("expected transcript_schema error phase, got %q", report.ErrorPhase)
|
|
}
|
|
if !strings.Contains(report.ErrorMessage, "not valid JSON") {
|
|
t.Fatalf("expected parse error message, got %q", report.ErrorMessage)
|
|
}
|
|
if report.Diagnostics == nil {
|
|
t.Fatalf("expected diagnostics metadata in failed report")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessReportJSONDoesNotLeakAPIKeys(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
primaryKey := "top-secret-primary"
|
|
validationKey := "top-secret-validation"
|
|
t.Setenv("AUDITA_LLM_API_KEY", primaryKey)
|
|
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", validationKey)
|
|
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--report-json",
|
|
reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
reportBytes := readFile(t, reportPath)
|
|
if strings.Contains(string(reportBytes), primaryKey) || strings.Contains(string(reportBytes), validationKey) {
|
|
t.Fatalf("report leaked API key material: %s", string(reportBytes))
|
|
}
|
|
}
|
|
|
|
func TestRunProcessReportJSONAndRunDirReportShareDiagnosticsMetadata(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"),
|
|
"--report-json",
|
|
reportPath,
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
externalReport := readProcessReport(t, reportPath)
|
|
runPath := onlyRunDir(t, workDir)
|
|
runDirReport := readProcessReport(t, filepath.Join(runPath, "report.json"))
|
|
|
|
if externalReport.Diagnostics == nil || runDirReport.Diagnostics == nil {
|
|
t.Fatalf("expected diagnostics metadata in both reports")
|
|
}
|
|
if externalReport.ReportMetadata != runDirReport.ReportMetadata {
|
|
t.Fatalf("expected same report metadata in --report-json and run-dir report\nexternal=%+v\nrun-dir=%+v",
|
|
externalReport.ReportMetadata, runDirReport.ReportMetadata)
|
|
}
|
|
if *externalReport.Diagnostics != *runDirReport.Diagnostics {
|
|
t.Fatalf("expected same diagnostics metadata in --report-json and run-dir report\nexternal=%+v\nrun-dir=%+v",
|
|
*externalReport.Diagnostics, *runDirReport.Diagnostics)
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\noutput:\n schema: audita-v1\n")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--config",
|
|
cfgPath,
|
|
"--report-json",
|
|
reportPath,
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.ReportMetadata.ConfigVersion == nil || *report.ReportMetadata.ConfigVersion != 1 {
|
|
t.Fatalf("expected config version 1 in report metadata, got %+v", report.ReportMetadata)
|
|
}
|
|
if report.ReportMetadata.OutputSchema != "audita-v1" {
|
|
t.Fatalf("expected output schema from config in report metadata, got %q", report.ReportMetadata.OutputSchema)
|
|
}
|
|
|
|
runDirReport := readProcessReport(t, filepath.Join(onlyRunDir(t, workDir), "report.json"))
|
|
if runDirReport.ReportMetadata.ReportSchemaName != report.ReportMetadata.ReportSchemaName ||
|
|
runDirReport.ReportMetadata.ReportSchemaVersion != report.ReportMetadata.ReportSchemaVersion ||
|
|
runDirReport.ReportMetadata.OutputSchema != report.ReportMetadata.OutputSchema {
|
|
t.Fatalf("expected same report metadata in run-dir report, got external=%+v run-dir=%+v",
|
|
report.ReportMetadata, runDirReport.ReportMetadata)
|
|
}
|
|
if runDirReport.ReportMetadata.ConfigVersion == nil ||
|
|
report.ReportMetadata.ConfigVersion == nil ||
|
|
*runDirReport.ReportMetadata.ConfigVersion != *report.ReportMetadata.ConfigVersion {
|
|
t.Fatalf("expected same config version in run-dir report metadata, got external=%+v run-dir=%+v",
|
|
report.ReportMetadata, runDirReport.ReportMetadata)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessWritesNormalizationDiagnosticsArtifacts(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
workDir := t.TempDir()
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"},
|
|
{"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
for _, artifact := range []string{
|
|
"source-transcript.json",
|
|
"source-transcript-parsed.json",
|
|
"normalized-transcript.json",
|
|
"normalization-summary.json",
|
|
"report.json",
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(runPath, artifact)); err != nil {
|
|
t.Fatalf("expected artifact %s: %v", artifact, err)
|
|
}
|
|
}
|
|
|
|
summaryBytes := readFile(t, filepath.Join(runPath, "normalization-summary.json"))
|
|
var summary normalization.NormalizationSummary
|
|
if err := json.Unmarshal(summaryBytes, &summary); err != nil {
|
|
t.Fatalf("failed to parse normalization summary: %v", err)
|
|
}
|
|
if summary.OutputSegmentCount != 1 {
|
|
t.Fatalf("expected merged output in normalization summary, got %d", summary.OutputSegmentCount)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessFailedRunRetainedWithNeverRetention(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
workDir := t.TempDir()
|
|
exitCode := Run([]string{
|
|
"process",
|
|
schemaFixturePath("transcript_empty_speaker.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"never",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil {
|
|
t.Fatalf("expected error.log in retained failed run: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessSuccessfulRunAutoRetentionRemovesRunDir(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"),
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"auto",
|
|
"--report-json",
|
|
reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
entries, err := os.ReadDir(workDir)
|
|
if err != nil {
|
|
t.Fatalf("failed to read work dir: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("expected auto retention to remove successful clean run dir, found %d entries", len(entries))
|
|
}
|
|
|
|
// Explicit report path must still exist even if run dir is removed.
|
|
if _, err := os.Stat(reportPath); err != nil {
|
|
t.Fatalf("expected --report-json output to survive run-dir removal: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessSuccessfulRunAlwaysRetentionKeepsRunDir(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
workDir := t.TempDir()
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(runPath); err != nil {
|
|
t.Fatalf("expected run dir retained under always: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessFailedRunRetainedWithAutoRetention(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
workDir := t.TempDir()
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
schemaFixturePath("transcript_empty_speaker.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"auto",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil {
|
|
t.Fatalf("expected error.log in retained failed run under auto: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessReportJSONIncludesChunkingSummary(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello world this is a test"},
|
|
{"id":2,"speaker":"Bob","start":2.0,"end":3.0,"text":"Another segment here with more text"},
|
|
{"id":3,"speaker":"Alice","start":4.0,"end":5.0,"text":"Final segment text"}
|
|
]`)
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "normalized.json")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--output",
|
|
outputPath,
|
|
"--report-json",
|
|
reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Chunking == nil {
|
|
t.Fatalf("expected chunking summary in report")
|
|
}
|
|
if report.Chunking.ChunkCount == 0 {
|
|
t.Errorf("expected non-zero chunk count")
|
|
}
|
|
if report.Chunking.MaxSectionTokens == 0 {
|
|
t.Errorf("expected max_section_tokens in report")
|
|
}
|
|
if report.Phase != "default_pipeline" {
|
|
t.Errorf("expected phase 'default_pipeline', got %q", report.Phase)
|
|
}
|
|
}
|
|
|
|
type fakeModuleFactory struct {
|
|
modules map[string]contracts.TranscriptModule
|
|
}
|
|
|
|
func (f fakeModuleFactory) ModuleForSpec(spec contracts.ModuleRunSpec) (contracts.TranscriptModule, error) {
|
|
m, ok := f.modules[spec.InstanceName]
|
|
if !ok {
|
|
return nil, errors.New("module not registered")
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
type fakeModule struct {
|
|
key string
|
|
policy proposals.ReplacementPolicy
|
|
validators []contracts.Validator
|
|
proposeF func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error)
|
|
}
|
|
|
|
func (m fakeModule) Key() string { return m.key }
|
|
func (m fakeModule) ReplacementPolicy() proposals.ReplacementPolicy { return m.policy }
|
|
func (m fakeModule) Validators() []contracts.Validator { return m.validators }
|
|
func (m fakeModule) Propose(ctx context.Context, req contracts.ProposalRequest) (contracts.ProposalResult, error) {
|
|
if m.proposeF == nil {
|
|
return contracts.ProposalResult{}, nil
|
|
}
|
|
proposalsOut, err := m.proposeF(req)
|
|
return contracts.ProposalResult{Proposals: proposalsOut}, err
|
|
}
|
|
|
|
type fakeValidator struct {
|
|
name string
|
|
validateF func(req contracts.ValidationRequest) (validators.Result, error)
|
|
}
|
|
|
|
func (v fakeValidator) Name() string { return v.name }
|
|
func (v fakeValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (validators.Result, error) {
|
|
_ = ctx
|
|
return v.validateF(req)
|
|
}
|
|
|
|
type fakeStructuredLLMClient struct {
|
|
validationResponses []validators.LLMValidationResponse
|
|
proposalResponses []proposal_generation.StructuredCorrectionSet
|
|
calls []string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeStructuredLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
_ = ctx
|
|
f.calls = append(f.calls, req.StageName)
|
|
if f.err != nil {
|
|
return contracts.StructuredCompletionResponse{}, f.err
|
|
}
|
|
switch target := out.(type) {
|
|
case *validators.LLMValidationResponse:
|
|
if len(f.validationResponses) == 0 {
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected validation llm call")
|
|
}
|
|
*target = f.validationResponses[0]
|
|
f.validationResponses = f.validationResponses[1:]
|
|
return contracts.StructuredCompletionResponse{}, nil
|
|
case *proposal_generation.StructuredCorrectionSet:
|
|
if len(f.proposalResponses) == 0 {
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected proposal llm call")
|
|
}
|
|
*target = f.proposalResponses[0]
|
|
f.proposalResponses = f.proposalResponses[1:]
|
|
return contracts.StructuredCompletionResponse{}, nil
|
|
default:
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected llm output type")
|
|
}
|
|
}
|
|
|
|
type capturePromptStructuredLLMClient struct {
|
|
proposalResponses []proposal_generation.StructuredCorrectionSet
|
|
validationResponses []validators.LLMValidationResponse
|
|
requests []contracts.StructuredCompletionRequest
|
|
}
|
|
|
|
func (c *capturePromptStructuredLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
|
_ = ctx
|
|
c.requests = append(c.requests, req)
|
|
switch target := out.(type) {
|
|
case *proposal_generation.StructuredCorrectionSet:
|
|
if len(c.proposalResponses) == 0 {
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected proposal llm call")
|
|
}
|
|
*target = c.proposalResponses[0]
|
|
c.proposalResponses = c.proposalResponses[1:]
|
|
return contracts.StructuredCompletionResponse{}, nil
|
|
case *validators.LLMValidationResponse:
|
|
if len(c.validationResponses) == 0 {
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected validation llm call")
|
|
}
|
|
*target = c.validationResponses[0]
|
|
c.validationResponses = c.validationResponses[1:]
|
|
return contracts.StructuredCompletionResponse{}, nil
|
|
default:
|
|
return contracts.StructuredCompletionResponse{}, errors.New("unexpected llm output type")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessInjectedFactoryExecutesRunnerAndReportsModules(t *testing.T) {
|
|
allow := fakeValidator{name: "allow", validateF: func(req contracts.ValidationRequest) (validators.Result, error) {
|
|
decisions := make([]validators.Decision, len(req.CandidateProposal))
|
|
for i, p := range req.CandidateProposal {
|
|
decisions[i] = validators.Decision{ProposalIndex: p.ProposalIndex, Approved: true, ReasonCode: validators.ReasonApproved, Message: "approved"}
|
|
}
|
|
return validators.Result{ValidatorName: "allow", Decisions: decisions}, nil
|
|
}}
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"glossary": fakeModule{key: "glossary", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{allow}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Hello", CorrectedText: "Hi", Confidence: 1},
|
|
}, nil
|
|
}},
|
|
"homophones": fakeModule{key: "homophones", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{allow}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
if req.WorkingTranscript.Segments[0].Text != "Hi world" {
|
|
t.Fatalf("expected module 2 to see module 1 changes, got %q", req.WorkingTranscript.Segments[0].Text)
|
|
}
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Hi", CorrectedText: "Hey", Confidence: 1},
|
|
{TargetSegmentID: 1, OriginalText: "missing", CorrectedText: "noop", Confidence: 1},
|
|
}, nil
|
|
}},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello world"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "glossary,homophones",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got exit %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
out := readFile(t, outputPath)
|
|
parsed, err := schema.ParseTranscriptJSON(out)
|
|
if err != nil {
|
|
t.Fatalf("parse output transcript: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "Hey world" {
|
|
t.Fatalf("expected runner-applied output text, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.ModulesSummary == nil || report.ModulesSummary.ModuleCount != 2 {
|
|
t.Fatalf("expected module summary for 2 modules, got %+v", report.ModulesSummary)
|
|
}
|
|
if report.ModulesSummary.TotalAppliedChanges != 2 || report.ModulesSummary.TotalSkippedChanges != 1 {
|
|
t.Fatalf("unexpected module totals: %+v", report.ModulesSummary)
|
|
}
|
|
if len(report.ModuleResults) != 2 {
|
|
t.Fatalf("expected 2 module results, got %d", len(report.ModuleResults))
|
|
}
|
|
if len(report.ModuleResults[1].SkippedChanges) != 1 {
|
|
t.Fatalf("expected skipped change for module 2, got %+v", report.ModuleResults[1].SkippedChanges)
|
|
}
|
|
if len(report.ModuleResults[0].ValidatorDecisions) == 0 {
|
|
t.Fatalf("expected validator decisions in module report")
|
|
}
|
|
|
|
runDirReport := readProcessReport(t, filepath.Join(onlyRunDir(t, workDir), "report.json"))
|
|
if len(runDirReport.ModuleResults) != 2 {
|
|
t.Fatalf("expected module results in run-dir report")
|
|
}
|
|
if len(runDirReport.ModuleResults[1].ValidatorDecisions) == 0 {
|
|
t.Fatalf("expected validator decisions in run-dir report")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessInjectedFactoryLLMValidatorResultsInReports(t *testing.T) {
|
|
llmValidator, err := validators.NewLLMBackedValidator("spoken_form_plausibility_review", validators.LLMValidatorTypeSpokenFormPlausibility, "")
|
|
if err != nil {
|
|
t.Fatalf("NewLLMBackedValidator: %v", err)
|
|
}
|
|
processValidationLLMClient = &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{{
|
|
Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: false, Confidence: 0.99, Reason: "reject"}},
|
|
}},
|
|
}
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"grammar": fakeModule{key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "Hello", CorrectedText: "Hi", Confidence: 1}}, nil
|
|
}},
|
|
}}
|
|
t.Cleanup(func() {
|
|
processModuleFactory = nil
|
|
processValidationLLMClient = nil
|
|
processValidationLLMScheduler = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello world"}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "grammar",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 || len(report.ModuleResults[0].ValidatorDecisions) == 0 {
|
|
t.Fatalf("expected llm validator decisions in external report")
|
|
}
|
|
runReport := readProcessReport(t, filepath.Join(onlyRunDir(t, workDir), "report.json"))
|
|
if len(runReport.ModuleResults) != 1 || len(runReport.ModuleResults[0].ValidatorRejected) != 1 {
|
|
t.Fatalf("expected llm validator rejection in run report")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessInjectedFactorySkippedKeepsAutoRetention(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"glossary": fakeModule{key: "glossary", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return []proposals.CorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "word", CorrectedText: "term", Confidence: 1},
|
|
}, nil
|
|
}},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"word word"}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "glossary",
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "auto",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if _, err := os.Stat(onlyRunDir(t, workDir)); err != nil {
|
|
t.Fatalf("expected run dir retained for skipped corrections under auto: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessInjectedFactoryFailureWritesFailedReport(t *testing.T) {
|
|
processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{
|
|
"glossary": fakeModule{key: "glossary", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
|
|
return nil, errors.New("test failure")
|
|
}},
|
|
}}
|
|
t.Cleanup(func() { processModuleFactory = nil })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "glossary",
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "always",
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatal("expected failure exit code")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "runner_execution") {
|
|
t.Fatalf("expected runner_execution failure on stderr, got %q", stderr.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "failed" {
|
|
t.Fatalf("expected failed report status, got %q", report.Status)
|
|
}
|
|
if report.ModulesSummary == nil || report.ModulesSummary.FailedModuleInstance == "" {
|
|
t.Fatalf("expected failed module summary, got %+v", report.ModulesSummary)
|
|
}
|
|
if len(report.ModuleResults) != 1 || report.ModuleResults[0].Status != "failed" {
|
|
t.Fatalf("expected failed module result, got %+v", report.ModuleResults)
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil {
|
|
t.Fatalf("expected error.log for failed runner module: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessUnsupportedModuleFailsDuringConfigValidation(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "made_up",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatal("expected failure exit code")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "invalid CLI configuration") {
|
|
t.Fatalf("expected config validation failure on stderr, got %q", stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "unsupported module key") {
|
|
t.Fatalf("expected explicit unsupported module message, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitUnsupportedModulesFailClearly(t *testing.T) {
|
|
for _, moduleKey := range []string{"made_up"} {
|
|
t.Run(moduleKey, func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", moduleKey,
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected failure for %s", moduleKey)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "unsupported module key") {
|
|
t.Fatalf("expected unsupported-module message, got %q", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitGrammarAppliesCorrectionAndReportsDiagnostics(t *testing.T) {
|
|
secret := "grammar-secret"
|
|
processProposalLLMClient = &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "hello ,world", CorrectedText: "Hello, world", Confidence: 0.95},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
processValidationLLMClient = &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: secret}}},
|
|
},
|
|
}
|
|
t.Setenv("AUDITA_LLM_API_KEY", secret)
|
|
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secret)
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
processProposalLLMScheduler = nil
|
|
processValidationLLMScheduler = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello ,world"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "grammar",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "Hello, world" {
|
|
t.Fatalf("expected grammar correction applied, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 || report.ModuleResults[0].ModuleKey != "grammar" {
|
|
t.Fatalf("expected one grammar module result, got %+v", report.ModuleResults)
|
|
}
|
|
if len(report.ModuleResults[0].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one applied grammar change, got %+v", report.ModuleResults[0].AppliedChanges)
|
|
}
|
|
if len(report.ModuleResults[0].ValidatorDecisions) == 0 {
|
|
t.Fatalf("expected validator decisions in report")
|
|
}
|
|
|
|
runDir := onlyRunDir(t, workDir)
|
|
runReport := readProcessReport(t, filepath.Join(runDir, "report.json"))
|
|
if len(runReport.ModuleResults) != 1 {
|
|
t.Fatalf("expected module results in run-dir report")
|
|
}
|
|
|
|
diagFiles, globErr := filepath.Glob(filepath.Join(runDir, "grammar", "*response-payload.json"))
|
|
if globErr != nil {
|
|
t.Fatalf("glob diagnostics: %v", globErr)
|
|
}
|
|
if len(diagFiles) == 0 {
|
|
t.Fatalf("expected grammar diagnostics payload files in %s", filepath.Join(runDir, "grammar"))
|
|
}
|
|
for _, f := range diagFiles {
|
|
raw := string(readFile(t, f))
|
|
if strings.Contains(raw, secret) {
|
|
t.Fatalf("secret leaked in diagnostics %q: %s", f, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunProcessTranscriptDescriptionReachesProposalAndValidatorPrompts(t *testing.T) {
|
|
proposalClient := &capturePromptStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "hello ,world", CorrectedText: "Hello, world", Confidence: 0.95},
|
|
}},
|
|
},
|
|
}
|
|
validationClient := &capturePromptStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
processProposalLLMScheduler = nil
|
|
processValidationLLMScheduler = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello ,world"}
|
|
]`)
|
|
description := "Hearing transcript where speakers reference proper nouns."
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "grammar",
|
|
"--transcript-description", description,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
if len(proposalClient.requests) == 0 {
|
|
t.Fatalf("expected proposal LLM requests")
|
|
}
|
|
if len(validationClient.requests) == 0 {
|
|
t.Fatalf("expected validation LLM requests")
|
|
}
|
|
|
|
proposalPrompt := combinedPrompt(proposalClient.requests[0].Messages)
|
|
validatorPrompt := combinedPrompt(validationClient.requests[0].Messages)
|
|
for _, prompt := range []string{proposalPrompt, validatorPrompt} {
|
|
for _, want := range []string{
|
|
"Transcript description (background context only):",
|
|
description,
|
|
"must not override the transcript content",
|
|
"Do not invent corrections, facts, names, events, motivations, or speaker intent based on this description.",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("expected prompt to contain %q, got: %q", want, prompt)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func combinedPrompt(messages []contracts.LLMMessage) string {
|
|
parts := make([]string, 0, len(messages))
|
|
for _, m := range messages {
|
|
parts = append(parts, m.Content)
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func TestRunProcessExplicitGrammarRejectedAndApplicationSkipAreDistinct(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Hello", CorrectedText: "Hi", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "Hello world", CorrectedText: "Hi world", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "world", CorrectedText: "earth", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
processValidationLLMClient = &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 2, Approved: false, Confidence: 0.9, Reason: "reject"},
|
|
},
|
|
},
|
|
{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello world"}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "grammar",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result")
|
|
}
|
|
module := report.ModuleResults[0]
|
|
if len(module.ValidatorRejected) != 1 {
|
|
t.Fatalf("expected one validator rejection, got %+v", module.ValidatorRejected)
|
|
}
|
|
if len(module.SkippedChanges) != 1 {
|
|
t.Fatalf("expected one application skip, got %+v", module.SkippedChanges)
|
|
}
|
|
if module.ValidatorRejected[0].ReasonCode == string(module.SkippedChanges[0].SkipReason) {
|
|
t.Fatalf("validator rejection and application skip should remain distinct")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitGrammarMalformedLLMOutputSucceedsWithWarning(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{err: errors.New("malformed structured output")}
|
|
t.Cleanup(func() { processProposalLLMClient = nil })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--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())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
|
}
|
|
parsed, err := schema.ParseTranscriptJSON(stdout.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("expected transcript stdout on success: %v", err)
|
|
}
|
|
if len(parsed.Segments) != 1 || parsed.Segments[0].Text != "hello" {
|
|
t.Fatalf("expected unchanged transcript, got %+v", parsed.Segments)
|
|
}
|
|
runDir := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runDir, "error.log")); err == nil || !os.IsNotExist(err) {
|
|
t.Fatalf("did not expect error.log on successful grammar run: %v", err)
|
|
}
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "success" || report.ErrorPhase != "" {
|
|
t.Fatalf("expected successful report, got %+v", report)
|
|
}
|
|
if len(report.ModuleResults) != 1 || len(report.ModuleResults[0].Warnings) != 1 {
|
|
t.Fatalf("expected one module warning, got %+v", report.ModuleResults)
|
|
}
|
|
if report.ModuleResults[0].Warnings[0].ReasonCode != "proposal_response_malformed" {
|
|
t.Fatalf("unexpected warning: %+v", report.ModuleResults[0].Warnings[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProcessDefaultRunExecutesFullModuleSequence(t *testing.T) {
|
|
secret := "pipeline-secret"
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Jesters", CorrectedText: "jesters", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "jesters", CorrectedText: "JESTERS", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "uh", CorrectedText: "hmm", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "hello ,", CorrectedText: "Hello,", Confidence: 0.99},
|
|
}},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Setenv("AUDITA_LLM_API_KEY", secret)
|
|
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secret)
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello , there were gestures uh"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "Hello, there were JESTERS hmm" {
|
|
t.Fatalf("expected full default pipeline transcript mutation, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.ModulesSummary == nil {
|
|
t.Fatalf("expected module summary in report")
|
|
}
|
|
if report.ModulesSummary.ModuleCount != 5 {
|
|
t.Fatalf("expected 5 module instances, got %+v", report.ModulesSummary)
|
|
}
|
|
if report.ModulesSummary.TotalAppliedChanges != 5 {
|
|
t.Fatalf("expected 5 applied changes, got %+v", report.ModulesSummary)
|
|
}
|
|
if report.ModulesSummary.TotalSkippedChanges != 0 {
|
|
t.Fatalf("expected 0 skipped changes, got %+v", report.ModulesSummary)
|
|
}
|
|
if len(report.ModuleResults) != 5 {
|
|
t.Fatalf("expected 5 module results, got %+v", report.ModuleResults)
|
|
}
|
|
|
|
gotOrder := make([]string, 0, len(report.ModuleResults))
|
|
for _, mr := range report.ModuleResults {
|
|
gotOrder = append(gotOrder, mr.ModuleInstance)
|
|
}
|
|
wantOrder := []string{"glossary_1", "homophones", "glossary_2", "spoken_word", "grammar"}
|
|
if !reflect.DeepEqual(gotOrder, wantOrder) {
|
|
t.Fatalf("unexpected module instance order: got %v want %v", gotOrder, wantOrder)
|
|
}
|
|
|
|
wantProposalCallOrder := []string{
|
|
"glossary_1:proposal",
|
|
"homophones:proposal",
|
|
"glossary_2:proposal",
|
|
"spoken_word:proposal",
|
|
"grammar:proposal",
|
|
}
|
|
if !reflect.DeepEqual(proposalClient.calls, wantProposalCallOrder) {
|
|
t.Fatalf("unexpected proposal call order: got %v want %v", proposalClient.calls, wantProposalCallOrder)
|
|
}
|
|
|
|
runDir := onlyRunDir(t, workDir)
|
|
runReport := readProcessReport(t, filepath.Join(runDir, "report.json"))
|
|
if len(runReport.ModuleResults) != 5 {
|
|
t.Fatalf("expected 5 module results in run-dir report, got %+v", runReport.ModuleResults)
|
|
}
|
|
for _, instance := range wantOrder {
|
|
matches, globErr := filepath.Glob(filepath.Join(runDir, instance, "*response-payload.json"))
|
|
if globErr != nil {
|
|
t.Fatalf("glob diagnostics for %s: %v", instance, globErr)
|
|
}
|
|
if len(matches) == 0 {
|
|
t.Fatalf("expected diagnostics payload files for module %s", instance)
|
|
}
|
|
for _, path := range matches {
|
|
raw := string(readFile(t, path))
|
|
if strings.Contains(raw, secret) {
|
|
t.Fatalf("secret leaked in diagnostics %q: %s", path, raw)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunProcessDefaultFullPipelineFailurePreservesPartialProgressAndRetention(t *testing.T) {
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Jesters", CorrectedText: "jesters", Confidence: 0.99},
|
|
}},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"there were gestures"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "auto",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected failure")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "runner_execution") {
|
|
t.Fatalf("expected runner_execution error, got %q", stderr.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "failed" {
|
|
t.Fatalf("expected failed report status, got %q", report.Status)
|
|
}
|
|
if report.ErrorPhase != "runner_execution" {
|
|
t.Fatalf("expected runner_execution phase, got %q", report.ErrorPhase)
|
|
}
|
|
if report.ModulesSummary == nil || report.ModulesSummary.FailedModuleInstance != "glossary_2" {
|
|
t.Fatalf("expected failed module instance glossary_2, got %+v", report.ModulesSummary)
|
|
}
|
|
if len(report.ModuleResults) != 3 {
|
|
t.Fatalf("expected partial progress with 3 module results, got %+v", report.ModuleResults)
|
|
}
|
|
if report.ModuleResults[0].Status != runner.ModuleStatusSuccess || report.ModuleResults[1].Status != runner.ModuleStatusSuccess {
|
|
t.Fatalf("expected first two modules to succeed, got %+v", report.ModuleResults)
|
|
}
|
|
if report.ModuleResults[2].Status != runner.ModuleStatusFailed || report.ModuleResults[2].ModuleInstance != "glossary_2" {
|
|
t.Fatalf("expected glossary_2 failed result, got %+v", report.ModuleResults[2])
|
|
}
|
|
if report.ModuleResults[2].ErrorMessage == "" {
|
|
t.Fatalf("expected failed module error message")
|
|
}
|
|
if report.Diagnostics == nil || report.Diagnostics.DirectoryPath == "" || report.Diagnostics.ErrorLogPath == "" {
|
|
t.Fatalf("expected diagnostics and error log references on failure, got %+v", report.Diagnostics)
|
|
}
|
|
if _, err := os.Stat(report.Diagnostics.ErrorLogPath); err != nil {
|
|
t.Fatalf("expected error.log to exist: %v", err)
|
|
}
|
|
// Auto retention must keep failed runs.
|
|
runDir := onlyRunDir(t, workDir)
|
|
runDirReport := readProcessReport(t, filepath.Join(runDir, "report.json"))
|
|
if len(runDirReport.ModuleResults) != 3 {
|
|
t.Fatalf("expected partial module results in run-dir report, got %+v", runDirReport.ModuleResults)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessDefaultFullPipelineAggregatesSkipsAndAutoRetentionKeepsRunDir(t *testing.T) {
|
|
secret := "retention-secret"
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Jesters", CorrectedText: "jesters", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "Jesters", CorrectedText: "JESTERX", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "jesters", CorrectedText: "JESTERS", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "uh", CorrectedText: "hmm", Confidence: 0.99},
|
|
}},
|
|
{Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "hello ,", CorrectedText: "Hello,", Confidence: 0.99},
|
|
}},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}, {CorrectionIndex: 1, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}, {CorrectionIndex: 1, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: false, Confidence: 0.99, Reason: "reject cleanup"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: secret}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Setenv("AUDITA_LLM_API_KEY", secret)
|
|
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secret)
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello , there were gestures uh"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "auto",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "success" {
|
|
t.Fatalf("expected success report status, got %q", report.Status)
|
|
}
|
|
if report.ModulesSummary == nil || report.ModulesSummary.ModuleCount != 5 {
|
|
t.Fatalf("expected 5-module summary, got %+v", report.ModulesSummary)
|
|
}
|
|
if report.ModulesSummary.TotalAppliedChanges != 4 {
|
|
t.Fatalf("expected 4 applied changes, got %+v", report.ModulesSummary)
|
|
}
|
|
if report.ModulesSummary.TotalSkippedChanges != 2 {
|
|
t.Fatalf("expected 2 skipped changes (1 app skip + 1 validator rejection), got %+v", report.ModulesSummary)
|
|
}
|
|
if len(report.ModuleResults) != 5 {
|
|
t.Fatalf("expected 5 module reports, got %+v", report.ModuleResults)
|
|
}
|
|
// Homophones contributes an application-level skip.
|
|
if len(report.ModuleResults[1].SkippedChanges) != 1 {
|
|
t.Fatalf("expected one homophones application skip, got %+v", report.ModuleResults[1].SkippedChanges)
|
|
}
|
|
// Spoken_word contributes one validator rejection.
|
|
if len(report.ModuleResults[3].ValidatorRejected) != 1 {
|
|
t.Fatalf("expected one spoken_word validator rejection, got %+v", report.ModuleResults[3].ValidatorRejected)
|
|
}
|
|
if report.ModuleResults[3].ValidatorRejected[0].ReasonCode == string(report.ModuleResults[1].SkippedChanges[0].SkipReason) {
|
|
t.Fatalf("validator rejection and application skip should remain distinct")
|
|
}
|
|
|
|
// Auto retention must keep successful runs with skipped/rejected corrections.
|
|
runDir := onlyRunDir(t, workDir)
|
|
runDirReport := readProcessReport(t, filepath.Join(runDir, "report.json"))
|
|
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)
|
|
}
|
|
if len(diagFiles) == 0 {
|
|
t.Fatalf("expected diagnostics payload artifacts")
|
|
}
|
|
for _, f := range diagFiles {
|
|
raw := string(readFile(t, f))
|
|
if strings.Contains(raw, secret) {
|
|
t.Fatalf("secret leaked in diagnostics %q: %s", f, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitGlossaryAppliesCorrectionAndReportsDiagnostics(t *testing.T) {
|
|
secret := "glossary-secret"
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.95},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "spoken plausible"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: secret}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Setenv("AUDITA_LLM_API_KEY", secret)
|
|
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secret)
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"There were gestures in the hall."}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "glossary",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "There were Jesters in the hall." {
|
|
t.Fatalf("expected glossary correction applied, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 || report.ModuleResults[0].ModuleKey != "glossary" {
|
|
t.Fatalf("expected one glossary module result, got %+v", report.ModuleResults)
|
|
}
|
|
if len(report.ModuleResults[0].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one applied glossary change, got %+v", report.ModuleResults[0].AppliedChanges)
|
|
}
|
|
if len(report.ModuleResults[0].ValidatorDecisions) == 0 {
|
|
t.Fatalf("expected validator decisions in report")
|
|
}
|
|
|
|
runDir := onlyRunDir(t, workDir)
|
|
runReport := readProcessReport(t, filepath.Join(runDir, "report.json"))
|
|
if len(runReport.ModuleResults) != 1 {
|
|
t.Fatalf("expected module results in run-dir report")
|
|
}
|
|
|
|
diagFiles, globErr := filepath.Glob(filepath.Join(runDir, "glossary", "*response-payload.json"))
|
|
if globErr != nil {
|
|
t.Fatalf("glob diagnostics: %v", globErr)
|
|
}
|
|
if len(diagFiles) == 0 {
|
|
t.Fatalf("expected glossary diagnostics payload files in %s", filepath.Join(runDir, "glossary"))
|
|
}
|
|
for _, f := range diagFiles {
|
|
raw := string(readFile(t, f))
|
|
if strings.Contains(raw, secret) {
|
|
t.Fatalf("secret leaked in diagnostics %q: %s", f, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitGlossaryRejectedAndApplicationSkipAreDistinct(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "there were gestures", CorrectedText: "There were gestures", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "there were gestures", CorrectedText: "there were jesters", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "hall", CorrectedText: "temple", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
processValidationLLMClient = &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 2, Approved: false, Confidence: 0.9, Reason: "reject"},
|
|
},
|
|
},
|
|
{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"there were gestures in the hall and there were gestures."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "glossary",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result")
|
|
}
|
|
module := report.ModuleResults[0]
|
|
if len(module.ValidatorRejected) != 1 {
|
|
t.Fatalf("expected one validator rejection, got %+v", module.ValidatorRejected)
|
|
}
|
|
if len(module.SkippedChanges) != 1 {
|
|
t.Fatalf("expected one application skip, got %+v", module.SkippedChanges)
|
|
}
|
|
if module.ValidatorRejected[0].ReasonCode == string(module.SkippedChanges[0].SkipReason) {
|
|
t.Fatalf("validator rejection and application skip should remain distinct")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitGlossaryRepeatedStagesUseDeterministicInstanceNamesAndSeePriorChanges(t *testing.T) {
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.99},
|
|
},
|
|
},
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Jesters", CorrectedText: "JESTERS", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"There were gestures in the hall."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "glossary,glossary",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "There were JESTERS in the hall." {
|
|
t.Fatalf("expected second stage to see first stage changes, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 2 {
|
|
t.Fatalf("expected two module results, got %+v", report.ModuleResults)
|
|
}
|
|
if report.ModuleResults[0].ModuleInstance != "glossary_1" || report.ModuleResults[1].ModuleInstance != "glossary_2" {
|
|
t.Fatalf("expected deterministic glossary instance names, got %+v", report.ModuleResults)
|
|
}
|
|
if len(report.ModuleResults[0].AppliedChanges) != 1 || len(report.ModuleResults[1].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one applied change per stage, got %+v", report.ModuleResults)
|
|
}
|
|
foundStage1 := false
|
|
foundStage2 := false
|
|
for _, call := range proposalClient.calls {
|
|
if strings.HasPrefix(call, "glossary_1:proposal") {
|
|
foundStage1 = true
|
|
}
|
|
if strings.HasPrefix(call, "glossary_2:proposal") {
|
|
foundStage2 = true
|
|
}
|
|
}
|
|
if !foundStage1 || !foundStage2 {
|
|
t.Fatalf("expected proposal calls for glossary_1 and glossary_2, got %v", proposalClient.calls)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitGlossaryMalformedLLMOutputSucceedsWithWarning(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{err: errors.New("malformed structured output")}
|
|
t.Cleanup(func() { processProposalLLMClient = nil })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "glossary",
|
|
"--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())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
|
}
|
|
parsed, err := schema.ParseTranscriptJSON(stdout.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("expected transcript stdout on success: %v", err)
|
|
}
|
|
if len(parsed.Segments) != 1 || parsed.Segments[0].Text != "hello" {
|
|
t.Fatalf("expected unchanged transcript, got %+v", parsed.Segments)
|
|
}
|
|
runDir := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runDir, "error.log")); err == nil || !os.IsNotExist(err) {
|
|
t.Fatalf("did not expect error.log on successful glossary run: %v", err)
|
|
}
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "success" || report.ErrorPhase != "" {
|
|
t.Fatalf("expected successful report, got %+v", report)
|
|
}
|
|
if len(report.ModuleResults) != 1 || len(report.ModuleResults[0].Warnings) != 1 {
|
|
t.Fatalf("expected one module warning, got %+v", report.ModuleResults)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitHomophonesAppliesCorrectionAndReportsDiagnostics(t *testing.T) {
|
|
secret := "homophones-secret"
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.95},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "spoken plausible"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: secret}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Setenv("AUDITA_LLM_API_KEY", secret)
|
|
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secret)
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"There were gestures in the hall."}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "homophones",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "There were Jesters in the hall." {
|
|
t.Fatalf("expected homophones correction applied, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 || report.ModuleResults[0].ModuleKey != "homophones" {
|
|
t.Fatalf("expected one homophones module result, got %+v", report.ModuleResults)
|
|
}
|
|
if len(report.ModuleResults[0].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one applied homophones change, got %+v", report.ModuleResults[0].AppliedChanges)
|
|
}
|
|
if len(report.ModuleResults[0].ValidatorDecisions) == 0 {
|
|
t.Fatalf("expected validator decisions in report")
|
|
}
|
|
|
|
runDir := onlyRunDir(t, workDir)
|
|
runReport := readProcessReport(t, filepath.Join(runDir, "report.json"))
|
|
if len(runReport.ModuleResults) != 1 || runReport.ModuleResults[0].ModuleKey != "homophones" {
|
|
t.Fatalf("expected homophones module results in run-dir report, got %+v", runReport.ModuleResults)
|
|
}
|
|
|
|
diagFiles, globErr := filepath.Glob(filepath.Join(runDir, "homophones", "*response-payload.json"))
|
|
if globErr != nil {
|
|
t.Fatalf("glob diagnostics: %v", globErr)
|
|
}
|
|
if len(diagFiles) == 0 {
|
|
t.Fatalf("expected homophones diagnostics payload files in %s", filepath.Join(runDir, "homophones"))
|
|
}
|
|
for _, f := range diagFiles {
|
|
raw := string(readFile(t, f))
|
|
if strings.Contains(raw, secret) {
|
|
t.Fatalf("secret leaked in diagnostics %q: %s", f, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitHomophonesRejectedAndApplicationSkipAreDistinct(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "hall", CorrectedText: "temple", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "there were gestures in the hall", CorrectedText: "there were gestures in the temple", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "and there were gestures.", CorrectedText: "and there were jesters.", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
processValidationLLMClient = &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 2, Approved: false, Confidence: 0.9, Reason: "reject"},
|
|
},
|
|
},
|
|
{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"there were gestures in the hall and there were gestures."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "homophones",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result")
|
|
}
|
|
module := report.ModuleResults[0]
|
|
if len(module.ValidatorRejected) != 1 {
|
|
t.Fatalf("expected one validator rejection, got %+v", module.ValidatorRejected)
|
|
}
|
|
if len(module.SkippedChanges) != 1 {
|
|
t.Fatalf("expected one application skip, got %+v", module.SkippedChanges)
|
|
}
|
|
if module.ValidatorRejected[0].ReasonCode == string(module.SkippedChanges[0].SkipReason) {
|
|
t.Fatalf("validator rejection and application skip should remain distinct")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitHomophonesProtectedGlossaryTermRejected(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Audita", CorrectedText: "audita", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Audita held the line."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "homophones",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "Audita held the line." {
|
|
t.Fatalf("expected protected glossary term to remain unchanged, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result, got %+v", report.ModuleResults)
|
|
}
|
|
module := report.ModuleResults[0]
|
|
if len(module.AppliedChanges) != 0 {
|
|
t.Fatalf("expected no applied changes, got %+v", module.AppliedChanges)
|
|
}
|
|
if len(module.ValidatorRejected) != 1 {
|
|
t.Fatalf("expected one validator rejection, got %+v", module.ValidatorRejected)
|
|
}
|
|
if module.ValidatorRejected[0].ReasonCode != validators.ReasonProtectedGlossaryTerm {
|
|
t.Fatalf("expected protected glossary term rejection, got %+v", module.ValidatorRejected[0])
|
|
}
|
|
if module.ValidatorRejected[0].ValidatorName != "protected_terms" {
|
|
t.Fatalf("expected stable validator key protected_terms, got %+v", module.ValidatorRejected[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitHomophonesMalformedLLMOutputSucceedsWithWarning(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{err: errors.New("malformed structured output")}
|
|
t.Cleanup(func() { processProposalLLMClient = nil })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "homophones",
|
|
"--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())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
|
}
|
|
parsed, err := schema.ParseTranscriptJSON(stdout.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("expected transcript stdout on success: %v", err)
|
|
}
|
|
if len(parsed.Segments) != 1 || parsed.Segments[0].Text != "hello" {
|
|
t.Fatalf("expected unchanged transcript, got %+v", parsed.Segments)
|
|
}
|
|
runDir := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runDir, "error.log")); err == nil || !os.IsNotExist(err) {
|
|
t.Fatalf("did not expect error.log on successful homophones run: %v", err)
|
|
}
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "success" || report.ErrorPhase != "" {
|
|
t.Fatalf("expected successful report, got %+v", report)
|
|
}
|
|
if len(report.ModuleResults) != 1 || len(report.ModuleResults[0].Warnings) != 1 {
|
|
t.Fatalf("expected one module warning, got %+v", report.ModuleResults)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitGlossaryThenHomophonesSeesWorkingTranscriptChanges(t *testing.T) {
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "gestures", CorrectedText: "Jesters", Confidence: 0.99},
|
|
},
|
|
},
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Jesters", CorrectedText: "Jester", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"There were gestures in the hall."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "glossary,homophones",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "There were Jester in the hall." {
|
|
t.Fatalf("expected homophones stage to see glossary output, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 2 {
|
|
t.Fatalf("expected two module results, got %+v", report.ModuleResults)
|
|
}
|
|
if report.ModuleResults[0].ModuleKey != "glossary" || report.ModuleResults[1].ModuleKey != "homophones" {
|
|
t.Fatalf("expected glossary then homophones results, got %+v", report.ModuleResults)
|
|
}
|
|
if len(report.ModuleResults[0].AppliedChanges) != 1 || len(report.ModuleResults[1].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one applied change per module, got %+v", report.ModuleResults)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitSpokenWordAppliesCleanupAndReportsDiagnostics(t *testing.T) {
|
|
secret := "spoken-word-secret"
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "I I think", CorrectedText: "I think", Confidence: 0.95},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "spoken cleanup acceptable"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: secret}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Setenv("AUDITA_LLM_API_KEY", secret)
|
|
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secret)
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"I I think we should go."}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "spoken_word",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
"--work-dir", workDir,
|
|
"--work-dir-retention", "always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "I think we should go." {
|
|
t.Fatalf("expected spoken_word cleanup applied, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 || report.ModuleResults[0].ModuleKey != "spoken_word" {
|
|
t.Fatalf("expected one spoken_word module result, got %+v", report.ModuleResults)
|
|
}
|
|
if len(report.ModuleResults[0].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one applied spoken_word change, got %+v", report.ModuleResults[0].AppliedChanges)
|
|
}
|
|
if len(report.ModuleResults[0].ValidatorDecisions) == 0 {
|
|
t.Fatalf("expected validator decisions in report")
|
|
}
|
|
|
|
runDir := onlyRunDir(t, workDir)
|
|
runReport := readProcessReport(t, filepath.Join(runDir, "report.json"))
|
|
if len(runReport.ModuleResults) != 1 || runReport.ModuleResults[0].ModuleKey != "spoken_word" {
|
|
t.Fatalf("expected spoken_word module results in run-dir report, got %+v", runReport.ModuleResults)
|
|
}
|
|
|
|
diagFiles, globErr := filepath.Glob(filepath.Join(runDir, "spoken_word", "*response-payload.json"))
|
|
if globErr != nil {
|
|
t.Fatalf("glob diagnostics: %v", globErr)
|
|
}
|
|
if len(diagFiles) == 0 {
|
|
t.Fatalf("expected spoken_word diagnostics payload files in %s", filepath.Join(runDir, "spoken_word"))
|
|
}
|
|
for _, f := range diagFiles {
|
|
raw := string(readFile(t, f))
|
|
if strings.Contains(raw, secret) {
|
|
t.Fatalf("secret leaked in diagnostics %q: %s", f, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitSpokenWordRejectedAndApplicationSkipAreDistinct(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "hall", CorrectedText: "temple", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "I I think in the hall.", CorrectedText: "I think in the temple.", Confidence: 0.99},
|
|
{TargetSegmentID: 1, OriginalText: "I I think", CorrectedText: "I suppose", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
processValidationLLMClient = &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 2, Approved: false, Confidence: 0.9, Reason: "reject"},
|
|
},
|
|
},
|
|
{
|
|
Validations: []validators.LLMValidationDecision{
|
|
{CorrectionIndex: 0, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
{CorrectionIndex: 1, Approved: true, Confidence: 0.9, Reason: "ok"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"I I think in the hall."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "spoken_word",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result")
|
|
}
|
|
module := report.ModuleResults[0]
|
|
if len(module.ValidatorRejected) != 1 {
|
|
t.Fatalf("expected one validator rejection, got %+v", module.ValidatorRejected)
|
|
}
|
|
if len(module.SkippedChanges) != 1 {
|
|
t.Fatalf("expected one application skip, got %+v", module.SkippedChanges)
|
|
}
|
|
if module.ValidatorRejected[0].ReasonCode == string(module.SkippedChanges[0].SkipReason) {
|
|
t.Fatalf("validator rejection and application skip should remain distinct")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitSpokenWordMeaningChangingCleanupRejected(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "I I think", CorrectedText: "I know", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
processValidationLLMClient = &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: false, Confidence: 0.99, Reason: "meaning changed"}}},
|
|
},
|
|
}
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"I I think we should go."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "spoken_word",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "I I think we should go." {
|
|
t.Fatalf("expected meaning-changing cleanup to be rejected, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result, got %+v", report.ModuleResults)
|
|
}
|
|
module := report.ModuleResults[0]
|
|
if len(module.AppliedChanges) != 0 {
|
|
t.Fatalf("expected no applied changes, got %+v", module.AppliedChanges)
|
|
}
|
|
if len(module.ValidatorRejected) != 1 {
|
|
t.Fatalf("expected one semantic guardrail rejection, got %+v", module.ValidatorRejected)
|
|
}
|
|
if module.ValidatorRejected[0].ValidatorName != "editorial_review" {
|
|
t.Fatalf("expected editorial_review rejection, got %+v", module.ValidatorRejected[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitSpokenWordProtectedGlossaryTermRejected(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "Audita", CorrectedText: "audita", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Audita held the line."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "spoken_word",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "Audita held the line." {
|
|
t.Fatalf("expected protected glossary term to remain unchanged, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 1 {
|
|
t.Fatalf("expected one module result, got %+v", report.ModuleResults)
|
|
}
|
|
module := report.ModuleResults[0]
|
|
if len(module.AppliedChanges) != 0 {
|
|
t.Fatalf("expected no applied changes, got %+v", module.AppliedChanges)
|
|
}
|
|
if len(module.ValidatorRejected) != 1 {
|
|
t.Fatalf("expected one validator rejection, got %+v", module.ValidatorRejected)
|
|
}
|
|
if module.ValidatorRejected[0].ReasonCode != validators.ReasonProtectedGlossaryTerm {
|
|
t.Fatalf("expected protected glossary term rejection, got %+v", module.ValidatorRejected[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitSpokenWordMalformedLLMOutputSucceedsWithWarning(t *testing.T) {
|
|
processProposalLLMClient = &fakeStructuredLLMClient{err: errors.New("malformed structured output")}
|
|
t.Cleanup(func() { processProposalLLMClient = nil })
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
workDir := t.TempDir()
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"hello"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "spoken_word",
|
|
"--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())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
|
}
|
|
parsed, err := schema.ParseTranscriptJSON(stdout.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("expected transcript stdout on success: %v", err)
|
|
}
|
|
if len(parsed.Segments) != 1 || parsed.Segments[0].Text != "hello" {
|
|
t.Fatalf("expected unchanged transcript, got %+v", parsed.Segments)
|
|
}
|
|
runDir := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runDir, "error.log")); err == nil || !os.IsNotExist(err) {
|
|
t.Fatalf("did not expect error.log on successful spoken_word run: %v", err)
|
|
}
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "success" || report.ErrorPhase != "" {
|
|
t.Fatalf("expected successful report, got %+v", report)
|
|
}
|
|
if len(report.ModuleResults) != 1 || len(report.ModuleResults[0].Warnings) != 1 {
|
|
t.Fatalf("expected one module warning, got %+v", report.ModuleResults)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessExplicitSpokenWordThenGrammarSeesWorkingTranscriptChanges(t *testing.T) {
|
|
proposalClient := &fakeStructuredLLMClient{
|
|
proposalResponses: []proposal_generation.StructuredCorrectionSet{
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "i i think", CorrectedText: "i think", Confidence: 0.99},
|
|
},
|
|
},
|
|
{
|
|
Corrections: []proposal_generation.StructuredCorrectionProposal{
|
|
{TargetSegmentID: 1, OriginalText: "i think we should go.", CorrectedText: "I think we should go.", Confidence: 0.99},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
validationClient := &fakeStructuredLLMClient{
|
|
validationResponses: []validators.LLMValidationResponse{
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
{Validations: []validators.LLMValidationDecision{{CorrectionIndex: 0, Approved: true, Confidence: 0.99, Reason: "ok"}}},
|
|
},
|
|
}
|
|
processProposalLLMClient = proposalClient
|
|
processValidationLLMClient = validationClient
|
|
t.Cleanup(func() {
|
|
processProposalLLMClient = nil
|
|
processValidationLLMClient = nil
|
|
})
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"i i think we should go."}
|
|
]`)
|
|
exitCode := Run([]string{
|
|
"process", transcriptPath,
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--modules", "spoken_word,grammar",
|
|
"--output", outputPath,
|
|
"--report-json", reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
parsed, err := schema.ParseTranscriptJSON(readFile(t, outputPath))
|
|
if err != nil {
|
|
t.Fatalf("parse output: %v", err)
|
|
}
|
|
if parsed.Segments[0].Text != "I think we should go." {
|
|
t.Fatalf("expected grammar stage to see spoken_word output, got %q", parsed.Segments[0].Text)
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if len(report.ModuleResults) != 2 {
|
|
t.Fatalf("expected two module results, got %+v", report.ModuleResults)
|
|
}
|
|
if report.ModuleResults[0].ModuleKey != "spoken_word" || report.ModuleResults[1].ModuleKey != "grammar" {
|
|
t.Fatalf("expected spoken_word then grammar results, got %+v", report.ModuleResults)
|
|
}
|
|
if len(report.ModuleResults[0].AppliedChanges) != 1 || len(report.ModuleResults[1].AppliedChanges) != 1 {
|
|
t.Fatalf("expected one applied change per module, got %+v", report.ModuleResults)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessChunkingSummaryArtifactWritten(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
workDir := t.TempDir()
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"},
|
|
{"id":2,"speaker":"Bob","start":2.0,"end":3.0,"text":"World"}
|
|
]`)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
|
|
// Verify chunking-summary.json exists
|
|
chunkingSummaryPath := filepath.Join(runPath, "chunking-summary.json")
|
|
if _, err := os.Stat(chunkingSummaryPath); err != nil {
|
|
t.Fatalf("expected chunking-summary.json artifact: %v", err)
|
|
}
|
|
|
|
// Verify it contains valid JSON with expected fields
|
|
content := readFile(t, chunkingSummaryPath)
|
|
var summary struct {
|
|
ChunkCount int `json:"chunk_count"`
|
|
MinEstimatedTokens int `json:"min_estimated_chunk_tokens"`
|
|
MaxEstimatedTokens int `json:"max_estimated_chunk_tokens"`
|
|
TotalEstimatedTokens int `json:"total_estimated_transcript_tokens"`
|
|
Chunks []struct {
|
|
Index int `json:"index"`
|
|
StartSegmentID int `json:"start_segment_id"`
|
|
EndSegmentID int `json:"end_segment_id"`
|
|
EstimatedTokens int `json:"estimated_tokens"`
|
|
SegmentCount int `json:"segment_count"`
|
|
} `json:"chunks"`
|
|
}
|
|
if err := json.Unmarshal(content, &summary); err != nil {
|
|
t.Fatalf("failed to parse chunking summary: %v", err)
|
|
}
|
|
if summary.ChunkCount == 0 {
|
|
t.Errorf("expected non-zero chunk count in summary")
|
|
}
|
|
if len(summary.Chunks) != summary.ChunkCount {
|
|
t.Errorf("expected %d chunks, got %d", summary.ChunkCount, len(summary.Chunks))
|
|
}
|
|
}
|
|
|
|
func TestRunProcessWritesRedactedRunMetadataArtifacts(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
workDir := t.TempDir()
|
|
outputPath := filepath.Join(t.TempDir(), "normalized.json")
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
primaryKey := "super-secret-primary-key"
|
|
validationKey := "super-secret-validation-key"
|
|
t.Setenv("AUDITA_LLM_API_KEY", primaryKey)
|
|
t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", validationKey)
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--transcript-description",
|
|
" scene takes place during a council hearing ",
|
|
"--total-llm-concurrency",
|
|
"3",
|
|
"--proposal-llm-concurrency",
|
|
"2",
|
|
"--validation-llm-concurrency",
|
|
"1",
|
|
"--output",
|
|
outputPath,
|
|
"--report-json",
|
|
reportPath,
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout when --output is provided, got %q", stdout.String())
|
|
}
|
|
|
|
// Existing report-json behavior should still work.
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "success" {
|
|
t.Fatalf("expected success report status, got %q", report.Status)
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
invocationPath := filepath.Join(runPath, "invocation.json")
|
|
configPath := filepath.Join(runPath, "effective-config.json")
|
|
|
|
if _, err := os.Stat(invocationPath); err != nil {
|
|
t.Fatalf("expected invocation metadata artifact: %v", err)
|
|
}
|
|
if _, err := os.Stat(configPath); err != nil {
|
|
t.Fatalf("expected effective config metadata artifact: %v", err)
|
|
}
|
|
|
|
invocationBytes := readFile(t, invocationPath)
|
|
configBytes := readFile(t, configPath)
|
|
if strings.Contains(string(invocationBytes), primaryKey) || strings.Contains(string(invocationBytes), validationKey) {
|
|
t.Fatalf("invocation artifact leaked API key material")
|
|
}
|
|
if strings.Contains(string(configBytes), primaryKey) || strings.Contains(string(configBytes), validationKey) {
|
|
t.Fatalf("effective config artifact leaked API key material")
|
|
}
|
|
|
|
var effectiveConfig struct {
|
|
TotalLLMConcurrency int `json:"TotalLLMConcurrency"`
|
|
ProposalLLMConcurrency int `json:"ProposalLLMConcurrency"`
|
|
ValidationLLMConcurrency *int `json:"ValidationLLMConcurrency"`
|
|
TranscriptDescription string `json:"TranscriptDescription"`
|
|
}
|
|
if err := json.Unmarshal(configBytes, &effectiveConfig); err != nil {
|
|
t.Fatalf("failed to parse effective config metadata: %v", err)
|
|
}
|
|
if effectiveConfig.TotalLLMConcurrency != 3 {
|
|
t.Fatalf("expected total_llm_concurrency=3 in effective config, got %d", effectiveConfig.TotalLLMConcurrency)
|
|
}
|
|
if effectiveConfig.ProposalLLMConcurrency != 2 {
|
|
t.Fatalf("expected proposal_llm_concurrency=2 in effective config, got %d", effectiveConfig.ProposalLLMConcurrency)
|
|
}
|
|
if effectiveConfig.ValidationLLMConcurrency == nil || *effectiveConfig.ValidationLLMConcurrency != 1 {
|
|
t.Fatalf("expected validation_llm_concurrency=1 in effective config, got %#v", effectiveConfig.ValidationLLMConcurrency)
|
|
}
|
|
if effectiveConfig.TranscriptDescription != "scene takes place during a council hearing" {
|
|
t.Fatalf("expected transcript description in effective config, got %q", effectiveConfig.TranscriptDescription)
|
|
}
|
|
|
|
var invocation struct {
|
|
Operation string `json:"operation"`
|
|
TranscriptPath string `json:"transcript_path"`
|
|
GlossaryPath string `json:"glossary_path"`
|
|
OutputPath string `json:"output_path"`
|
|
ReportJSONPath string `json:"report_json_path"`
|
|
TranscriptDescription string `json:"transcript_description"`
|
|
Modules []string `json:"modules"`
|
|
RunID string `json:"run_id"`
|
|
StartedAt string `json:"started_at"`
|
|
}
|
|
if err := json.Unmarshal(invocationBytes, &invocation); err != nil {
|
|
t.Fatalf("failed to parse invocation metadata: %v", err)
|
|
}
|
|
if invocation.Operation != "process" {
|
|
t.Fatalf("expected operation=process, got %q", invocation.Operation)
|
|
}
|
|
if invocation.TranscriptPath != fixturePath("tiny_transcript.json") {
|
|
t.Fatalf("unexpected transcript_path: %q", invocation.TranscriptPath)
|
|
}
|
|
if invocation.GlossaryPath != fixturePath("tiny_glossary.yaml") {
|
|
t.Fatalf("unexpected glossary_path: %q", invocation.GlossaryPath)
|
|
}
|
|
if invocation.OutputPath != outputPath {
|
|
t.Fatalf("unexpected output_path: %q", invocation.OutputPath)
|
|
}
|
|
if invocation.ReportJSONPath != reportPath {
|
|
t.Fatalf("unexpected report_json_path: %q", invocation.ReportJSONPath)
|
|
}
|
|
if invocation.TranscriptDescription != "scene takes place during a council hearing" {
|
|
t.Fatalf("unexpected transcript_description: %q", invocation.TranscriptDescription)
|
|
}
|
|
if len(invocation.Modules) == 0 {
|
|
t.Fatalf("expected non-empty modules list in invocation metadata")
|
|
}
|
|
if invocation.RunID == "" {
|
|
t.Fatalf("expected non-empty run_id in invocation metadata")
|
|
}
|
|
if invocation.StartedAt == "" {
|
|
t.Fatalf("expected non-empty started_at in invocation metadata")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessStdoutTranscriptWhenNoOutputWithRunMetadataArtifacts(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
workDir := t.TempDir()
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
if _, err := schema.ParseTranscriptJSON(stdout.Bytes()); err != nil {
|
|
t.Fatalf("expected stdout to contain only transcript JSON, got parse error: %v", err)
|
|
}
|
|
|
|
runPath := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runPath, "invocation.json")); err != nil {
|
|
t.Fatalf("expected invocation metadata artifact: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(runPath, "effective-config.json")); err != nil {
|
|
t.Fatalf("expected effective config metadata artifact: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessTargetSectionsThroughCLI(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello world"},
|
|
{"id":2,"speaker":"Bob","start":2.0,"end":3.0,"text":"Another test"},
|
|
{"id":3,"speaker":"Charlie","start":4.0,"end":5.0,"text":"Third segment"},
|
|
{"id":4,"speaker":"Alice","start":6.0,"end":7.0,"text":"Final segment"}
|
|
]`)
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--target-sections",
|
|
"2",
|
|
"--report-json",
|
|
reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Chunking == nil {
|
|
t.Fatalf("expected chunking summary in report")
|
|
}
|
|
if report.Chunking.ChunkCount != 2 {
|
|
t.Errorf("expected 2 chunks with --target-sections 2, got %d", report.Chunking.ChunkCount)
|
|
}
|
|
if report.Chunking.TargetSections == nil || *report.Chunking.TargetSections != 2 {
|
|
t.Errorf("expected target_sections=2 in report")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessImpossibleTargetSectionsFailsCleanly(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
// Create transcript with 2 segments
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"},
|
|
{"id":2,"speaker":"Bob","start":2.0,"end":3.0,"text":"World"}
|
|
]`)
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
workDir := t.TempDir()
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--target-sections",
|
|
"5",
|
|
"--report-json",
|
|
reportPath,
|
|
"--work-dir",
|
|
workDir,
|
|
"--work-dir-retention",
|
|
"always",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected nonzero exit code for impossible target-sections")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "target_sections") {
|
|
t.Errorf("expected target_sections error in stderr, got %q", stderr.String())
|
|
}
|
|
|
|
// Verify report was written with failure info
|
|
report := readProcessReport(t, reportPath)
|
|
if report.Status != "failed" {
|
|
t.Errorf("expected failed status, got %q", report.Status)
|
|
}
|
|
if report.ErrorPhase != "chunking" {
|
|
t.Errorf("expected error phase 'chunking', got %q", report.ErrorPhase)
|
|
}
|
|
|
|
// Verify run dir was retained with error.log
|
|
runPath := onlyRunDir(t, workDir)
|
|
if _, err := os.Stat(filepath.Join(runPath, "error.log")); err != nil {
|
|
t.Errorf("expected error.log in retained failed run: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessOutputTranscriptStillNormalizedOnly(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"},
|
|
{"id":2,"speaker":"Alice","start":1.5,"end":2.5,"text":"world"}
|
|
]`)
|
|
outputPath := filepath.Join(t.TempDir(), "normalized.json")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--output",
|
|
outputPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
// Verify output is the normalized transcript (same as before)
|
|
output := readFile(t, outputPath)
|
|
transcript, err := schema.ParseTranscriptJSON(output)
|
|
if err != nil {
|
|
t.Fatalf("expected valid transcript JSON: %v", err)
|
|
}
|
|
if len(transcript.Segments) != 1 {
|
|
t.Errorf("expected 1 merged segment, got %d", len(transcript.Segments))
|
|
}
|
|
// Text should be normalized (merged with space)
|
|
if transcript.Segments[0].Text != "Hello world" {
|
|
t.Errorf("expected merged text 'Hello world', got %q", transcript.Segments[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessReportJSONNotPrintedToStdout(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
transcriptPath := writeFile(t, "transcript.json", `[
|
|
{"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"}
|
|
]`)
|
|
reportPath := filepath.Join(t.TempDir(), "report.json")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
transcriptPath,
|
|
"--glossary",
|
|
fixturePath("tiny_glossary.yaml"),
|
|
"--report-json",
|
|
reportPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
|
|
}
|
|
|
|
// Verify stdout contains only transcript JSON, not report JSON
|
|
stdoutStr := stdout.String()
|
|
if strings.Contains(stdoutStr, "chunk_count") {
|
|
t.Errorf("stdout should not contain report JSON fields, got: %s", stdoutStr)
|
|
}
|
|
if strings.Contains(stdoutStr, "normalization_merges") {
|
|
t.Errorf("stdout should not contain report JSON fields, got: %s", stdoutStr)
|
|
}
|
|
|
|
// Verify transcript is valid JSON
|
|
if _, err := schema.ParseTranscriptJSON(stdout.Bytes()); err != nil {
|
|
t.Errorf("stdout should contain valid transcript JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunProcessDefaultOutputMatchesBareSegmentsSchema(t *testing.T) {
|
|
var stdoutDefault, stderrDefault bytes.Buffer
|
|
var stdoutBare, stderrBare bytes.Buffer
|
|
|
|
transcriptPath := fixturePath("tiny_transcript.json")
|
|
glossaryPath := fixturePath("tiny_glossary.yaml")
|
|
|
|
exitDefault := Run([]string{"process", transcriptPath, "--glossary", glossaryPath}, &stdoutDefault, &stderrDefault)
|
|
if exitDefault != 0 {
|
|
t.Fatalf("default run failed: %d stderr=%q", exitDefault, stderrDefault.String())
|
|
}
|
|
exitBare := Run([]string{"process", transcriptPath, "--glossary", glossaryPath, "--output-schema", "bare-segments"}, &stdoutBare, &stderrBare)
|
|
if exitBare != 0 {
|
|
t.Fatalf("bare-segments run failed: %d stderr=%q", exitBare, stderrBare.String())
|
|
}
|
|
if stdoutDefault.String() != stdoutBare.String() {
|
|
t.Fatalf("expected default output to match bare-segments output")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessOutputSchemaAuditaV1ToStdout(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--output-schema", "audita-v1",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
|
|
var out struct {
|
|
Schema string `json:"schema"`
|
|
Version string `json:"version"`
|
|
Segments []schema.Segment `json:"segments"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
|
t.Fatalf("expected audita-v1 JSON object output: %v", err)
|
|
}
|
|
if out.Schema != "audita-v1" || out.Version != "v1" {
|
|
t.Fatalf("unexpected schema metadata: schema=%q version=%q", out.Schema, out.Version)
|
|
}
|
|
if len(out.Segments) == 0 {
|
|
t.Fatalf("expected non-empty segments in audita-v1 output")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessOutputSchemaAuditaV1ToFile(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
outputPath := filepath.Join(t.TempDir(), "out.json")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--output-schema", "audita-v1",
|
|
"--output", outputPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout with --output, got %q", stdout.String())
|
|
}
|
|
|
|
var out map[string]any
|
|
if err := json.Unmarshal(readFile(t, outputPath), &out); err != nil {
|
|
t.Fatalf("expected valid audita-v1 JSON file: %v", err)
|
|
}
|
|
if out["schema"] != "audita-v1" {
|
|
t.Fatalf("expected audita-v1 schema in file output, got %#v", out["schema"])
|
|
}
|
|
}
|
|
|
|
func TestRunProcessOutputSchemaFromConfig(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\noutput:\n schema: audita-v1\n")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--config", cfgPath,
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
|
t.Fatalf("expected audita-v1 object from config output schema: %v", err)
|
|
}
|
|
if out["schema"] != "audita-v1" {
|
|
t.Fatalf("expected audita-v1 schema from config output, got %#v", out["schema"])
|
|
}
|
|
}
|
|
|
|
func TestRunProcessCLIOutputSchemaOverridesConfig(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cfgPath := writeFile(t, "config.yml", "version: 1\noutput:\n schema: audita-v1\n")
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--config", cfgPath,
|
|
"--output-schema", "bare-segments",
|
|
}, &stdout, &stderr)
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if strings.HasPrefix(strings.TrimSpace(stdout.String()), "{") {
|
|
t.Fatalf("expected bare-segments array output from CLI override, got object")
|
|
}
|
|
}
|
|
|
|
func TestRunProcessUnknownOutputSchemaFailsClearly(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{
|
|
"process",
|
|
fixturePath("tiny_transcript.json"),
|
|
"--glossary", fixturePath("tiny_glossary.yaml"),
|
|
"--output-schema", "seriatim-intermediate",
|
|
}, &stdout, &stderr)
|
|
if exitCode == 0 {
|
|
t.Fatalf("expected failure for unknown output schema")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "unsupported output schema") {
|
|
t.Fatalf("expected unsupported output schema error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func fixturePath(name string) string {
|
|
return filepath.Join("testdata", name)
|
|
}
|
|
|
|
func schemaFixturePath(name string) string {
|
|
return filepath.Join("..", "core", "schema", "testdata", name)
|
|
}
|
|
|
|
func writeFile(t *testing.T, name string, content string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), name)
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("failed to write test file %q: %v", path, err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func readFile(t *testing.T, path string) []byte {
|
|
return testsupport.ReadFile(t, path)
|
|
}
|
|
|
|
func readProcessReport(t *testing.T, path string) reporting.ProcessReport {
|
|
t.Helper()
|
|
raw := readFile(t, path)
|
|
var report reporting.ProcessReport
|
|
if err := json.Unmarshal(raw, &report); err != nil {
|
|
t.Fatalf("failed to parse process report JSON: %v (raw=%s)", err, string(raw))
|
|
}
|
|
return report
|
|
}
|
|
|
|
func onlyRunDir(t *testing.T, workDir string) string {
|
|
return testsupport.OnlyRunDir(t, workDir)
|
|
}
|