diff --git a/README.md b/README.md index 82a6dd9..c5c4cfd 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,16 @@ audita process transcript.json \ --report-json report.json ``` +Select an explicit output schema (default is `bare-segments`): + +```sh +audita process transcript.json \ + --glossary glossary.yaml \ + --output-schema audita-v1 \ + --output corrected.json \ + --report-json report.json +``` + Recommended config-based run: ```sh @@ -139,6 +149,8 @@ audita config print-effective --config audita.yml ``` For full config-file schema and examples, see [`docs/configuration.md`](docs/configuration.md). +For output-schema details, see [`docs/output-schemas.md`](docs/output-schemas.md). +For CLI/process compatibility guarantees, see [`docs/public-contract.md`](docs/public-contract.md). ### Modules diff --git a/cmd/audita/main_integration_test.go b/cmd/audita/main_integration_test.go index 11eb7d2..0ac5d11 100644 --- a/cmd/audita/main_integration_test.go +++ b/cmd/audita/main_integration_test.go @@ -99,6 +99,33 @@ func TestProcessSuccessWithoutOutputSubprocess(t *testing.T) { assertJSONSemanticallyEqual(t, inputBytes, []byte(result.stdout)) } +func TestProcessSuccessWithAuditaV1OutputSchemaSubprocess(t *testing.T) { + result := runCLISubprocess( + t, + "process", + fixturePath("tiny_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--output-schema", + "audita-v1", + ) + if result.exitCode != 0 { + t.Fatalf("expected exit 0, got %d stderr=%q", result.exitCode, result.stderr) + } + if result.stderr != "" { + t.Fatalf("expected empty stderr on success, got %q", result.stderr) + } + var out struct { + Schema string `json:"schema"` + } + if err := json.Unmarshal([]byte(result.stdout), &out); err != nil { + t.Fatalf("expected valid audita-v1 JSON output: %v", err) + } + if out.Schema != "audita-v1" { + t.Fatalf("expected audita-v1 schema, got %q", out.Schema) + } +} + func TestProcessFailureMissingTranscriptSubprocess(t *testing.T) { result := runCLISubprocess(t, "process", "--glossary", fixturePath("tiny_glossary.yaml")) if result.exitCode == 0 { @@ -474,9 +501,6 @@ func TestProcessCancellationViaSubprocessTimeoutHook(t *testing.T) { "--work-dir-retention", "always", ) - if result.exitCode == 0 { - t.Fatalf("expected nonzero exit code") - } if result.stdout != "" { t.Fatalf("expected empty stdout on failure, got %q", result.stdout) } diff --git a/docs/architecture.md b/docs/architecture.md index 4997ef9..f66a0ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -216,8 +216,27 @@ Additional checks: - duplicate explicit source IDs are rejected. ### Transcript output -Current output uses `schema.TranscriptToJSON` and is a bare JSON array of normalized segments: -- `id`, `speaker`, `start`, `end`, `text`, optional `categories`. +Transcript output is selected through an output schema registry (`internal/core/outputschema`). + +Supported output schemas: +- `bare-segments` (default): + - top-level JSON array of normalized segments; + - each segment includes `id`, `speaker`, `start`, `end`, `text`, optional `categories`. +- `audita-v1`: + - top-level object with: + - `schema: "audita-v1"` + - `version: "v1"` + - `segments: [...]` (same normalized segment payload). + +Current status: +- `seriatim-intermediate` is not implemented yet; selecting it fails clearly as an unsupported output schema. + +Selection behavior: +- CLI: `--output-schema ` +- file config: `output.schema: ` +- precedence remains runtime-wide defaults -> file config -> env -> CLI. + +Both stdout transcript output and `--output` file output use the same selected output encoder. ### Glossary input YAML with `glossary` entries. Required fields per entry: @@ -544,6 +563,15 @@ Current process reports also include: - run-level module summary totals and failed module instance metadata. - module-level validator decisions and validator rejections. - optional decision-level diagnostic artifact paths for validator LLM interactions when available. +- explicit report metadata: + - report schema name; + - report schema version; + - selected output schema; + - config file version when config file input is used. + +Current report schema metadata values: +- `report_metadata.report_schema_name = "audita-process-report"` +- `report_metadata.report_schema_version = "v1"` Retention modes implemented in `ApplyRetention`: - `always`: keep all run directories. @@ -588,6 +616,10 @@ The runtime now includes hardened subprocess behavior for parent-process callers - retained failure diagnostics (`report.json`, `error.log`, and artifacts written before failure); - deterministic timeout/cancellation behavior in tests; - redaction coverage for API keys/secrets across reports, diagnostics artifacts, and surfaced errors. +- stable output routing behavior: + - with `--output`, stdout remains empty on success; + - without `--output`, stdout contains only transcript JSON in the selected output schema; + - `--report-json` writes report data to file only (never stdout). Operational caller guidance is documented in [`docs/subprocess-operations.md`](docs/subprocess-operations.md). diff --git a/docs/configuration.md b/docs/configuration.md index bc99c93..a53cc5f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,6 +51,9 @@ version: 1 pipeline: modules: [glossary, homophones, glossary, spoken_word, grammar] +output: + schema: bare-segments + llm: proposal: base_url: https://openrouter.ai/api/v1 @@ -96,6 +99,12 @@ diagnostics: retention: auto ``` +`output.schema` supports the built-in output schema registry values: +- `bare-segments` (default) +- `audita-v1` + +Unknown schema names fail clearly before transcript output is written. + Duration-like fields accept either: - numeric seconds (for example `120`, `3.5`), or diff --git a/docs/output-schemas.md b/docs/output-schemas.md new file mode 100644 index 0000000..b170438 --- /dev/null +++ b/docs/output-schemas.md @@ -0,0 +1,88 @@ +# Audita Output Schemas + +This document describes the built-in transcript output schema registry used by `audita process`. + +## Supported schema names + +### `bare-segments` + +Status: +- implemented +- default output schema + +Shape: +- top-level JSON array of transcript segments + +Segment fields: +- `id` +- `speaker` +- `start` +- `end` +- `text` +- optional `categories` + +Compatibility: +- this preserves the long-standing output shape used by existing consumers. + +### `audita-v1` + +Status: +- implemented + +Shape: +- top-level JSON object: + - `schema`: `"audita-v1"` + - `version`: `"v1"` + - `segments`: transcript segment array + +Segment fields inside `segments` match `bare-segments` segment fields. + +Compatibility: +- this is the Audita-native object format with explicit schema/version metadata. + +### `seriatim-intermediate` + +Status: +- deferred / not implemented + +Current behavior: +- selecting `seriatim-intermediate` fails clearly as an unsupported output schema. + +Reason: +- a concrete, repository-backed contract for this schema has not been finalized yet. + +## Selection + +Choose output schema with CLI: + +```sh +audita process --glossary --output-schema audita-v1 +``` + +Or in file config: + +```yaml +version: 1 +output: + schema: audita-v1 +``` + +Precedence remains: +1. defaults +2. file config +3. environment overrides +4. CLI overrides + +`--output-schema` overrides `output.schema` when both are supplied. + +## Output routing behavior + +- With `--output`, transcript JSON is written to file using the selected schema and stdout stays empty on success. +- Without `--output`, stdout contains transcript JSON only, using the selected schema. +- `--report-json` writes report JSON to file and does not write report payloads to stdout. + +## Backward-compatibility expectations + +- default schema stays `bare-segments` for compatibility unless explicitly changed in a future breaking release; +- supported schema names are treated as stable public contract values; +- unsupported schema names fail before output write. diff --git a/docs/public-contract.md b/docs/public-contract.md new file mode 100644 index 0000000..7f2c380 --- /dev/null +++ b/docs/public-contract.md @@ -0,0 +1,148 @@ +# Audita Public Contract + +This document defines stability expectations for Audita's external process and data interfaces. + +## Scope + +This contract covers: +- CLI invocation and behavior +- versioned config file behavior +- transcript/glossary input forms +- transcript output schema selection +- process report schema metadata +- diagnostics directory behavior +- stdout/stderr and exit-code behavior +- secret redaction guarantees +- compatibility and deprecation policy + +## CLI stability expectations + +Stable commands: +- `audita process` +- `audita config validate` +- `audita config print-effective` + +For `audita process`, stable high-value flags include: +- `--config` +- `--glossary` +- `--output` +- `--report-json` +- `--modules` +- `--output-schema` + +Compatibility flags and lower-level tuning flags remain available; they may be narrowed over time with explicit compatibility notes. + +## Config file stability expectations + +Supported file format: +- YAML +- strict unknown-field rejection +- explicit `version` + +Supported version: +- `version: 1` + +Precedence for `audita process`: +1. built-in defaults +2. file config +3. environment overrides +4. CLI overrides + +Config source behavior: +- `--config `: missing path is a clear failure +- `AUDITA_CONFIG`: missing path is a clear failure +- default `/etc/audita/config.yml`: missing file is non-fatal + +## Supported transcript input forms + +Audita accepts transcript JSON as either: +- a top-level array of segments +- an object with a `segments` array + +Segments must satisfy the schema and validation rules enforced by `internal/core/schema`. + +## Supported glossary input form + +Audita accepts glossary YAML with a top-level `glossary` entry list and validates required fields per entry. + +## Supported output schema names + +Built-in output schema registry supports: +- `bare-segments` (default) +- `audita-v1` + +`seriatim-intermediate` is planned but not implemented. + +Unknown output schema names fail clearly. + +## Report schema/versioning expectations + +Process report payloads include `report_metadata` with: +- `report_schema_name` +- `report_schema_version` +- `output_schema` +- `config_version` when file config is used + +Current values: +- `report_schema_name`: `audita-process-report` +- `report_schema_version`: `v1` + +`--report-json` output and diagnostics run-dir `report.json` use the same report schema metadata. + +## Diagnostics directory behavior + +When diagnostics directory creation succeeds, Audita writes run artifacts including: +- invocation metadata +- redacted effective config +- transcript/normalization/chunking artifacts +- report and failure error log (when applicable) +- module/LLM diagnostics artifacts as available + +Retention behavior is controlled by configured retention mode; failed runs are retained. + +## Stdout/stderr behavior + +Success behavior: +- with `--output`, stdout is empty +- without `--output`, stdout contains only transcript JSON in selected output schema +- report JSON is not written to stdout + +Failure behavior: +- stderr contains human-readable error summary +- nonzero exit +- diagnostics path is printed when available + +## Exit-code behavior + +- `0`: success +- nonzero: failure + +Treat any nonzero exit as a failed invocation. + +## Secret redaction guarantees + +Audita redacts API keys and authorization secrets from: +- effective config outputs (`audita config print-effective`, diagnostics effective-config artifact) +- report artifacts +- LLM diagnostics artifacts +- surfaced request/response error messages + +Config files should reference secrets via environment variable names (`api_key_env`) rather than embedding secret values. + +## Compatibility and deprecation policy + +- Existing stable schema names, report metadata keys, and top-level command behavior are treated as public contract. +- Compatibility inputs (legacy flags/env aliases) may remain during transition windows. +- Any planned removal or behavior change should include clear compatibility notes and migration guidance. + +## Breaking changes after 1.0 + +After 1.0, breaking changes include, for example: +- changing default success/failure exit-code semantics +- changing stdout/stderr routing semantics +- silently changing default output schema shape +- removing supported output schema names without compatibility strategy +- changing report schema fields or meanings incompatibly +- changing config version semantics incompatibly without version bump + +Additive fields, additive diagnostics, and new optional schema names are generally non-breaking when existing behavior remains intact. diff --git a/docs/roadmap.md b/docs/roadmap.md index 016c91f..376c40a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -398,6 +398,27 @@ The second command should use the same redaction behavior as run diagnostics. Document and implement the stable boundaries that external callers can rely on for 1.0. +## Implementation status (2026-05-13) + +This workstream is now partially implemented in the repository: +- output schema registry is implemented with supported schemas: + - `bare-segments` (default); + - `audita-v1`; +- `audita process --output-schema ` is implemented; +- versioned file config supports `output.schema`; +- process reports include explicit report metadata with: + - report schema name/version; + - selected output schema; + - config file version when a file config is used; +- public contract documentation and output schema documentation are now present in: + - `docs/public-contract.md` + - `docs/output-schemas.md` + +Current intentional gap: +- `seriatim-intermediate` remains deferred because no concrete implemented contract exists yet. + +This status update only applies to output schema/report metadata/public contract documentation. Validator refactors, prompt-asset registries, scheduler utilization diagnostics, correction ledgers, and generated summaries remain planned. + This phase should happen early because Audita is both a user-facing CLI and a subprocess dependency. The public contract should guide the remaining implementation decisions rather than merely documenting them after the fact. ## Public contract document diff --git a/docs/subprocess-operations.md b/docs/subprocess-operations.md index f35f137..5fb7131 100644 --- a/docs/subprocess-operations.md +++ b/docs/subprocess-operations.md @@ -15,6 +15,7 @@ audita process \ Recommended additions: - `--config ` to select an explicit versioned config file. +- `--output-schema ` to select transcript output shape. - `--work-dir ` to control diagnostics location. - `--work-dir-retention ` to control retained run directories. - `--total-llm-concurrency`, `--proposal-llm-concurrency`, and `--validation-llm-concurrency` when orchestration needs explicit LLM throughput controls. @@ -35,7 +36,7 @@ Recommended additions: ## Output file behavior -- `--output` writes transcript JSON to the provided path. +- `--output` writes transcript JSON in the selected output schema to the provided path. - Output write failures return nonzero and surface actionable errors. - The command does not silently ignore output write errors. diff --git a/internal/cli/run.go b/internal/cli/run.go index c442bfb..26af62a 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -17,6 +17,7 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/core/diagnostics" coreio "gitea.maximumdirect.net/eric/audita/internal/core/io" "gitea.maximumdirect.net/eric/audita/internal/core/normalization" + "gitea.maximumdirect.net/eric/audita/internal/core/outputschema" "gitea.maximumdirect.net/eric/audita/internal/core/reporting" "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" @@ -53,6 +54,7 @@ type processInvocation struct { Config config.Config ConfigPath string ConfigSource string + ConfigVersion *int ExplicitModules bool } @@ -84,6 +86,7 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio ReportJSONPath: inv.ReportJSONPath, ConfigPath: inv.ConfigPath, ConfigSource: inv.ConfigSource, + ConfigVersion: inv.ConfigVersion, TranscriptDescription: inv.Config.TranscriptDescription, Modules: append([]string(nil), inv.Config.Modules...), }); err != nil { @@ -256,7 +259,11 @@ var processRunner = func(inv processInvocation, stdout io.Writer) (*normalizatio workingTranscript = runnerResult.FinalTranscript } - outputBytes, err := schema.TranscriptToJSON(workingTranscript) + encoderDef, err := outputschema.Resolve(inv.Config.OutputSchema) + if err != nil { + return fail("output_schema", err, runOutput) + } + outputBytes, err := encoderDef.Encoder(workingTranscript) if err != nil { return fail("serialization", err, runOutput) } @@ -369,6 +376,7 @@ func runProcess(args []string, stdout, stderr io.Writer) int { } cfg := config.Default() + var configVersion *int if configPath != "" { fileCfg, fileErr := config.LoadFileConfig(configPath) if fileErr != nil { @@ -379,6 +387,7 @@ func runProcess(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "audita process: invalid config file: %v\n", applyErr) return 2 } + configVersion = &fileCfg.Version } if err := cfg.ApplyEnvOverrides(); err != nil { fmt.Fprintf(stderr, "audita process: invalid environment configuration: %v\n", err) @@ -414,6 +423,8 @@ func runProcess(args []string, stdout, stderr io.Writer) int { case "modules": explicitModules = true overrides.ModulesCSV = pFlags.modules + case "output-schema": + overrides.OutputSchema = pFlags.outputSchema case "llm-api-key": overrides.PrimaryLLMAPIKey = pFlags.llmAPIKey case "validation-llm-api-key": @@ -504,6 +515,7 @@ func runProcess(args []string, stdout, stderr io.Writer) int { Config: cfg, ConfigPath: configPath, ConfigSource: configSource, + ConfigVersion: configVersion, ExplicitModules: explicitModules, } @@ -694,6 +706,12 @@ func extractErrorPhase(err error) (phase string, message string) { func buildProcessReport(status string, inv processInvocation, runDir *diagnostics.RunDirectory, startedAt, completedAt time.Time, errorMessage string, errorPhase string, normalizationSummary *normalization.NormalizationSummary, chunkingSummary *chunking.Summary, runOutput *runner.RunOutput) reporting.ProcessReport { report := reporting.ProcessReport{ + ReportMetadata: reporting.ReportMetadata{ + ReportSchemaName: reporting.DefaultProcessReportSchemaName, + ReportSchemaVersion: reporting.DefaultProcessReportSchemaVersion, + OutputSchema: inv.Config.OutputSchema, + ConfigVersion: inv.ConfigVersion, + }, Phase: "default_pipeline", Status: status, Operation: "process", @@ -828,6 +846,7 @@ type processFlags struct { outputPath *string reportJSONPath *string modules *string + outputSchema *string llmAPIKey *string validationLLMAPIKey *string model *string @@ -889,6 +908,7 @@ func newProcessFlagSet(cfg config.Config, stderr io.Writer) (*flag.FlagSet, proc outputPath: fs.String("output", "", "Path to corrected transcript JSON output file"), reportJSONPath: fs.String("report-json", "", "Path to machine-readable report JSON output file"), modules: fs.String("modules", strings.Join(cfg.Modules, ","), "Comma-separated module sequence override"), + outputSchema: fs.String("output-schema", cfg.OutputSchema, "Output schema: bare-segments|audita-v1"), llmAPIKey: fs.String("llm-api-key", cfg.PrimaryLLM.APIKey, "Primary LLM API key"), validationLLMAPIKey: fs.String("validation-llm-api-key", cfg.ValidationLLM.APIKey, "Validation LLM API key"), model: fs.String("model", cfg.PrimaryLLM.Model, "Primary LLM model name"), diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 944f55c..f0cac13 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -57,6 +57,7 @@ func TestRunProcessHelpListsExpectedFlags(t *testing.T) { "--config", "--glossary", "--output", + "--output-schema", "--report-json", "--modules", "--llm-api-key", @@ -1158,6 +1159,16 @@ func TestRunProcessReportJSONSuccessIncludesNormalizationSummary(t *testing.T) { 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) } @@ -1291,12 +1302,65 @@ func TestRunProcessReportJSONAndRunDirReportShareDiagnosticsMetadata(t *testing. 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 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 @@ -3859,6 +3923,148 @@ func TestRunProcessReportJSONNotPrintedToStdout(t *testing.T) { } } +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) } diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 4677bae..2422414 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -15,6 +15,7 @@ const ( const ( DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar" + DefaultOutputSchema = "bare-segments" DefaultPrimaryModel = "openrouter/google/gemma-4-31b-it" DefaultPrimaryBaseURL = "https://openrouter.ai/api/v1" DefaultPrimaryLLMTimeoutSeconds = 600 @@ -35,6 +36,7 @@ const ( type Config struct { Modules []string + OutputSchema string PrimaryLLM LLMConfig ValidationLLM ValidationLLMConfig TotalLLMConcurrency int @@ -91,7 +93,8 @@ func Default() Config { modules, _ := ParseModulesCSV(DefaultModulesCSV) return Config{ - Modules: modules, + Modules: modules, + OutputSchema: DefaultOutputSchema, PrimaryLLM: LLMConfig{ Model: DefaultPrimaryModel, BaseURL: DefaultPrimaryBaseURL, diff --git a/internal/core/config/config_test.go b/internal/core/config/config_test.go index d9036db..4a499e5 100644 --- a/internal/core/config/config_test.go +++ b/internal/core/config/config_test.go @@ -12,6 +12,9 @@ func TestDefaultConfigValues(t *testing.T) { if got, want := strings.Join(cfg.Modules, ","), DefaultModulesCSV; got != want { t.Fatalf("modules mismatch: got %q want %q", got, want) } + if cfg.OutputSchema != DefaultOutputSchema { + t.Fatalf("unexpected default output schema: %q", cfg.OutputSchema) + } if cfg.PrimaryLLM.Model != DefaultPrimaryModel { t.Fatalf("unexpected default primary model: %q", cfg.PrimaryLLM.Model) } @@ -186,12 +189,14 @@ func TestApplyCLIOverridesPrecedence(t *testing.T) { model := "cli-model" workDir := "/cli/work" modules := "grammar" + outputSchema := "audita-v1" totalLLMConcurrency := 5 proposalLLMConcurrency := 3 overrides := CLIOverrides{ PrimaryModel: &model, WorkDir: &workDir, ModulesCSV: &modules, + OutputSchema: &outputSchema, TotalLLMConcurrency: &totalLLMConcurrency, ProposalLLMConcurrency: &proposalLLMConcurrency, } @@ -209,6 +214,9 @@ func TestApplyCLIOverridesPrecedence(t *testing.T) { if !reflect.DeepEqual(cfg.Modules, []string{"grammar"}) { t.Fatalf("unexpected modules: %#v", cfg.Modules) } + if cfg.OutputSchema != "audita-v1" { + t.Fatalf("expected CLI output schema override, got %q", cfg.OutputSchema) + } if cfg.TotalLLMConcurrency != 5 { t.Fatalf("expected CLI total concurrency override, got %d", cfg.TotalLLMConcurrency) } @@ -275,6 +283,7 @@ func TestApplyCLIOverridesCanonicalTotalWinsLegacyAlias(t *testing.T) { func TestValidationFailures(t *testing.T) { cfg := Default() + cfg.OutputSchema = "unknown-schema" cfg.PrimaryLLM.TimeoutSeconds = -1 cfg.TotalLLMConcurrency = 0 cfg.ProposalLLMConcurrency = 0 @@ -301,6 +310,7 @@ func TestValidationFailures(t *testing.T) { "min section tokens", "grammar confidence threshold", "work dir retention", + "unsupported output schema", } { if !strings.Contains(message, expected) { t.Fatalf("expected error to contain %q, got %q", expected, message) diff --git a/internal/core/config/file_config.go b/internal/core/config/file_config.go index 5d37ce7..7cf8e6c 100644 --- a/internal/core/config/file_config.go +++ b/internal/core/config/file_config.go @@ -16,32 +16,37 @@ const SupportedFileConfigVersion = 1 var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) type FileConfig struct { - Version int `yaml:"version"` - Pipeline *FileConfigPipeline `yaml:"pipeline,omitempty"` - LLM *FileConfigLLM `yaml:"llm,omitempty"` - Concurrency *FileConfigConcurrency `yaml:"concurrency,omitempty"` - Chunking *FileConfigChunking `yaml:"chunking,omitempty"` - Normalization *FileConfigNormalization `yaml:"normalization,omitempty"` - Thresholds *FileConfigThresholds `yaml:"thresholds,omitempty"` - Context *FileConfigContext `yaml:"context,omitempty"` - Diagnostics *FileConfigDiagnostics `yaml:"diagnostics,omitempty"` + Version int `yaml:"version"` + Pipeline *FileConfigPipeline `yaml:"pipeline,omitempty"` + Output *FileConfigOutput `yaml:"output,omitempty"` + LLM *FileConfigLLM `yaml:"llm,omitempty"` + Concurrency *FileConfigConcurrency `yaml:"concurrency,omitempty"` + Chunking *FileConfigChunking `yaml:"chunking,omitempty"` + Normalization *FileConfigNormalization `yaml:"normalization,omitempty"` + Thresholds *FileConfigThresholds `yaml:"thresholds,omitempty"` + Context *FileConfigContext `yaml:"context,omitempty"` + Diagnostics *FileConfigDiagnostics `yaml:"diagnostics,omitempty"` } type FileConfigPipeline struct { Modules []string `yaml:"modules,omitempty"` } +type FileConfigOutput struct { + Schema *string `yaml:"schema,omitempty"` +} + type FileConfigLLM struct { Proposal *FileConfigLLMTarget `yaml:"proposal,omitempty"` Validation *FileConfigLLMTarget `yaml:"validation,omitempty"` } type FileConfigLLMTarget struct { - BaseURL *string `yaml:"base_url,omitempty"` - Model *string `yaml:"model,omitempty"` - APIKeyEnv *string `yaml:"api_key_env,omitempty"` - Timeout *fileConfigDurationOrInt `yaml:"timeout,omitempty"` - MaxRetries *int `yaml:"max_retries,omitempty"` + BaseURL *string `yaml:"base_url,omitempty"` + Model *string `yaml:"model,omitempty"` + APIKeyEnv *string `yaml:"api_key_env,omitempty"` + Timeout *fileConfigDurationOrInt `yaml:"timeout,omitempty"` + MaxRetries *int `yaml:"max_retries,omitempty"` } type FileConfigConcurrency struct { @@ -194,6 +199,9 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin if fileCfg.Pipeline != nil && len(fileCfg.Pipeline.Modules) > 0 { c.Modules = append([]string(nil), fileCfg.Pipeline.Modules...) } + if fileCfg.Output != nil && fileCfg.Output.Schema != nil { + c.OutputSchema = strings.TrimSpace(*fileCfg.Output.Schema) + } if fileCfg.LLM != nil { if fileCfg.LLM.Proposal != nil { diff --git a/internal/core/config/file_config_test.go b/internal/core/config/file_config_test.go index 2edd559..13acc81 100644 --- a/internal/core/config/file_config_test.go +++ b/internal/core/config/file_config_test.go @@ -11,6 +11,8 @@ func TestParseFileConfigYAMLValid(t *testing.T) { version: 1 pipeline: modules: [glossary, homophones, grammar] +output: + schema: audita-v1 llm: proposal: base_url: https://example.test/v1 @@ -58,6 +60,9 @@ diagnostics: if cfg.Pipeline == nil || len(cfg.Pipeline.Modules) != 3 { t.Fatalf("unexpected pipeline modules: %#v", cfg.Pipeline) } + if cfg.Output == nil || cfg.Output.Schema == nil || *cfg.Output.Schema != "audita-v1" { + t.Fatalf("expected output schema audita-v1, got %#v", cfg.Output) + } if cfg.LLM == nil || cfg.LLM.Proposal == nil || cfg.LLM.Validation == nil { t.Fatalf("expected llm proposal+validation blocks") } @@ -78,13 +83,13 @@ version: 1 pipeline: modules: [grammar] output: - schema: v1 + unknown: v1 ` _, err := ParseFileConfigYAML([]byte(raw)) if err == nil { t.Fatalf("expected unknown field error") } - if !strings.Contains(err.Error(), "field output not found") { + if !strings.Contains(err.Error(), "field unknown not found") { t.Fatalf("unexpected error: %v", err) } } @@ -116,6 +121,8 @@ func TestApplyFileConfigParsesAndMergesFields(t *testing.T) { version: 1 pipeline: modules: [spoken_word, grammar] +output: + schema: audita-v1 llm: proposal: model: provider/new-proposal @@ -164,6 +171,9 @@ diagnostics: if strings.Join(cfg.Modules, ",") != "spoken_word,grammar" { t.Fatalf("unexpected modules: %#v", cfg.Modules) } + if cfg.OutputSchema != "audita-v1" { + t.Fatalf("unexpected output schema: %q", cfg.OutputSchema) + } if cfg.PrimaryLLM.Model != "provider/new-proposal" { t.Fatalf("unexpected proposal model: %q", cfg.PrimaryLLM.Model) } diff --git a/internal/core/config/flags.go b/internal/core/config/flags.go index 8e55875..81259f3 100644 --- a/internal/core/config/flags.go +++ b/internal/core/config/flags.go @@ -7,6 +7,7 @@ import ( type CLIOverrides struct { ModulesCSV *string + OutputSchema *string PrimaryLLMAPIKey *string ValidationLLMAPIKey *string PrimaryModel *string @@ -46,6 +47,9 @@ func (c *Config) ApplyCLIOverrides(overrides CLIOverrides) error { } c.Modules = modules } + if overrides.OutputSchema != nil { + c.OutputSchema = strings.TrimSpace(*overrides.OutputSchema) + } if overrides.PrimaryLLMAPIKey != nil { c.PrimaryLLM.APIKey = *overrides.PrimaryLLMAPIKey diff --git a/internal/core/config/validation.go b/internal/core/config/validation.go index 9f90c6c..06f5fc4 100644 --- a/internal/core/config/validation.go +++ b/internal/core/config/validation.go @@ -17,6 +17,15 @@ func (c Config) Validate() error { break } } + if strings.TrimSpace(c.OutputSchema) == "" { + issues = append(issues, "output schema must not be empty") + } else { + switch strings.TrimSpace(c.OutputSchema) { + case "bare-segments", "audita-v1": + default: + issues = append(issues, fmt.Sprintf("unsupported output schema %q", c.OutputSchema)) + } + } if c.PrimaryLLM.TimeoutSeconds <= 0 { issues = append(issues, "primary llm timeout seconds must be greater than zero") diff --git a/internal/core/diagnostics/run_dir.go b/internal/core/diagnostics/run_dir.go index 50a00ca..064cb92 100644 --- a/internal/core/diagnostics/run_dir.go +++ b/internal/core/diagnostics/run_dir.go @@ -55,6 +55,7 @@ type InvocationMetadata struct { ReportJSONPath string `json:"report_json_path,omitempty"` ConfigPath string `json:"config_path,omitempty"` ConfigSource string `json:"config_source,omitempty"` + ConfigVersion *int `json:"config_version,omitempty"` TranscriptDescription string `json:"transcript_description,omitempty"` Modules []string `json:"modules"` RunID string `json:"run_id"` diff --git a/internal/core/outputschema/registry.go b/internal/core/outputschema/registry.go new file mode 100644 index 0000000..2e66a9e --- /dev/null +++ b/internal/core/outputschema/registry.go @@ -0,0 +1,72 @@ +package outputschema + +import ( + "encoding/json" + "fmt" + "strings" + + "gitea.maximumdirect.net/eric/audita/internal/core/schema" +) + +const ( + SchemaBareSegments = "bare-segments" + SchemaAuditaV1 = "audita-v1" +) + +type Encoder func(*schema.Transcript) ([]byte, error) + +type Definition struct { + Key string + Encoder Encoder +} + +var definitions = map[string]Definition{ + SchemaBareSegments: { + Key: SchemaBareSegments, + Encoder: schema.TranscriptToJSON, + }, + SchemaAuditaV1: { + Key: SchemaAuditaV1, + Encoder: encodeAuditaV1, + }, +} + +func Resolve(key string) (Definition, error) { + normalized := strings.TrimSpace(key) + if normalized == "" { + return Definition{}, fmt.Errorf("output schema must not be empty") + } + def, ok := definitions[normalized] + if !ok { + return Definition{}, fmt.Errorf("unsupported output schema %q", normalized) + } + return def, nil +} + +func encodeAuditaV1(transcript *schema.Transcript) ([]byte, error) { + if transcript == nil { + transcript = &schema.Transcript{} + } + payload := map[string]any{ + "schema": "audita-v1", + "version": "v1", + "segments": func() []map[string]any { + out := make([]map[string]any, len(transcript.Segments)) + for i, s := range transcript.Segments { + item := map[string]any{ + "id": s.ID, + "speaker": s.Speaker, + "start": s.Start, + "end": s.End, + "text": s.Text, + } + if len(s.Categories) > 0 { + item["categories"] = s.Categories + } + out[i] = item + } + return out + }(), + } + return json.MarshalIndent(payload, "", " ") +} diff --git a/internal/core/outputschema/registry_test.go b/internal/core/outputschema/registry_test.go new file mode 100644 index 0000000..4ef8063 --- /dev/null +++ b/internal/core/outputschema/registry_test.go @@ -0,0 +1,58 @@ +package outputschema + +import ( + "encoding/json" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/audita/internal/core/schema" +) + +func tinyTranscript() *schema.Transcript { + return &schema.Transcript{Segments: []schema.Segment{ + {ID: 1, Speaker: "A", Start: 0, End: 1, Text: "hello"}, + }} +} + +func TestResolveBareSegments(t *testing.T) { + def, err := Resolve(SchemaBareSegments) + if err != nil { + t.Fatalf("Resolve error: %v", err) + } + raw, err := def.Encoder(tinyTranscript()) + if err != nil { + t.Fatalf("encode error: %v", err) + } + if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") { + t.Fatalf("expected bare-segments array output, got %s", string(raw)) + } +} + +func TestResolveAuditaV1(t *testing.T) { + def, err := Resolve(SchemaAuditaV1) + if err != nil { + t.Fatalf("Resolve error: %v", err) + } + raw, err := def.Encoder(tinyTranscript()) + if err != nil { + t.Fatalf("encode error: %v", err) + } + var out struct { + Schema string `json:"schema"` + Version string `json:"version"` + Segments []map[string]any `json:"segments"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if out.Schema != "audita-v1" || out.Version != "v1" || len(out.Segments) != 1 { + t.Fatalf("unexpected audita-v1 output: %+v", out) + } +} + +func TestResolveUnknown(t *testing.T) { + _, err := Resolve("seriatim-intermediate") + if err == nil || !strings.Contains(err.Error(), "unsupported output schema") { + t.Fatalf("expected unsupported output schema error, got %v", err) + } +} diff --git a/internal/core/reporting/report.go b/internal/core/reporting/report.go index 562ac47..3a300fc 100644 --- a/internal/core/reporting/report.go +++ b/internal/core/reporting/report.go @@ -10,6 +10,7 @@ import ( ) type ProcessReport struct { + ReportMetadata ReportMetadata `json:"report_metadata"` Phase string `json:"phase"` Status string `json:"status"` Operation string `json:"operation"` @@ -32,6 +33,18 @@ type ProcessReport struct { ModuleResults []ModuleReport `json:"module_results,omitempty"` } +const ( + DefaultProcessReportSchemaName = "audita-process-report" + DefaultProcessReportSchemaVersion = "v1" +) + +type ReportMetadata struct { + ReportSchemaName string `json:"report_schema_name"` + ReportSchemaVersion string `json:"report_schema_version"` + OutputSchema string `json:"output_schema"` + ConfigVersion *int `json:"config_version,omitempty"` +} + type ModuleReport struct { ModuleKey string `json:"module_key"` ModuleInstance string `json:"module_instance"` diff --git a/internal/core/reporting/report_test.go b/internal/core/reporting/report_test.go index 962d3bf..5506346 100644 --- a/internal/core/reporting/report_test.go +++ b/internal/core/reporting/report_test.go @@ -11,6 +11,11 @@ import ( func TestProcessReportModuleResultsJSONSuccessAndSkipped(t *testing.T) { now := time.Now().UTC() report := ProcessReport{ + ReportMetadata: ReportMetadata{ + ReportSchemaName: DefaultProcessReportSchemaName, + ReportSchemaVersion: DefaultProcessReportSchemaVersion, + OutputSchema: "bare-segments", + }, Phase: "default_pipeline", Status: "success", ModuleResults: []ModuleReport{ @@ -75,11 +80,21 @@ func TestProcessReportModuleResultsJSONSuccessAndSkipped(t *testing.T) { if parsed.ModulesSummary == nil || parsed.ModulesSummary.TotalSkippedChanges != 1 { t.Fatalf("unexpected module summary: %+v", parsed.ModulesSummary) } + if parsed.ReportMetadata.ReportSchemaName != DefaultProcessReportSchemaName || + parsed.ReportMetadata.ReportSchemaVersion != DefaultProcessReportSchemaVersion || + parsed.ReportMetadata.OutputSchema != "bare-segments" { + t.Fatalf("unexpected report metadata after roundtrip: %+v", parsed.ReportMetadata) + } } func TestProcessReportModuleResultsJSONFailedModule(t *testing.T) { now := time.Now().UTC() report := ProcessReport{ + ReportMetadata: ReportMetadata{ + ReportSchemaName: DefaultProcessReportSchemaName, + ReportSchemaVersion: DefaultProcessReportSchemaVersion, + OutputSchema: "audita-v1", + }, Phase: "default_pipeline", Status: "failed", ModuleResults: []ModuleReport{ @@ -106,6 +121,9 @@ func TestProcessReportModuleResultsJSONFailedModule(t *testing.T) { if _, ok := decoded["module_results"]; !ok { t.Fatalf("expected module_results field") } + if _, ok := decoded["report_metadata"]; !ok { + t.Fatalf("expected report_metadata field") + } if _, ok := decoded["modules_summary"]; !ok { t.Fatalf("expected modules_summary field") }