Implement artifact-level render command with Markdown output and update docs

This commit is contained in:
2026-05-24 22:55:56 +00:00
parent a90859114a
commit c37ea70dcb
28 changed files with 1619 additions and 99 deletions

View 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
}

View 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
View 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
View 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:0100:00:04] **Eric:** Hello there.") {
t.Fatalf("missing first segment:\n%s", data)
}
if !strings.Contains(data, "[00:00:0500: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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()

View 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)
}

View 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:0100:00:04] **Eric:** Hello there.") {
t.Fatalf("expected first segment in output:\n%s", output)
}
if !strings.Contains(output, "[00:00:0500: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
View 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
}

View 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
}

View 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
}

View 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
}

View 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
View 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
}

View File

@@ -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.