package cli import ( "errors" "flag" "fmt" "io" "strings" "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/reporting" ) type processInvocation struct { TranscriptPath string GlossaryPath string OutputPath string ReportJSONPath string Config config.Config } var processRunner = func(inv processInvocation, stdout io.Writer) error { transcriptBytes, err := coreio.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 { return err } if strings.TrimSpace(inv.OutputPath) != "" { if err := coreio.WriteFile(inv.OutputPath, transcriptBytes); err != nil { return err } return nil } if _, err := stdout.Write(transcriptBytes); err != nil { return fmt.Errorf("failed to write transcript to stdout: %w", err) } return nil } // Run executes the Audita CLI with the provided arguments and streams. func Run(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { writeRootUsage(stdout) return 0 } if isHelpCommand(args) { writeRootUsage(stdout) return 0 } if args[0] == "process" { return runProcess(args[1:], stdout, stderr) } fmt.Fprintf(stderr, "audita: unknown command %q\n\n", args[0]) writeRootUsage(stderr) return 2 } func runProcess(args []string, stdout, stderr io.Writer) int { startedAt := time.Now().UTC() cfg, err := config.LoadFromEnv() if err != nil { fmt.Fprintf(stderr, "audita process: invalid environment configuration: %v\n", err) return 2 } fs, pFlags := newProcessFlagSet(cfg, stderr) if isHelpCommand(args) || hasHelpFlag(args) { writeProcessUsage(stdout, fs) return 0 } parseArgs := args transcriptFromFront := "" if len(args) > 0 && !strings.HasPrefix(args[0], "-") { transcriptFromFront = args[0] parseArgs = args[1:] } if err := fs.Parse(parseArgs); err != nil { if errors.Is(err, flag.ErrHelp) { writeProcessUsage(stdout, fs) return 0 } return 2 } overrides := config.CLIOverrides{} fs.Visit(func(f *flag.Flag) { switch f.Name { case "modules": overrides.ModulesCSV = pFlags.modules case "llm-api-key": overrides.PrimaryLLMAPIKey = pFlags.llmAPIKey case "validation-llm-api-key": overrides.ValidationLLMAPIKey = pFlags.validationLLMAPIKey case "model": overrides.PrimaryModel = pFlags.model case "validation-model": overrides.ValidationModel = pFlags.validationModel case "base-url": overrides.PrimaryBaseURL = pFlags.baseURL case "validation-base-url": overrides.ValidationBaseURL = pFlags.validationBaseURL case "llm-timeout-seconds": overrides.PrimaryLLMTimeoutSeconds = pFlags.llmTimeoutSeconds case "validation-llm-timeout-seconds": overrides.ValidationLLMTimeoutSeconds = pFlags.validationLLMTimeoutSeconds case "max-retries": overrides.MaxRetries = pFlags.maxRetries case "validation-max-retries": overrides.ValidationMaxRetries = pFlags.validationMaxRetries case "validation-llm-concurrency": overrides.ValidationLLMConcurrency = pFlags.validationLLMConcurrency case "validation-max-prompt-tokens": overrides.ValidationMaxPromptTokens = pFlags.validationMaxPromptTokens case "max-section-tokens": overrides.MaxSectionTokens = pFlags.maxSectionTokens case "min-section-tokens": overrides.MinSectionTokens = pFlags.minSectionTokens case "target-sections": overrides.TargetSections = pFlags.targetSections case "glossary-confidence-threshold": overrides.GlossaryConfidenceThreshold = pFlags.glossaryConfidenceThreshold case "grammar-confidence-threshold": overrides.GrammarConfidenceThreshold = pFlags.grammarConfidenceThreshold case "homophones-confidence-threshold": overrides.HomophonesConfidenceThreshold = pFlags.homophonesConfidenceThreshold case "spoken-word-confidence-threshold": overrides.SpokenWordConfidenceThreshold = pFlags.spokenWordConfidenceThreshold case "normalize-max-segment-gap": overrides.NormalizeMaxSegmentGap = pFlags.normalizeMaxSegmentGap case "normalize-ellipsis-gap": overrides.NormalizeEllipsisGap = pFlags.normalizeEllipsisGap case "normalize-max-segment-duration": overrides.NormalizeMaxSegmentDuration = pFlags.normalizeMaxSegmentDuration case "normalize-max-segment-tokens": overrides.NormalizeMaxSegmentTokens = pFlags.normalizeMaxSegmentTokens case "work-dir": overrides.WorkDir = pFlags.workDir case "work-dir-retention": overrides.WorkDirRetention = pFlags.workDirRetention } }) if err := cfg.ApplyCLIOverrides(overrides); err != nil { fmt.Fprintf(stderr, "audita process: invalid CLI configuration: %v\n", err) return 2 } remaining := fs.Args() positional := make([]string, 0, len(remaining)+1) if transcriptFromFront != "" { positional = append(positional, transcriptFromFront) } positional = append(positional, remaining...) if len(positional) != 1 { fmt.Fprintln(stderr, "audita process: expected exactly 1 transcript JSON path argument") return 2 } if strings.TrimSpace(*pFlags.glossaryPath) == "" { fmt.Fprintln(stderr, "audita process: --glossary is required") return 2 } inv := processInvocation{ TranscriptPath: positional[0], GlossaryPath: *pFlags.glossaryPath, OutputPath: *pFlags.outputPath, ReportJSONPath: *pFlags.reportJSONPath, Config: cfg, } if err := processRunner(inv, stdout); err != nil { completedAt := time.Now().UTC() if strings.TrimSpace(inv.ReportJSONPath) != "" { report := buildProcessReport("failed", inv, startedAt, completedAt, err.Error()) if reportErr := reporting.WriteProcessReport(inv.ReportJSONPath, report); reportErr != nil { fmt.Fprintf(stderr, "audita process: %v\n", reportErr) } } fmt.Fprintf(stderr, "audita process: %v\n", err) return 1 } if strings.TrimSpace(inv.ReportJSONPath) != "" { completedAt := time.Now().UTC() report := buildProcessReport("success", inv, startedAt, completedAt, "") if err := reporting.WriteProcessReport(inv.ReportJSONPath, report); err != nil { fmt.Fprintf(stderr, "audita process: %v\n", err) return 1 } } return 0 } func buildProcessReport(status string, inv processInvocation, startedAt, completedAt time.Time, errorMessage string) reporting.ProcessReport { report := reporting.ProcessReport{ Phase: "phase1-minimal", Status: status, Operation: "process", TranscriptPath: inv.TranscriptPath, GlossaryPath: inv.GlossaryPath, OutputPath: inv.OutputPath, Modules: append([]string(nil), inv.Config.Modules...), StartedAt: startedAt, CompletedAt: &completedAt, } if errorMessage != "" { report.ErrorMessage = errorMessage } return report } type processFlags struct { glossaryPath *string outputPath *string reportJSONPath *string modules *string llmAPIKey *string validationLLMAPIKey *string model *string validationModel *string baseURL *string validationBaseURL *string llmTimeoutSeconds *int validationLLMTimeoutSeconds *int validationMaxPromptTokens *int targetSections *int maxRetries *int validationMaxRetries *int validationLLMConcurrency *int maxSectionTokens *int minSectionTokens *int glossaryConfidenceThreshold *float64 grammarConfidenceThreshold *float64 homophonesConfidenceThreshold *float64 spokenWordConfidenceThreshold *float64 normalizeMaxSegmentGap *float64 normalizeEllipsisGap *float64 normalizeMaxSegmentDuration *float64 normalizeMaxSegmentTokens *int workDir *string workDirRetention *string } func newProcessFlagSet(cfg config.Config, stderr io.Writer) (*flag.FlagSet, processFlags) { fs := flag.NewFlagSet("process", flag.ContinueOnError) fs.SetOutput(stderr) validationTimeoutSecondsDefault := cfg.PrimaryLLM.TimeoutSeconds if cfg.ValidationLLM.TimeoutSeconds != nil { validationTimeoutSecondsDefault = *cfg.ValidationLLM.TimeoutSeconds } validationMaxRetriesDefault := cfg.PrimaryLLM.MaxRetries if cfg.ValidationLLM.MaxRetries != nil { validationMaxRetriesDefault = *cfg.ValidationLLM.MaxRetries } validationLLMConcurrencyDefault := cfg.PrimaryLLM.Concurrency if cfg.ValidationLLM.Concurrency != nil { validationLLMConcurrencyDefault = *cfg.ValidationLLM.Concurrency } targetSectionsDefault := 0 if cfg.TargetSections != nil { targetSectionsDefault = *cfg.TargetSections } pFlags := processFlags{ glossaryPath: fs.String("glossary", "", "Path to glossary YAML file"), 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"), 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"), validationModel: fs.String("validation-model", cfg.ValidationLLM.Model, "Validation LLM model name"), baseURL: fs.String("base-url", cfg.PrimaryLLM.BaseURL, "Primary OpenAI-compatible base URL"), validationBaseURL: fs.String("validation-base-url", cfg.ValidationLLM.BaseURL, "Validation OpenAI-compatible base URL"), llmTimeoutSeconds: fs.Int("llm-timeout-seconds", cfg.PrimaryLLM.TimeoutSeconds, "Primary LLM timeout in seconds"), validationLLMTimeoutSeconds: fs.Int("validation-llm-timeout-seconds", validationTimeoutSecondsDefault, "Validation LLM timeout in seconds"), validationMaxPromptTokens: fs.Int("validation-max-prompt-tokens", cfg.ValidationMaxPromptTokens, "Validation max prompt tokens"), targetSections: fs.Int("target-sections", targetSectionsDefault, "Target number of transcript sections"), maxRetries: fs.Int("max-retries", cfg.PrimaryLLM.MaxRetries, "Maximum structured-output retries"), validationMaxRetries: fs.Int("validation-max-retries", validationMaxRetriesDefault, "Validation structured-output retries"), validationLLMConcurrency: fs.Int("validation-llm-concurrency", validationLLMConcurrencyDefault, "Validation LLM concurrency"), maxSectionTokens: fs.Int("max-section-tokens", cfg.MaxSectionTokens, "Maximum section tokens"), minSectionTokens: fs.Int("min-section-tokens", cfg.MinSectionTokens, "Minimum section tokens"), glossaryConfidenceThreshold: fs.Float64("glossary-confidence-threshold", cfg.Thresholds.Glossary, "Glossary confidence threshold"), grammarConfidenceThreshold: fs.Float64("grammar-confidence-threshold", cfg.Thresholds.Grammar, "Grammar confidence threshold"), homophonesConfidenceThreshold: fs.Float64("homophones-confidence-threshold", cfg.Thresholds.Homophones, "Homophones confidence threshold"), spokenWordConfidenceThreshold: fs.Float64("spoken-word-confidence-threshold", cfg.Thresholds.SpokenWord, "Spoken-word confidence threshold"), normalizeMaxSegmentGap: fs.Float64("normalize-max-segment-gap", cfg.Normalization.MaxSegmentGap, "Maximum same-speaker merge gap"), normalizeEllipsisGap: fs.Float64("normalize-ellipsis-gap", cfg.Normalization.EllipsisGap, "Gap threshold for ellipsis insertion"), normalizeMaxSegmentDuration: fs.Float64("normalize-max-segment-duration", cfg.Normalization.MaxSegmentDuration, "Maximum merged segment duration"), normalizeMaxSegmentTokens: fs.Int("normalize-max-segment-tokens", cfg.Normalization.MaxSegmentTokens, "Maximum merged segment token estimate"), workDir: fs.String("work-dir", cfg.WorkDir, "Per-run work directory"), workDirRetention: fs.String("work-dir-retention", string(cfg.WorkDirRetention), "Work-dir retention policy: auto|always|never"), } return fs, pFlags } func isHelpCommand(args []string) bool { if len(args) == 0 { return false } if len(args) == 1 { switch args[0] { case "help", "-h", "--help": return true } } if len(args) == 2 && args[0] == "help" { switch args[1] { case "process": return true } } return false } func hasHelpFlag(args []string) bool { for _, arg := range args { if arg == "-h" || arg == "--help" { return true } } return false } func writeRootUsage(w io.Writer) { fmt.Fprintln(w, "Audita is a transcript polishing CLI.") fmt.Fprintln(w) fmt.Fprintln(w, "Usage:") fmt.Fprintln(w, " audita [options]") fmt.Fprintln(w) fmt.Fprintln(w, "Commands:") fmt.Fprintln(w, " process Process a transcript JSON file") fmt.Fprintln(w) fmt.Fprintln(w, "Example:") fmt.Fprintln(w, " audita process transcript.json --glossary glossary.yaml --output corrected.json") } func writeProcessUsage(w io.Writer, fs *flag.FlagSet) { fmt.Fprintln(w, "Process a transcript JSON file.") fmt.Fprintln(w) fmt.Fprintln(w, "Usage:") fmt.Fprintln(w, " audita process [flags]") fmt.Fprintln(w) fmt.Fprintln(w, "Flags:") fs.VisitAll(func(f *flag.Flag) { fmt.Fprintf(w, " --%s\n", f.Name) }) fmt.Fprintln(w) fmt.Fprintln(w, "Example:") fmt.Fprintln(w, " audita process transcript.json --glossary glossary.yaml --output corrected.json") }