Add output schema registry and public contract docs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
72
internal/core/outputschema/registry.go
Normal file
72
internal/core/outputschema/registry.go
Normal file
@@ -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, "", " ")
|
||||
}
|
||||
58
internal/core/outputschema/registry_test.go
Normal file
58
internal/core/outputschema/registry_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user