Implement artifact-level render command with Markdown output and update docs
This commit is contained in:
178
internal/artifact/output_artifact.go
Normal file
178
internal/artifact/output_artifact.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
OutputSchemaMinimal = schema.OutputSchemaMinimal
|
||||
OutputSchemaIntermediate = schema.OutputSchemaIntermediate
|
||||
OutputSchemaFull = schema.OutputSchemaFull
|
||||
)
|
||||
|
||||
// OutputArtifact stores a parsed seriatim output artifact of one supported schema.
|
||||
type OutputArtifact struct {
|
||||
Schema string
|
||||
Full *schema.Transcript
|
||||
Intermediate *schema.IntermediateTranscript
|
||||
Minimal *schema.MinimalTranscript
|
||||
}
|
||||
|
||||
// ParseOutputArtifactJSON parses and validates serialized seriatim output JSON.
|
||||
func ParseOutputArtifactJSON(data []byte) (OutputArtifact, error) {
|
||||
var decoded any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return OutputArtifact{}, fmt.Errorf("input JSON is malformed: %w", err)
|
||||
}
|
||||
|
||||
var full schema.Transcript
|
||||
if err := json.Unmarshal(data, &full); err == nil {
|
||||
if err := schema.ValidateTranscript(full); err == nil {
|
||||
return OutputArtifact{
|
||||
Schema: OutputSchemaFull,
|
||||
Full: &full,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var intermediate schema.IntermediateTranscript
|
||||
if err := json.Unmarshal(data, &intermediate); err == nil {
|
||||
if err := schema.ValidateIntermediateTranscript(intermediate); err == nil {
|
||||
return OutputArtifact{
|
||||
Schema: OutputSchemaIntermediate,
|
||||
Intermediate: &intermediate,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var minimal schema.MinimalTranscript
|
||||
if err := json.Unmarshal(data, &minimal); err == nil {
|
||||
if err := schema.ValidateMinimalTranscript(minimal); err == nil {
|
||||
return OutputArtifact{
|
||||
Schema: OutputSchemaMinimal,
|
||||
Minimal: &minimal,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return OutputArtifact{}, fmt.Errorf("input JSON is not a valid seriatim output artifact")
|
||||
}
|
||||
|
||||
// Value returns the output payload value for serialization.
|
||||
func (artifact OutputArtifact) Value() any {
|
||||
switch artifact.Schema {
|
||||
case OutputSchemaFull:
|
||||
if artifact.Full == nil {
|
||||
return schema.Transcript{}
|
||||
}
|
||||
return *artifact.Full
|
||||
case OutputSchemaIntermediate:
|
||||
if artifact.Intermediate == nil {
|
||||
return schema.IntermediateTranscript{}
|
||||
}
|
||||
return *artifact.Intermediate
|
||||
case OutputSchemaMinimal:
|
||||
if artifact.Minimal == nil {
|
||||
return schema.MinimalTranscript{}
|
||||
}
|
||||
return *artifact.Minimal
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SegmentCount returns the number of segments in the output artifact.
|
||||
func (artifact OutputArtifact) SegmentCount() int {
|
||||
switch artifact.Schema {
|
||||
case OutputSchemaFull:
|
||||
if artifact.Full == nil {
|
||||
return 0
|
||||
}
|
||||
return len(artifact.Full.Segments)
|
||||
case OutputSchemaIntermediate:
|
||||
if artifact.Intermediate == nil {
|
||||
return 0
|
||||
}
|
||||
return len(artifact.Intermediate.Segments)
|
||||
case OutputSchemaMinimal:
|
||||
if artifact.Minimal == nil {
|
||||
return 0
|
||||
}
|
||||
return len(artifact.Minimal.Segments)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Application returns output artifact metadata application name.
|
||||
func (artifact OutputArtifact) Application() string {
|
||||
switch artifact.Schema {
|
||||
case OutputSchemaFull:
|
||||
if artifact.Full == nil {
|
||||
return ""
|
||||
}
|
||||
return artifact.Full.Metadata.Application
|
||||
case OutputSchemaIntermediate:
|
||||
if artifact.Intermediate == nil {
|
||||
return ""
|
||||
}
|
||||
return artifact.Intermediate.Metadata.Application
|
||||
case OutputSchemaMinimal:
|
||||
if artifact.Minimal == nil {
|
||||
return ""
|
||||
}
|
||||
return artifact.Minimal.Metadata.Application
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Version returns output artifact metadata version.
|
||||
func (artifact OutputArtifact) Version() string {
|
||||
switch artifact.Schema {
|
||||
case OutputSchemaFull:
|
||||
if artifact.Full == nil {
|
||||
return ""
|
||||
}
|
||||
return artifact.Full.Metadata.Version
|
||||
case OutputSchemaIntermediate:
|
||||
if artifact.Intermediate == nil {
|
||||
return ""
|
||||
}
|
||||
return artifact.Intermediate.Metadata.Version
|
||||
case OutputSchemaMinimal:
|
||||
if artifact.Minimal == nil {
|
||||
return ""
|
||||
}
|
||||
return artifact.Minimal.Metadata.Version
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// FullPayload returns the full-schema payload when present.
|
||||
func (artifact OutputArtifact) FullPayload() (*schema.Transcript, error) {
|
||||
if artifact.Full == nil {
|
||||
return nil, fmt.Errorf("full artifact payload is missing")
|
||||
}
|
||||
return artifact.Full, nil
|
||||
}
|
||||
|
||||
// IntermediatePayload returns the intermediate-schema payload when present.
|
||||
func (artifact OutputArtifact) IntermediatePayload() (*schema.IntermediateTranscript, error) {
|
||||
if artifact.Intermediate == nil {
|
||||
return nil, fmt.Errorf("intermediate artifact payload is missing")
|
||||
}
|
||||
return artifact.Intermediate, nil
|
||||
}
|
||||
|
||||
// MinimalPayload returns the minimal-schema payload when present.
|
||||
func (artifact OutputArtifact) MinimalPayload() (*schema.MinimalTranscript, error) {
|
||||
if artifact.Minimal == nil {
|
||||
return nil, fmt.Errorf("minimal artifact payload is missing")
|
||||
}
|
||||
return artifact.Minimal, nil
|
||||
}
|
||||
134
internal/artifact/output_artifact_test.go
Normal file
134
internal/artifact/output_artifact_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||
)
|
||||
|
||||
func TestParseOutputArtifactJSONParsesFullIntermediateAndMinimal(t *testing.T) {
|
||||
t.Run("full", func(t *testing.T) {
|
||||
first := 0
|
||||
value := schema.Transcript{
|
||||
Metadata: schema.Metadata{
|
||||
Application: "seriatim",
|
||||
Version: "v-test",
|
||||
InputReader: "json-files",
|
||||
InputFiles: []string{"input.json"},
|
||||
PreprocessingModules: []string{"validate-raw"},
|
||||
PostprocessingModules: []string{"assign-ids", "validate-output"},
|
||||
OutputModules: []string{"json"},
|
||||
},
|
||||
Segments: []schema.Segment{
|
||||
{
|
||||
ID: 1,
|
||||
Source: "input.json",
|
||||
SourceSegmentIndex: &first,
|
||||
Speaker: "Alice",
|
||||
Start: 1,
|
||||
End: 2,
|
||||
Text: "hello",
|
||||
Categories: []string{"backchannel"},
|
||||
},
|
||||
},
|
||||
OverlapGroups: []schema.OverlapGroup{},
|
||||
}
|
||||
|
||||
parsed := mustParseOutputArtifact(t, value)
|
||||
if parsed.Schema != OutputSchemaFull {
|
||||
t.Fatalf("schema = %q, want %q", parsed.Schema, OutputSchemaFull)
|
||||
}
|
||||
if parsed.Full == nil {
|
||||
t.Fatal("expected full payload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("intermediate", func(t *testing.T) {
|
||||
value := schema.IntermediateTranscript{
|
||||
Metadata: schema.IntermediateMetadata{
|
||||
Application: "seriatim",
|
||||
Version: "v-test",
|
||||
OutputSchema: OutputSchemaIntermediate,
|
||||
},
|
||||
Segments: []schema.IntermediateSegment{
|
||||
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "hello", Categories: []string{"filler"}},
|
||||
},
|
||||
}
|
||||
|
||||
parsed := mustParseOutputArtifact(t, value)
|
||||
if parsed.Schema != OutputSchemaIntermediate {
|
||||
t.Fatalf("schema = %q, want %q", parsed.Schema, OutputSchemaIntermediate)
|
||||
}
|
||||
if parsed.Intermediate == nil {
|
||||
t.Fatal("expected intermediate payload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("minimal", func(t *testing.T) {
|
||||
value := schema.MinimalTranscript{
|
||||
Metadata: schema.MinimalMetadata{
|
||||
Application: "seriatim",
|
||||
Version: "v-test",
|
||||
OutputSchema: OutputSchemaMinimal,
|
||||
},
|
||||
Segments: []schema.MinimalSegment{
|
||||
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
parsed := mustParseOutputArtifact(t, value)
|
||||
if parsed.Schema != OutputSchemaMinimal {
|
||||
t.Fatalf("schema = %q, want %q", parsed.Schema, OutputSchemaMinimal)
|
||||
}
|
||||
if parsed.Minimal == nil {
|
||||
t.Fatal("expected minimal payload")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseOutputArtifactJSONRejectsMalformedJSON(t *testing.T) {
|
||||
_, err := ParseOutputArtifactJSON([]byte(`{"metadata":`))
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed JSON error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input JSON is malformed") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOutputArtifactJSONRejectsRawWhisperXLikeInput(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"segments": [
|
||||
{
|
||||
"id": 0,
|
||||
"start": 0.1,
|
||||
"end": 1.2,
|
||||
"text": "hello",
|
||||
"words": [{"word":"hello","start":0.1,"end":0.8}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, err := ParseOutputArtifactJSON(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected artifact validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a valid seriatim output artifact") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseOutputArtifact(t *testing.T, value any) OutputArtifact {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
parsed, err := ParseOutputArtifactJSON(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
39
internal/cli/render.go
Normal file
39
internal/cli/render.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
||||
"gitea.maximumdirect.net/eric/seriatim/internal/render"
|
||||
)
|
||||
|
||||
func newRenderCommand() *cobra.Command {
|
||||
opts := config.RenderOptions{
|
||||
Title: config.DefaultRenderTitle,
|
||||
IncludeTimestamps: true,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "render",
|
||||
Short: "Render a seriatim transcript artifact into human-readable output",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.NewRenderConfig(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return render.Run(cmd.Context(), cfg)
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.StringVar(&opts.InputFile, "input-file", "", "input seriatim transcript artifact JSON file")
|
||||
flags.StringVar(&opts.OutputFile, "output-file", "", "rendered output file path")
|
||||
flags.StringVar(&opts.Format, "format", "", "output format (markdown)")
|
||||
flags.StringVar(&opts.Title, "title", config.DefaultRenderTitle, "document title")
|
||||
flags.BoolVar(&opts.IncludeTimestamps, "include-timestamps", true, "include segment timestamps")
|
||||
flags.BoolVar(&opts.IncludeSegmentIDs, "include-segment-ids", false, "include segment IDs")
|
||||
flags.BoolVar(&opts.IncludeMetadata, "include-metadata", false, "include artifact metadata")
|
||||
|
||||
return cmd
|
||||
}
|
||||
208
internal/cli/render_test.go
Normal file
208
internal/cli/render_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
||||
)
|
||||
|
||||
func TestRenderCommandIsRecognized(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"render", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("render command should be recognized: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpIncludesRender(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("help failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "render") {
|
||||
t.Fatalf("root help missing render command:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEndToEndMarkdownOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
input := writeJSONFile(t, dir, "input.json", `{
|
||||
"metadata": {
|
||||
"application": "seriatim",
|
||||
"version": "v-test",
|
||||
"output_schema": "seriatim-intermediate"
|
||||
},
|
||||
"segments": [
|
||||
{"id": 1, "start": 1, "end": 4, "speaker": "Eric", "text": "Hello there."},
|
||||
{"id": 2, "start": 5, "end": 8, "speaker": "Mike", "text": "Yeah.", "categories": ["backchannel"]}
|
||||
]
|
||||
}`)
|
||||
output := writeJSONFile(t, dir, "output.md", "")
|
||||
|
||||
err := executeRender(
|
||||
"--input-file", input,
|
||||
"--output-file", output,
|
||||
"--format", config.RenderFormatMarkdown,
|
||||
"--title", "Transcript",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("render failed: %v", err)
|
||||
}
|
||||
|
||||
data := readFile(t, output)
|
||||
if !strings.Contains(data, "# Transcript") {
|
||||
t.Fatalf("missing title:\n%s", data)
|
||||
}
|
||||
if !strings.Contains(data, "[00:00:01–00:00:04] **Eric:** Hello there.") {
|
||||
t.Fatalf("missing first segment:\n%s", data)
|
||||
}
|
||||
if !strings.Contains(data, "[00:00:05–00:00:08] **Mike:** *Yeah.*") {
|
||||
t.Fatalf("missing italicized backchannel segment:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsUnsupportedFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
input := writeJSONFile(t, dir, "input.json", `{"metadata":{"application":"seriatim","version":"v-test","output_schema":"seriatim-minimal"},"segments":[]}`)
|
||||
output := writeJSONFile(t, dir, "output.md", "")
|
||||
|
||||
err := executeRender(
|
||||
"--input-file", input,
|
||||
"--output-file", output,
|
||||
"--format", "txt",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected format error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--format must be") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRejectsMalformedAndRawInput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
output := writeJSONFile(t, dir, "output.md", "")
|
||||
|
||||
malformed := writeJSONFile(t, dir, "malformed.json", `{"metadata":`)
|
||||
err := executeRender(
|
||||
"--input-file", malformed,
|
||||
"--output-file", output,
|
||||
"--format", config.RenderFormatMarkdown,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed input error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input JSON is malformed") {
|
||||
t.Fatalf("unexpected malformed input error: %v", err)
|
||||
}
|
||||
|
||||
raw := writeJSONFile(t, dir, "raw.json", `{"segments":[{"id":0,"start":0.1,"end":1.1,"text":"hello","words":[{"word":"hello"}]}]}`)
|
||||
err = executeRender(
|
||||
"--input-file", raw,
|
||||
"--output-file", output,
|
||||
"--format", config.RenderFormatMarkdown,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected artifact validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a valid seriatim output artifact") {
|
||||
t.Fatalf("unexpected raw input error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSupportsMinimalIntermediateAndFullInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "minimal",
|
||||
content: `{
|
||||
"metadata": {
|
||||
"application": "seriatim",
|
||||
"version": "v-test",
|
||||
"output_schema": "seriatim-minimal"
|
||||
},
|
||||
"segments": [{"id":1,"start":1,"end":2,"speaker":"A","text":"one"}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "intermediate",
|
||||
content: `{
|
||||
"metadata": {
|
||||
"application": "seriatim",
|
||||
"version": "v-test",
|
||||
"output_schema": "seriatim-intermediate"
|
||||
},
|
||||
"segments": [{"id":1,"start":1,"end":2,"speaker":"A","text":"one","categories":["filler"]}]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "full",
|
||||
content: `{
|
||||
"metadata": {
|
||||
"application": "seriatim",
|
||||
"version": "v-test",
|
||||
"input_reader": "json-files",
|
||||
"input_files": ["input.json"],
|
||||
"preprocessing_modules": [],
|
||||
"postprocessing_modules": [],
|
||||
"output_modules": ["json"]
|
||||
},
|
||||
"segments": [{
|
||||
"id":1,
|
||||
"source":"input.json",
|
||||
"source_segment_index":0,
|
||||
"speaker":"A",
|
||||
"start":1,
|
||||
"end":2,
|
||||
"text":"one"
|
||||
}],
|
||||
"overlap_groups": []
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
input := writeJSONFile(t, dir, "input.json", test.content)
|
||||
output := writeJSONFile(t, dir, "output.md", "")
|
||||
|
||||
err := executeRender(
|
||||
"--input-file", input,
|
||||
"--output-file", output,
|
||||
"--format", config.RenderFormatMarkdown,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("render failed: %v", err)
|
||||
}
|
||||
data := readFile(t, output)
|
||||
if !strings.Contains(data, "**A:**") {
|
||||
t.Fatalf("missing rendered segment for %s input:\n%s", test.name, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func executeRender(args ...string) error {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs(append([]string{"render"}, args...))
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func NewRootCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "seriatim",
|
||||
Short: "Merge, trim, and normalize transcript artifacts",
|
||||
Short: "Merge, trim, normalize, and render transcript artifacts",
|
||||
Version: buildinfo.Version,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
@@ -18,6 +18,7 @@ func NewRootCommand() *cobra.Command {
|
||||
|
||||
cmd.AddCommand(newMergeCommand())
|
||||
cmd.AddCommand(newNormalizeCommand())
|
||||
cmd.AddCommand(newRenderCommand())
|
||||
cmd.AddCommand(newTrimCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const (
|
||||
DefaultInputReader = "json-files"
|
||||
DefaultOutputModules = "json"
|
||||
DefaultOutputSchema = OutputSchemaIntermediate
|
||||
DefaultRenderTitle = "Transcript"
|
||||
RenderFormatMarkdown = "markdown"
|
||||
DefaultPreprocessingModules = "validate-raw,normalize-speakers,trim-text"
|
||||
DefaultPostprocessingModules = "detect-overlaps,resolve-overlaps,backchannel,filler,resolve-danglers,coalesce,detect-overlaps,autocorrect,assign-ids,validate-output"
|
||||
DefaultOverlapWordRunGap = 1.0
|
||||
@@ -69,6 +71,17 @@ type NormalizeOptions struct {
|
||||
OutputModules string
|
||||
}
|
||||
|
||||
// RenderOptions captures raw CLI option values before validation.
|
||||
type RenderOptions struct {
|
||||
InputFile string
|
||||
OutputFile string
|
||||
Format string
|
||||
Title string
|
||||
IncludeTimestamps bool
|
||||
IncludeSegmentIDs bool
|
||||
IncludeMetadata bool
|
||||
}
|
||||
|
||||
// Config is the validated runtime configuration for a merge invocation.
|
||||
type Config struct {
|
||||
InputFiles []string
|
||||
@@ -108,6 +121,17 @@ type NormalizeConfig struct {
|
||||
OutputModules []string
|
||||
}
|
||||
|
||||
// RenderConfig is the validated runtime configuration for a render invocation.
|
||||
type RenderConfig struct {
|
||||
InputFile string
|
||||
OutputFile string
|
||||
Format string
|
||||
Title string
|
||||
IncludeTimestamps bool
|
||||
IncludeSegmentIDs bool
|
||||
IncludeMetadata bool
|
||||
}
|
||||
|
||||
// NewMergeConfig validates raw merge options and returns normalized config.
|
||||
func NewMergeConfig(opts MergeOptions) (Config, error) {
|
||||
cfg := Config{
|
||||
@@ -303,6 +327,42 @@ func NewNormalizeConfig(opts NormalizeOptions) (NormalizeConfig, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewRenderConfig validates raw render options and returns normalized config.
|
||||
func NewRenderConfig(opts RenderOptions) (RenderConfig, error) {
|
||||
inputFile, err := normalizeSingleInputFile(opts.InputFile, "--input-file")
|
||||
if err != nil {
|
||||
return RenderConfig{}, err
|
||||
}
|
||||
|
||||
outputFile, err := normalizeOutputPath(opts.OutputFile, "--output-file")
|
||||
if err != nil {
|
||||
return RenderConfig{}, err
|
||||
}
|
||||
|
||||
format := strings.TrimSpace(opts.Format)
|
||||
if format == "" {
|
||||
return RenderConfig{}, errors.New("--format is required")
|
||||
}
|
||||
if err := validateRenderFormat(format); err != nil {
|
||||
return RenderConfig{}, err
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(opts.Title)
|
||||
if title == "" {
|
||||
title = DefaultRenderTitle
|
||||
}
|
||||
|
||||
return RenderConfig{
|
||||
InputFile: inputFile,
|
||||
OutputFile: outputFile,
|
||||
Format: format,
|
||||
Title: title,
|
||||
IncludeTimestamps: opts.IncludeTimestamps,
|
||||
IncludeSegmentIDs: opts.IncludeSegmentIDs,
|
||||
IncludeMetadata: opts.IncludeMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseModuleList(value string) ([]string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
@@ -485,3 +545,12 @@ func validateNormalizeOutputModules(modules []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRenderFormat(format string) error {
|
||||
switch format {
|
||||
case RenderFormatMarkdown:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("--format must be %q", RenderFormatMarkdown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +804,97 @@ func TestNewNormalizeConfigTreatsWhitespaceReportFileAsOmitted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRenderConfigRequiresInputOutputAndFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
input := writeTempFile(t, dir, "input.json")
|
||||
output := filepath.Join(dir, "rendered.md")
|
||||
|
||||
_, err := NewRenderConfig(RenderOptions{
|
||||
OutputFile: output,
|
||||
Format: RenderFormatMarkdown,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "--input-file is required") {
|
||||
t.Fatalf("expected input-file required error, got %v", err)
|
||||
}
|
||||
|
||||
_, err = NewRenderConfig(RenderOptions{
|
||||
InputFile: input,
|
||||
Format: RenderFormatMarkdown,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "--output-file is required") {
|
||||
t.Fatalf("expected output-file required error, got %v", err)
|
||||
}
|
||||
|
||||
_, err = NewRenderConfig(RenderOptions{
|
||||
InputFile: input,
|
||||
OutputFile: output,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "--format is required") {
|
||||
t.Fatalf("expected format required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRenderConfigRejectsUnknownFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
input := writeTempFile(t, dir, "input.json")
|
||||
output := filepath.Join(dir, "rendered.md")
|
||||
|
||||
opts := validRenderOptions(input, output)
|
||||
opts.Format = "txt"
|
||||
_, err := NewRenderConfig(opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected format validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--format must be") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRenderConfigAppliesDefaultsAndFlags(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
input := writeTempFile(t, dir, "input.json")
|
||||
output := filepath.Join(dir, "rendered.md")
|
||||
|
||||
cfg, err := NewRenderConfig(validRenderOptions(input, output))
|
||||
if err != nil {
|
||||
t.Fatalf("config failed: %v", err)
|
||||
}
|
||||
if cfg.Title != DefaultRenderTitle {
|
||||
t.Fatalf("title = %q, want %q", cfg.Title, DefaultRenderTitle)
|
||||
}
|
||||
if !cfg.IncludeTimestamps {
|
||||
t.Fatal("include timestamps should default true")
|
||||
}
|
||||
if cfg.IncludeSegmentIDs {
|
||||
t.Fatal("include segment IDs should default false")
|
||||
}
|
||||
if cfg.IncludeMetadata {
|
||||
t.Fatal("include metadata should default false")
|
||||
}
|
||||
|
||||
opts := validRenderOptions(input, output)
|
||||
opts.Title = "Meeting Notes"
|
||||
opts.IncludeTimestamps = false
|
||||
opts.IncludeSegmentIDs = true
|
||||
opts.IncludeMetadata = true
|
||||
cfg, err = NewRenderConfig(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("config failed: %v", err)
|
||||
}
|
||||
if cfg.Title != "Meeting Notes" {
|
||||
t.Fatalf("title = %q, want Meeting Notes", cfg.Title)
|
||||
}
|
||||
if cfg.IncludeTimestamps {
|
||||
t.Fatal("include timestamps should be false")
|
||||
}
|
||||
if !cfg.IncludeSegmentIDs {
|
||||
t.Fatal("include segment IDs should be true")
|
||||
}
|
||||
if !cfg.IncludeMetadata {
|
||||
t.Fatal("include metadata should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func assertPositiveFloatEnvValidation(t *testing.T, envName string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -862,6 +953,18 @@ func validNormalizeOptions(inputFile string, outputFile string) NormalizeOptions
|
||||
}
|
||||
}
|
||||
|
||||
func validRenderOptions(inputFile string, outputFile string) RenderOptions {
|
||||
return RenderOptions{
|
||||
InputFile: inputFile,
|
||||
OutputFile: outputFile,
|
||||
Format: RenderFormatMarkdown,
|
||||
Title: DefaultRenderTitle,
|
||||
IncludeTimestamps: true,
|
||||
IncludeSegmentIDs: false,
|
||||
IncludeMetadata: false,
|
||||
}
|
||||
}
|
||||
|
||||
func writeTempFile(t *testing.T, dir string, name string) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
75
internal/render/markdown.go
Normal file
75
internal/render/markdown.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MarkdownRenderer renders transcript artifacts as Markdown.
|
||||
type MarkdownRenderer struct{}
|
||||
|
||||
// Render renders the transcript into deterministic Markdown.
|
||||
func (MarkdownRenderer) Render(transcript Transcript, opts Options) (string, error) {
|
||||
var lines []string
|
||||
|
||||
title := strings.TrimSpace(opts.Title)
|
||||
if title == "" {
|
||||
title = "Transcript"
|
||||
}
|
||||
lines = append(lines, "# "+title, "")
|
||||
|
||||
if opts.IncludeMetadata {
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("- Application: %s", transcript.Metadata.Application),
|
||||
fmt.Sprintf("- Version: %s", transcript.Metadata.Version),
|
||||
fmt.Sprintf("- Output schema: %s", transcript.Schema),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
for _, segment := range transcript.Segments {
|
||||
parts := make([]string, 0, 4)
|
||||
if opts.IncludeTimestamps {
|
||||
parts = append(parts, fmt.Sprintf("[%s–%s]", formatTimestamp(segment.Start), formatTimestamp(segment.End)))
|
||||
}
|
||||
if opts.IncludeSegmentIDs {
|
||||
parts = append(parts, fmt.Sprintf("[#%d]", segment.ID))
|
||||
}
|
||||
|
||||
text := segment.Text
|
||||
if shouldItalicize(segment.Categories) {
|
||||
text = "*" + text + "*"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("**%s:** %s", segment.Speaker, text))
|
||||
lines = append(lines, strings.Join(parts, " "))
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
output := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(output, "\n") {
|
||||
output += "\n"
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func shouldItalicize(categories []string) bool {
|
||||
for _, category := range categories {
|
||||
switch category {
|
||||
case "background", "backchannel", "filler":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatTimestamp(seconds float64) string {
|
||||
total := int(math.Round(seconds))
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
hours := total / 3600
|
||||
minutes := (total % 3600) / 60
|
||||
remainder := total % 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainder)
|
||||
}
|
||||
140
internal/render/markdown_test.go
Normal file
140
internal/render/markdown_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownRendererDefaultTranscriptShape(t *testing.T) {
|
||||
transcript := Transcript{
|
||||
Schema: "seriatim-intermediate",
|
||||
Metadata: Metadata{
|
||||
Application: "seriatim",
|
||||
Version: "v-test",
|
||||
},
|
||||
Segments: []Segment{
|
||||
{ID: 1, Start: 1, End: 4, Speaker: "Eric", Text: "Hello there."},
|
||||
{ID: 2, Start: 5, End: 8, Speaker: "Mike", Text: "Welcome back, everyone."},
|
||||
},
|
||||
}
|
||||
|
||||
output, err := MarkdownRenderer{}.Render(transcript, Options{
|
||||
Title: "Transcript",
|
||||
IncludeTimestamps: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render markdown: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "# Transcript") {
|
||||
t.Fatalf("expected title in output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "[00:00:01–00:00:04] **Eric:** Hello there.") {
|
||||
t.Fatalf("expected first segment in output:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "[00:00:05–00:00:08] **Mike:** Welcome back, everyone.") {
|
||||
t.Fatalf("expected second segment in output:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownRendererWithoutTimestamps(t *testing.T) {
|
||||
transcript := Transcript{
|
||||
Segments: []Segment{
|
||||
{ID: 1, Start: 1, End: 4, Speaker: "Eric", Text: "Hello."},
|
||||
},
|
||||
}
|
||||
|
||||
output, err := MarkdownRenderer{}.Render(transcript, Options{
|
||||
Title: "Transcript",
|
||||
IncludeTimestamps: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render markdown: %v", err)
|
||||
}
|
||||
if strings.Contains(output, "[00:00:01") {
|
||||
t.Fatalf("timestamps should be omitted:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "**Eric:** Hello.") {
|
||||
t.Fatalf("expected speaker/text line:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownRendererWithSegmentIDs(t *testing.T) {
|
||||
transcript := Transcript{
|
||||
Segments: []Segment{
|
||||
{ID: 17, Start: 1, End: 4, Speaker: "Eric", Text: "Hello."},
|
||||
},
|
||||
}
|
||||
|
||||
output, err := MarkdownRenderer{}.Render(transcript, Options{
|
||||
Title: "Transcript",
|
||||
IncludeTimestamps: true,
|
||||
IncludeSegmentIDs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render markdown: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, "[#17]") {
|
||||
t.Fatalf("expected segment ID in output:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownRendererMetadataOnlyWhenRequested(t *testing.T) {
|
||||
transcript := Transcript{
|
||||
Schema: "seriatim-full",
|
||||
Metadata: Metadata{
|
||||
Application: "seriatim",
|
||||
Version: "v-test",
|
||||
},
|
||||
}
|
||||
|
||||
withMetadata, err := MarkdownRenderer{}.Render(transcript, Options{
|
||||
Title: "Transcript",
|
||||
IncludeMetadata: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render with metadata: %v", err)
|
||||
}
|
||||
if !strings.Contains(withMetadata, "- Application: seriatim") {
|
||||
t.Fatalf("expected metadata block:\n%s", withMetadata)
|
||||
}
|
||||
|
||||
withoutMetadata, err := MarkdownRenderer{}.Render(transcript, Options{Title: "Transcript"})
|
||||
if err != nil {
|
||||
t.Fatalf("render without metadata: %v", err)
|
||||
}
|
||||
if strings.Contains(withoutMetadata, "- Application: seriatim") {
|
||||
t.Fatalf("metadata should be omitted:\n%s", withoutMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownRendererCategoryHintItalicsAndUnknownCategories(t *testing.T) {
|
||||
transcript := Transcript{
|
||||
Segments: []Segment{
|
||||
{ID: 1, Start: 1, End: 2, Speaker: "A", Text: "bg", Categories: []string{"background"}},
|
||||
{ID: 2, Start: 2, End: 3, Speaker: "B", Text: "bc", Categories: []string{"backchannel"}},
|
||||
{ID: 3, Start: 3, End: 4, Speaker: "C", Text: "fill", Categories: []string{"filler"}},
|
||||
{ID: 4, Start: 4, End: 5, Speaker: "D", Text: "plain", Categories: []string{"unknown-tag"}},
|
||||
},
|
||||
}
|
||||
|
||||
output, err := MarkdownRenderer{}.Render(transcript, Options{
|
||||
Title: "Transcript",
|
||||
IncludeTimestamps: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render markdown: %v", err)
|
||||
}
|
||||
if !strings.Contains(output, "**A:** *bg*") {
|
||||
t.Fatalf("expected background italics:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "**B:** *bc*") {
|
||||
t.Fatalf("expected backchannel italics:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "**C:** *fill*") {
|
||||
t.Fatalf("expected filler italics:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "**D:** plain") {
|
||||
t.Fatalf("expected unknown category to be ignored:\n%s", output)
|
||||
}
|
||||
}
|
||||
24
internal/render/model.go
Normal file
24
internal/render/model.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package render
|
||||
|
||||
// Transcript is the render-normalized transcript model used by renderers.
|
||||
type Transcript struct {
|
||||
Schema string
|
||||
Metadata Metadata
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
// Metadata is the render-relevant artifact metadata.
|
||||
type Metadata struct {
|
||||
Application string
|
||||
Version string
|
||||
}
|
||||
|
||||
// Segment is a normalized render segment.
|
||||
type Segment struct {
|
||||
ID int
|
||||
Start float64
|
||||
End float64
|
||||
Speaker string
|
||||
Text string
|
||||
Categories []string
|
||||
}
|
||||
96
internal/render/normalize.go
Normal file
96
internal/render/normalize.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||
)
|
||||
|
||||
// FromOutputArtifact converts a parsed output artifact into the internal render model.
|
||||
func FromOutputArtifact(input artifact.OutputArtifact) (Transcript, error) {
|
||||
switch input.Schema {
|
||||
case artifact.OutputSchemaFull:
|
||||
payload, err := input.FullPayload()
|
||||
if err != nil {
|
||||
return Transcript{}, err
|
||||
}
|
||||
segments := make([]Segment, len(payload.Segments))
|
||||
for index, segment := range payload.Segments {
|
||||
segments[index] = Segment{
|
||||
ID: segment.ID,
|
||||
Start: segment.Start,
|
||||
End: segment.End,
|
||||
Speaker: segment.Speaker,
|
||||
Text: segment.Text,
|
||||
Categories: normalizeCategories(segment.Categories),
|
||||
}
|
||||
}
|
||||
return Transcript{
|
||||
Schema: input.Schema,
|
||||
Metadata: Metadata{
|
||||
Application: payload.Metadata.Application,
|
||||
Version: payload.Metadata.Version,
|
||||
},
|
||||
Segments: segments,
|
||||
}, nil
|
||||
case artifact.OutputSchemaIntermediate:
|
||||
payload, err := input.IntermediatePayload()
|
||||
if err != nil {
|
||||
return Transcript{}, err
|
||||
}
|
||||
segments := make([]Segment, len(payload.Segments))
|
||||
for index, segment := range payload.Segments {
|
||||
segments[index] = Segment{
|
||||
ID: segment.ID,
|
||||
Start: segment.Start,
|
||||
End: segment.End,
|
||||
Speaker: segment.Speaker,
|
||||
Text: segment.Text,
|
||||
Categories: normalizeCategories(segment.Categories),
|
||||
}
|
||||
}
|
||||
return Transcript{
|
||||
Schema: input.Schema,
|
||||
Metadata: Metadata{
|
||||
Application: payload.Metadata.Application,
|
||||
Version: payload.Metadata.Version,
|
||||
},
|
||||
Segments: segments,
|
||||
}, nil
|
||||
case artifact.OutputSchemaMinimal:
|
||||
payload, err := input.MinimalPayload()
|
||||
if err != nil {
|
||||
return Transcript{}, err
|
||||
}
|
||||
segments := make([]Segment, len(payload.Segments))
|
||||
for index, segment := range payload.Segments {
|
||||
segments[index] = Segment{
|
||||
ID: segment.ID,
|
||||
Start: segment.Start,
|
||||
End: segment.End,
|
||||
Speaker: segment.Speaker,
|
||||
Text: segment.Text,
|
||||
Categories: []string{},
|
||||
}
|
||||
}
|
||||
return Transcript{
|
||||
Schema: input.Schema,
|
||||
Metadata: Metadata{
|
||||
Application: payload.Metadata.Application,
|
||||
Version: payload.Metadata.Version,
|
||||
},
|
||||
Segments: segments,
|
||||
}, nil
|
||||
default:
|
||||
return Transcript{}, fmt.Errorf("unsupported artifact schema %q", input.Schema)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCategories(categories []string) []string {
|
||||
if categories == nil {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, len(categories))
|
||||
copy(out, categories)
|
||||
return out
|
||||
}
|
||||
139
internal/render/normalize_test.go
Normal file
139
internal/render/normalize_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||
)
|
||||
|
||||
func TestFromOutputArtifactNormalizesSupportedSchemas(t *testing.T) {
|
||||
t.Run("full", func(t *testing.T) {
|
||||
sourceIndex := 0
|
||||
input := schema.Transcript{
|
||||
Metadata: schema.Metadata{
|
||||
Application: "seriatim",
|
||||
Version: "v-test",
|
||||
InputReader: "json-files",
|
||||
InputFiles: []string{"a.json"},
|
||||
PreprocessingModules: []string{"validate-raw"},
|
||||
PostprocessingModules: []string{"assign-ids", "validate-output"},
|
||||
OutputModules: []string{"json"},
|
||||
},
|
||||
Segments: []schema.Segment{
|
||||
{
|
||||
ID: 1,
|
||||
Source: "a.json",
|
||||
SourceSegmentIndex: &sourceIndex,
|
||||
Speaker: "Alice",
|
||||
Start: 1,
|
||||
End: 2,
|
||||
Text: "hello",
|
||||
Categories: []string{"background"},
|
||||
},
|
||||
},
|
||||
OverlapGroups: []schema.OverlapGroup{},
|
||||
}
|
||||
model := mustNormalizeOutputArtifact(t, input)
|
||||
if model.Schema != artifact.OutputSchemaFull {
|
||||
t.Fatalf("schema = %q, want %q", model.Schema, artifact.OutputSchemaFull)
|
||||
}
|
||||
if len(model.Segments) != 1 {
|
||||
t.Fatalf("segment count = %d, want 1", len(model.Segments))
|
||||
}
|
||||
if model.Segments[0].ID != 1 || model.Segments[0].Speaker != "Alice" || model.Segments[0].Text != "hello" {
|
||||
t.Fatalf("unexpected segment: %#v", model.Segments[0])
|
||||
}
|
||||
if len(model.Segments[0].Categories) != 1 || model.Segments[0].Categories[0] != "background" {
|
||||
t.Fatalf("categories = %#v, want [background]", model.Segments[0].Categories)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("intermediate", func(t *testing.T) {
|
||||
input := schema.IntermediateTranscript{
|
||||
Metadata: schema.IntermediateMetadata{
|
||||
Application: "seriatim",
|
||||
Version: "v-test",
|
||||
OutputSchema: artifact.OutputSchemaIntermediate,
|
||||
},
|
||||
Segments: []schema.IntermediateSegment{
|
||||
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "one", Categories: []string{}},
|
||||
{ID: 2, Start: 2, End: 3, Speaker: "Bob", Text: "two"},
|
||||
},
|
||||
}
|
||||
model := mustNormalizeOutputArtifact(t, input)
|
||||
if model.Schema != artifact.OutputSchemaIntermediate {
|
||||
t.Fatalf("schema = %q, want %q", model.Schema, artifact.OutputSchemaIntermediate)
|
||||
}
|
||||
if len(model.Segments[0].Categories) != 0 {
|
||||
t.Fatalf("segment[0] categories = %#v, want empty slice", model.Segments[0].Categories)
|
||||
}
|
||||
if len(model.Segments[1].Categories) != 0 {
|
||||
t.Fatalf("segment[1] categories = %#v, want empty slice", model.Segments[1].Categories)
|
||||
}
|
||||
if model.Segments[0].Categories == nil || model.Segments[1].Categories == nil {
|
||||
t.Fatal("expected non-nil empty categories slices")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("minimal", func(t *testing.T) {
|
||||
input := schema.MinimalTranscript{
|
||||
Metadata: schema.MinimalMetadata{
|
||||
Application: "seriatim",
|
||||
Version: "v-test",
|
||||
OutputSchema: artifact.OutputSchemaMinimal,
|
||||
},
|
||||
Segments: []schema.MinimalSegment{
|
||||
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "one"},
|
||||
},
|
||||
}
|
||||
model := mustNormalizeOutputArtifact(t, input)
|
||||
if model.Schema != artifact.OutputSchemaMinimal {
|
||||
t.Fatalf("schema = %q, want %q", model.Schema, artifact.OutputSchemaMinimal)
|
||||
}
|
||||
if len(model.Segments[0].Categories) != 0 {
|
||||
t.Fatalf("categories = %#v, want empty slice", model.Segments[0].Categories)
|
||||
}
|
||||
if model.Segments[0].Categories == nil {
|
||||
t.Fatal("expected non-nil empty categories slice")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFromOutputArtifactRejectsMalformedAndRawInput(t *testing.T) {
|
||||
_, err := artifact.ParseOutputArtifactJSON([]byte(`{"metadata":`))
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed JSON error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input JSON is malformed") {
|
||||
t.Fatalf("unexpected malformed error: %v", err)
|
||||
}
|
||||
|
||||
rawWhisper := []byte(`{"segments":[{"id":0,"start":0.1,"end":1.2,"text":"hello","words":[{"word":"hello"}]}]}`)
|
||||
_, err = artifact.ParseOutputArtifactJSON(rawWhisper)
|
||||
if err == nil {
|
||||
t.Fatal("expected raw input artifact error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a valid seriatim output artifact") {
|
||||
t.Fatalf("unexpected raw input error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustNormalizeOutputArtifact(t *testing.T, value any) Transcript {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
parsed, err := artifact.ParseOutputArtifactJSON(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
model, err := FromOutputArtifact(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize: %v", err)
|
||||
}
|
||||
return model
|
||||
}
|
||||
41
internal/render/registry.go
Normal file
41
internal/render/registry.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package render
|
||||
|
||||
import "fmt"
|
||||
|
||||
const FormatMarkdown = "markdown"
|
||||
|
||||
// Options configures rendering behavior across formats.
|
||||
type Options struct {
|
||||
Title string
|
||||
IncludeTimestamps bool
|
||||
IncludeSegmentIDs bool
|
||||
IncludeMetadata bool
|
||||
}
|
||||
|
||||
// Renderer turns a normalized render model into text output.
|
||||
type Renderer interface {
|
||||
Render(transcript Transcript, opts Options) (string, error)
|
||||
}
|
||||
|
||||
// Registry resolves renderers by public format name.
|
||||
type Registry struct {
|
||||
renderers map[string]Renderer
|
||||
}
|
||||
|
||||
// NewRegistry returns a renderer registry with built-in renderers.
|
||||
func NewRegistry() Registry {
|
||||
return Registry{
|
||||
renderers: map[string]Renderer{
|
||||
FormatMarkdown: MarkdownRenderer{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve resolves a renderer by format name.
|
||||
func (registry Registry) Resolve(format string) (Renderer, error) {
|
||||
renderer, ok := registry.renderers[format]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported --format %q", format)
|
||||
}
|
||||
return renderer, nil
|
||||
}
|
||||
22
internal/render/registry_test.go
Normal file
22
internal/render/registry_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package render
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRegistryResolvesMarkdownRenderer(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
renderer, err := registry.Resolve(FormatMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve markdown renderer: %v", err)
|
||||
}
|
||||
if renderer == nil {
|
||||
t.Fatal("expected renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRejectsUnknownRenderer(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
_, err := registry.Resolve("txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported format error")
|
||||
}
|
||||
}
|
||||
72
internal/render/run.go
Normal file
72
internal/render/run.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
||||
)
|
||||
|
||||
// Run executes artifact-level render orchestration.
|
||||
func Run(ctx context.Context, cfg config.RenderConfig) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(cfg.InputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read --input-file %q: %w", cfg.InputFile, err)
|
||||
}
|
||||
|
||||
inputArtifact, err := artifact.ParseOutputArtifactJSON(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--input-file %q: %w", cfg.InputFile, err)
|
||||
}
|
||||
|
||||
model, err := FromOutputArtifact(inputArtifact)
|
||||
if err != nil {
|
||||
return fmt.Errorf("normalize artifact for render: %w", err)
|
||||
}
|
||||
|
||||
registry := NewRegistry()
|
||||
renderer, err := registry.Resolve(cfg.Format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rendered, err := renderer.Render(model, Options{
|
||||
Title: cfg.Title,
|
||||
IncludeTimestamps: cfg.IncludeTimestamps,
|
||||
IncludeSegmentIDs: cfg.IncludeSegmentIDs,
|
||||
IncludeMetadata: cfg.IncludeMetadata,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("render %q output: %w", cfg.Format, err)
|
||||
}
|
||||
|
||||
if err := writeFile(cfg.OutputFile, rendered); err != nil {
|
||||
return fmt.Errorf("write --output-file %q: %w", cfg.OutputFile, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFile(path string, content string) (err error) {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %q: %w", path, err)
|
||||
}
|
||||
defer func() {
|
||||
closeErr := file.Close()
|
||||
if err == nil && closeErr != nil {
|
||||
err = fmt.Errorf("close %q: %w", path, closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := file.WriteString(content); err != nil {
|
||||
return fmt.Errorf("write %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
package trim
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
artifactpkg "gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaMinimal = schema.OutputSchemaMinimal
|
||||
SchemaIntermediate = schema.OutputSchemaIntermediate
|
||||
SchemaFull = schema.OutputSchemaFull
|
||||
SchemaMinimal = artifactpkg.OutputSchemaMinimal
|
||||
SchemaIntermediate = artifactpkg.OutputSchemaIntermediate
|
||||
SchemaFull = artifactpkg.OutputSchemaFull
|
||||
)
|
||||
|
||||
// Artifact stores a parsed seriatim output artifact of one supported schema.
|
||||
@@ -31,42 +31,16 @@ type ApplyArtifactResult struct {
|
||||
|
||||
// ParseArtifactJSON parses and validates a serialized seriatim output artifact.
|
||||
func ParseArtifactJSON(data []byte) (Artifact, error) {
|
||||
var decoded any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return Artifact{}, fmt.Errorf("input JSON is malformed: %w", err)
|
||||
parsed, err := artifactpkg.ParseOutputArtifactJSON(data)
|
||||
if err != nil {
|
||||
return Artifact{}, err
|
||||
}
|
||||
|
||||
var full schema.Transcript
|
||||
if err := json.Unmarshal(data, &full); err == nil {
|
||||
if err := schema.ValidateTranscript(full); err == nil {
|
||||
return Artifact{
|
||||
Schema: SchemaFull,
|
||||
Full: &full,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var intermediate schema.IntermediateTranscript
|
||||
if err := json.Unmarshal(data, &intermediate); err == nil {
|
||||
if err := schema.ValidateIntermediateTranscript(intermediate); err == nil {
|
||||
return Artifact{
|
||||
Schema: SchemaIntermediate,
|
||||
Intermediate: &intermediate,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var minimal schema.MinimalTranscript
|
||||
if err := json.Unmarshal(data, &minimal); err == nil {
|
||||
if err := schema.ValidateMinimalTranscript(minimal); err == nil {
|
||||
return Artifact{
|
||||
Schema: SchemaMinimal,
|
||||
Minimal: &minimal,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return Artifact{}, fmt.Errorf("input JSON is not a valid seriatim output artifact")
|
||||
return Artifact{
|
||||
Schema: parsed.Schema,
|
||||
Full: parsed.Full,
|
||||
Intermediate: parsed.Intermediate,
|
||||
Minimal: parsed.Minimal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateArtifact validates an artifact against its declared schema.
|
||||
|
||||
Reference in New Issue
Block a user