Wire schema validation into process command

This commit is contained in:
2026-05-11 00:01:42 +00:00
parent 950edc01f2
commit 3cfa4b6e8a
2 changed files with 178 additions and 42 deletions

View File

@@ -9,8 +9,9 @@ import (
"time"
"gitea.maximumdirect.net/eric/audita/internal/core/config"
coreio "gitea.maximumdirect.net/eric/audita/internal/core/io"
"gitea.maximumdirect.net/eric/audita/internal/core/io"
"gitea.maximumdirect.net/eric/audita/internal/core/reporting"
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
)
type processInvocation struct {
@@ -22,25 +23,61 @@ type processInvocation struct {
}
var processRunner = func(inv processInvocation, stdout io.Writer) error {
transcriptBytes, err := coreio.ReadRequiredFile(inv.TranscriptPath, "transcript")
transcriptBytes, err := io.ReadRequiredFile(inv.TranscriptPath, "transcript")
if err != nil {
return err
}
if err := coreio.ValidateWellFormedJSON(inv.TranscriptPath, transcriptBytes); err != nil {
return err
}
if _, err := coreio.ReadRequiredFile(inv.GlossaryPath, "glossary"); err != nil {
glossaryBytes, err := io.ReadRequiredFile(inv.GlossaryPath, "glossary")
if err != nil {
return err
}
// Parse and validate transcript using typed schema
transcript, err := schema.ParseSourceTranscriptJSON(transcriptBytes)
if err != nil {
return fmt.Errorf("invalid transcript: %w", err)
}
// Parse and validate glossary using typed schema
glossary, err := schema.ParseGlossaryYAML(glossaryBytes)
if err != nil {
return fmt.Errorf("invalid glossary: %w", err)
}
// Convert to canonical transcript format for output
outputTranscript := &schema.Transcript{
Segments: make([]schema.Segment, len(transcript.Segments)),
}
for i, s := range transcript.Segments {
id := i + 1
if s.ID != nil {
id = *s.ID
}
outputTranscript.Segments[i] = schema.Segment{
ID: id,
Speaker: s.Speaker,
Start: s.Start,
End: s.End,
Text: s.Text,
Categories: s.Categories,
}
}
// Serialize output transcript to JSON
outputBytes, err := schema.TranscriptToJSON(outputTranscript)
if err != nil {
return fmt.Errorf("failed to serialize transcript: %w", err)
}
if strings.TrimSpace(inv.OutputPath) != "" {
if err := coreio.WriteFile(inv.OutputPath, transcriptBytes); err != nil {
if err := io.WriteFile(inv.OutputPath, outputBytes); err != nil {
return err
}
return nil
}
if _, err := stdout.Write(transcriptBytes); err != nil {
if _, err := stdout.Write(outputBytes); err != nil {
return fmt.Errorf("failed to write transcript to stdout: %w", err)
}
return nil

View File

@@ -341,49 +341,71 @@ func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) {
}
}
func TestRunProcessCLIOverridesEnvironment(t *testing.T) {
func TestRunProcessInvalidTranscriptSchema(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
t.Setenv("AUDITA_MODEL", "env-model")
t.Setenv("AUDITA_VALIDATION_LLM_CONCURRENCY", "2")
transcriptPath := fixturePath("tiny_transcript.json")
glossaryPath := fixturePath("tiny_glossary.yaml")
var captured processInvocation
originalRunner := processRunner
processRunner = func(inv processInvocation, output io.Writer) error {
_ = output
captured = inv
return nil
exitCode := Run([]string{"process", fixturePath("transcript_empty_speaker.json"), "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr)
if exitCode == 0 {
t.Fatalf("expected nonzero exit code for invalid transcript schema")
}
t.Cleanup(func() {
processRunner = originalRunner
})
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
if !strings.Contains(stderr.String(), "invalid transcript:") {
t.Fatalf("expected invalid transcript error, got %q", stderr.String())
}
if !strings.Contains(stderr.String(), "speaker") {
t.Fatalf("expected speaker validation error, got %q", stderr.String())
}
}
exitCode := Run([]string{
"process",
transcriptPath,
"--glossary",
glossaryPath,
"--model",
"cli-model",
"--validation-llm-concurrency",
"5",
}, &stdout, &stderr)
func TestRunProcessValidBareArrayTranscript(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"process", "../core/schema/testdata/transcript_bare_array.json", "--glossary", fixturePath("tiny_glossary.yaml")}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("expected exit code 0, got %d", exitCode)
t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
}
if captured.Config.PrimaryLLM.Model != "cli-model" {
t.Fatalf("expected CLI model override, got %q", captured.Config.PrimaryLLM.Model)
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
}
if captured.Config.ValidationLLM.Concurrency == nil || *captured.Config.ValidationLLM.Concurrency != 5 {
t.Fatalf("expected CLI validation concurrency override, got %#v", captured.Config.ValidationLLM.Concurrency)
// Should produce valid JSON output
if !json.Valid(stdout.Bytes()) {
t.Fatalf("expected valid JSON output, got %q", stdout.String())
}
if captured.GlossaryPath != glossaryPath {
t.Fatalf("unexpected glossary path: %q", captured.GlossaryPath)
// Parse the output to verify it's a proper transcript
var output any
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("failed to unmarshal output JSON: %v", err)
}
// Should be an array at the top level (canonical object form would be {"segments": [...]})
outputArray, ok := output.([]any)
if !ok {
t.Fatalf("expected output to be JSON array, got %T", output)
}
if len(outputArray) != 2 {
t.Fatalf("expected 2 segments in output, got %d", len(outputArray))
}
}
func TestRunProcessInvalidGlossarySchema(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"process", fixturePath("tiny_transcript.json"), "--glossary", "../core/schema/testdata/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(), "invalid glossary:") {
t.Fatalf("expected invalid glossary error, got %q", stderr.String())
}
}
@@ -438,5 +460,82 @@ func readProcessReport(t *testing.T, path string) minimalProcessReport {
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatalf("failed to unmarshal process report JSON: %v (raw: %q)", err, string(raw))
}
return report
func TestRunProcessStdoutBehaviorWithoutOutput(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := Run([]string{"process", "../core/schema/testdata/transcript_bare_array.json", "--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())
}
// Should have transcript JSON in stdout
if stdout.Len() == 0 {
t.Fatalf("expected transcript JSON in stdout, got empty stdout")
}
if !json.Valid(stdout.Bytes()) {
t.Fatalf("expected valid JSON in stdout, got %q", stdout.String())
}
}
func TestRunProcessEmptyStdoutWithOutput(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
transcriptPath := "../core/schema/testdata/transcript_bare_array.json"
glossaryPath := fixturePath("tiny_glossary.yaml")
outputPath := filepath.Join(t.TempDir(), "corrected.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 when --output is used, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
}
// Verify output file was created and contains valid JSON
outputBytes := readFile(t, outputPath)
if !json.Valid(outputBytes) {
t.Fatalf("expected valid JSON in output file, got %q", string(outputBytes))
}
}
func TestRunProcessReportJSONOnParseFailure(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
reportPath := filepath.Join(t.TempDir(), "report.json")
exitCode := Run([]string{
"process",
fixturePath("transcript_empty_speaker.json"),
"--glossary",
fixturePath("tiny_glossary.yaml"),
"--report-json",
reportPath,
}, &stdout, &stderr)
if exitCode == 0 {
t.Fatalf("expected nonzero exit code for parse failure")
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout on failure, got %q", stdout.String())
}
// Report should still be written on parse failure
report := readProcessReport(t, reportPath)
if report.Status != "failed" {
t.Fatalf("expected failed report status, got %q", report.Status)
}
if report.ErrorMessage == "" {
t.Fatalf("expected error message in failed report")
}
if !strings.Contains(report.ErrorMessage, "invalid transcript:") {
t.Fatalf("expected invalid transcript error in report, got %q", report.ErrorMessage)
}
}