413 lines
15 KiB
Go
413 lines
15 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/audita/internal/core/config"
|
|
"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 {
|
|
TranscriptPath string
|
|
GlossaryPath string
|
|
OutputPath string
|
|
ReportJSONPath string
|
|
Config config.Config
|
|
}
|
|
|
|
var processRunner = func(inv processInvocation, stdout io.Writer) error {
|
|
transcriptBytes, err := io.ReadRequiredFile(inv.TranscriptPath, "transcript")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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 := io.WriteFile(inv.OutputPath, outputBytes); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if _, err := stdout.Write(outputBytes); 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 <command> [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 <transcript.json> [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")
|
|
}
|