diff --git a/README.md b/README.md index 2a3d25e..c21c485 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ `seriatim` is a Go CLI for transcript artifact processing. -It merges per-speaker WhisperX-style JSON into one deterministic transcript, trims existing seriatim artifacts by segment ID, and normalizes transcript-like JSON into standard seriatim output schemas. +It merges per-speaker WhisperX-style JSON into deterministic seriatim JSON, +trims existing seriatim artifacts by segment ID, normalizes transcript-like JSON +into supported output schemas, and renders existing seriatim artifacts as +human-readable Markdown. ## Quickstart @@ -20,6 +23,7 @@ go run ./cmd/seriatim merge \ - `merge`: merge one or more input transcript JSON files. - `trim`: keep/remove segment IDs from an existing seriatim artifact. - `normalize`: canonicalize transcript-like JSON into a seriatim artifact. +- `render`: render an existing seriatim artifact as Markdown. ## Documentation diff --git a/docs/cli.md b/docs/cli.md index 162d704..8d1164f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,6 +16,7 @@ go run ./cmd/seriatim merge \ | `merge` | Merge one or more raw transcript JSON inputs into one seriatim artifact. | | `trim` | Keep or remove segment IDs from an existing seriatim artifact. | | `normalize` | Canonicalize transcript-like JSON into a seriatim artifact. | +| `render` | Render an existing seriatim artifact as Markdown. | Root usage: @@ -130,6 +131,34 @@ Flags: - Does not run merge modules. - When `--output-schema` is omitted, schema resolution is: `SERIATIM_OUTPUT_SCHEMA` -> default `seriatim-intermediate`. +## `render` + +Usage: + +```text +seriatim render [flags] +``` + +Flags: + +| Flag | Required | Default | Description | +| --- | --- | --- | --- | +| `--input-file string` | Yes | none | Input seriatim artifact JSON file. | +| `--output-file string` | Yes | none | Rendered output file path. | +| `--format string` | Yes | none | Output format. Current supported value: `markdown`. | +| `--title string` | No | `Transcript` | Markdown document title. | +| `--include-timestamps` | No | `true` | Include `[HH:MM:SS–HH:MM:SS]` per segment. | +| `--include-segment-ids` | No | `false` | Include `[#id]` marker per segment. | +| `--include-metadata` | No | `false` | Include artifact metadata block near the top. | + +`render` behavior: + +- Input must be a valid existing seriatim output artifact (`seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`). +- Raw WhisperX-style JSON is rejected. +- `render` does not execute merge/trim/normalize transformations. +- Markdown output is deterministic for the same input artifact and render flags. +- Category names are not printed directly; `background`, `backchannel`, and `filler` only influence italics. + ## Common workflows Merge with a speaker map and report output: @@ -160,6 +189,15 @@ go run ./cmd/seriatim normalize \ --output-file /tmp/seriatim-example-normalize-object.json ``` +Render an existing artifact as Markdown: + +```sh +go run ./cmd/seriatim render \ + --input-file examples/render/input-intermediate.json \ + --output-file /tmp/seriatim-example-render.md \ + --format markdown +``` + ## Exit and errors - Commands return exit code `0` on success. diff --git a/docs/config.md b/docs/config.md index 3a7a21c..254ea06 100644 --- a/docs/config.md +++ b/docs/config.md @@ -23,6 +23,17 @@ For `trim`: - If `--output-schema` is omitted, output preserves the input artifact schema. - If `--output-schema` is set, it must be one of `seriatim-minimal`, `seriatim-intermediate`, `seriatim-full`. +## Render format and defaults + +`render` requires `--format`. Current supported value is `markdown`. + +Render defaults: + +- `--title`: `Transcript` +- `--include-timestamps`: `true` +- `--include-segment-ids`: `false` +- `--include-metadata`: `false` + ## Merge module defaults Default merge module selections: @@ -152,6 +163,11 @@ All commands: - Validates `--output-schema` through the same schema set as `merge`. - Currently accepts only `json` in `--output-modules`. +`render`: + +- Requires `--input-file`, `--output-file`, and `--format`. +- Validates `--format` as `markdown`. + ## Related docs - CLI reference: [cli.md](cli.md) diff --git a/docs/integrations/output-schemas.md b/docs/integrations/output-schemas.md index 62ff2b4..e7c4387 100644 --- a/docs/integrations/output-schemas.md +++ b/docs/integrations/output-schemas.md @@ -8,7 +8,8 @@ seriatim emits one of three public JSON output contracts: - `seriatim-intermediate` - `seriatim-full` -These are used by `merge`, `trim`, and `normalize`. +These are used by `merge`, `trim`, and `normalize`, and are accepted as input +by `render`. ## Schema roles diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index 7956141..0bf70ad 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -2,8 +2,8 @@ ## Purpose -Describes public artifact conversion and validation internals for merge output, -trim, and normalize. +Describes implemented artifact parsing, conversion, validation, and render-model +normalization internals. ## Artifact contracts @@ -19,35 +19,40 @@ Machine-readable schemas: - `schema/intermediate-output.schema.json` - `schema/minimal-output.schema.json` -## Schema selection +## Shared output-artifact parser -Merge pipeline conversion uses `internal/artifact.SelectedFromMerged`: +`internal/artifact/output_artifact.go` provides schema-aware parsing for +existing seriatim output artifacts. + +Behavior: + +- accepts only valid full, intermediate, or minimal seriatim output artifacts +- validates through `schema` semantic + JSON schema checks +- rejects malformed JSON +- rejects raw WhisperX-style JSON and other non-seriatim shapes + +Consumers: + +- `internal/trim` artifact-level trim flow +- `internal/render` artifact-level render flow + +## Merge conversion behavior + +`internal/artifact/transcript.go` converts `model.MergedTranscript` to public +contracts: + +- full schema preserves source/provenance, overlap groups, and metadata module + lists +- intermediate schema emits segment timing/text/speaker with optional + categories and compact metadata +- minimal schema emits compact segment timing/text/speaker and compact metadata + +Schema selection uses `internal/artifact.SelectedFromMerged`: - `seriatim-full` -> `artifact.FromMerged` - `seriatim-intermediate` -> `artifact.IntermediateFromMerged` - `seriatim-minimal` -> `artifact.MinimalFromMerged` - -Unknown/empty selection falls back to intermediate conversion. - -## Merge conversion behavior - -`internal/artifact` converts `model.MergedTranscript` to public contracts: - -- full schema preserves source/provenance, overlap groups, and metadata module - lists. -- intermediate schema emits segment timing/text/speaker with optional - categories and compact metadata. -- minimal schema emits compact segment timing/text/speaker and compact - metadata. - -## Validation behavior - -`schema/output.go` validates both structure and semantics: - -- embedded JSON Schema validation via `jsonschema/v6` -- semantic checks for sequential segment IDs starting at `1` -- semantic checks for non-inverted segment timing (`end >= start`) -- full schema overlap-group timing checks (`group.end >= group.start`) +- unknown/empty -> intermediate fallback ## Trim internals @@ -72,9 +77,8 @@ Apply layer (`apply.go`): - schema-specific segment reconstruction for full/intermediate/minimal outputs - overlap-group recomputation only for full-schema outputs -Artifact layer (`artifact.go`): +Artifact conversion layer (`artifact.go`): -- schema detection for full/intermediate/minimal artifacts - schema-preserving trim application - supported schema conversions: - full -> intermediate/minimal @@ -85,10 +89,10 @@ Artifact layer (`artifact.go`): Trim invariants: -- selected IDs must exist in input. -- input IDs must be positive, unique, sequential. -- retained segment order follows input transcript order. -- output IDs are reassigned to `1..N`. +- selected IDs must exist in input +- input IDs must be positive, unique, sequential +- retained segment order follows input transcript order +- output IDs are reassigned to `1..N` ## Normalize internals @@ -117,13 +121,64 @@ Run layer (`normalize.go`): Normalize invariant: -- report events do not embed transcript text. +- report events do not embed transcript text + +## Render internals + +`internal/render` is an artifact-level, downstream-only renderer. + +Model normalization (`normalize.go`): + +- converts full/intermediate/minimal artifacts into a common render model +- preserves segment order and segment IDs +- normalizes per-segment fields to ID, start, end, speaker, text, categories +- emits empty categories slice when categories are absent in input + +Renderer registry (`registry.go`): + +- resolves renderers by public format name +- currently registers `markdown` + +Markdown renderer (`markdown.go`): + +- writes title header `# {title}` +- renders optional `[HH:MM:SS–HH:MM:SS]` timestamps +- renders optional `[#id]` segment references +- renders `**speaker:** text` +- italicizes text when categories include `background`, `backchannel`, or + `filler` +- ignores unknown categories +- optionally includes metadata summary block + +Run layer (`run.go`): + +1. Read input artifact JSON. +2. Parse via shared output-artifact parser. +3. Normalize to render model. +4. Resolve renderer by `--format`. +5. Render text output. +6. Write output file. + +Render invariants: + +- does not run merge/trim/normalize modules +- does not expose report output +- deterministic for identical input artifact and render flags + +## Validation behavior + +`schema/output.go` validates both structure and semantics: + +- embedded JSON Schema validation via `jsonschema/v6` +- semantic checks for sequential segment IDs starting at `1` +- semantic checks for non-inverted segment timing (`end >= start`) +- full schema overlap-group timing checks (`group.end >= group.start`) ## Boundaries - CLI flag semantics belong to `docs/cli.md`. - Runtime config/env surfaces belong to `docs/config.md`. -- This doc describes internal conversion/validation behavior only. +- This document describes internal conversion/validation behavior only. ## Failure behavior @@ -133,22 +188,29 @@ Representative failure classes: - schema validation failure for parsed artifact or built output - unsupported schema conversion path (trim) - selector or input-ID consistency errors (trim) +- unsupported renderer format (render) - output/report file write failures from command paths ## Tests to inspect before changes - `schema/output_test.go` - `internal/artifact/transcript_test.go` +- `internal/artifact/output_artifact_test.go` - `internal/trim/selector_test.go` - `internal/trim/artifact_test.go` - `internal/trim/apply_test.go` - `internal/normalize/parse_test.go` +- `internal/render/normalize_test.go` +- `internal/render/markdown_test.go` +- `internal/render/registry_test.go` - `internal/cli/trim_test.go` - `internal/cli/normalize_test.go` +- `internal/cli/render_test.go` ## Invariants - Public artifacts are validated through `schema` before acceptance. - Segment IDs in emitted artifacts are sequential and deterministic. - Internal-only fields are not emitted in minimal/intermediate contracts. -- Trim and normalize stay artifact-level and do not execute merge modules. +- Trim, normalize, and render stay artifact-level and do not execute merge + modules. diff --git a/docs/internal/pipeline.md b/docs/internal/pipeline.md index 386a063..d3148b7 100644 --- a/docs/internal/pipeline.md +++ b/docs/internal/pipeline.md @@ -73,7 +73,8 @@ coalesce gap and overlap thresholds). - Pipeline does not parse CLI flags. - Pipeline does not normalize raw CLI strings. - Pipeline delegates conversion to public output contracts to `internal/artifact`. -- Artifact-level commands `trim` and `normalize` are outside this pipeline. +- Artifact-level commands `trim`, `normalize`, and `render` are outside this + pipeline. ## Failure behavior diff --git a/docs/operations.md b/docs/operations.md index 4d12c9e..60c8a28 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -7,6 +7,7 @@ This document covers runtime operation of the implemented CLI commands: - `merge` - `trim` - `normalize` +- `render` ## Runtime model @@ -29,6 +30,7 @@ Command-specific expectations: - `merge`: requires at least one `--input-file`; optional `--speakers` and `--autocorrect` paths must exist when provided. - `trim`: input must be an existing valid seriatim artifact JSON file. - `normalize`: input must be a JSON object with `segments` or a top-level segment array. +- `render`: input must be an existing valid seriatim artifact JSON file. ## Normal workflow @@ -80,11 +82,28 @@ go run ./cmd/seriatim normalize \ --report-file normalize-report.json ``` +### Render + +1. Provide existing seriatim artifact with `--input-file`. +2. Provide `--output-file`. +3. Provide `--format markdown`. +4. Optionally provide `--title`, `--include-timestamps`, `--include-segment-ids`, and `--include-metadata`. + +Example: + +```sh +go run ./cmd/seriatim render \ + --input-file normalized.json \ + --output-file transcript.md \ + --format markdown +``` + ## Output and report artifacts -Primary output: +Primary outputs: -- `--output-file` writes JSON transcript artifact in selected schema. +- `merge`, `trim`, `normalize`: `--output-file` writes JSON transcript artifact in the selected schema. +- `render`: `--output-file` writes presentation Markdown. Optional report output: @@ -92,6 +111,7 @@ Optional report output: - `merge` report metadata records reader/modules and event sequence. - `trim` report includes a `trim-audit` event with mode/selector/counts and old-to-new ID mapping. - `normalize` report includes a `normalize-audit` event with input shape, repair stats, and output selection details. +- `render` has no report output in the current implementation. ## Failure and retry behavior @@ -106,9 +126,10 @@ Retry guidance: 2. Re-run the same command. 3. If a prior run created a partial or unwanted output/report file, remove it and rerun. -Operational note: +Operational notes: -- With identical inputs/config/version, merge behavior is deterministic and input files are sorted before processing. +- With identical inputs/config/version, `merge` behavior is deterministic and input files are sorted before processing. +- With identical input artifact and render flags, `render` output is deterministic. ## Cleanup @@ -124,6 +145,7 @@ Transcript artifacts and reports are local files and may contain sensitive conve - Store outputs in controlled directories with appropriate OS permissions. - Share report files carefully; they include file paths and processing diagnostics. - Normalize report events intentionally avoid embedding transcript text, but output artifacts contain transcript content. +- Rendered Markdown is human-readable transcript content and should be handled as sensitive output when applicable. ## Related docs diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index c04598c..63640cc 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -14,7 +14,7 @@ must describe current behavior only; planned or speculative work belongs under ## Project Shape seriatim is a Go CLI for transcript artifact processing. The implemented -commands are `merge`, `trim`, and `normalize`. +commands are `merge`, `trim`, `normalize`, and `render`. `merge` reads one or more JSON transcript files, optionally maps input files to canonical speakers, runs a registry-selected preprocessing chain, merges @@ -22,11 +22,12 @@ canonical segments into deterministic chronological order, runs a registry-selected postprocessing chain, validates the selected output schema, and writes JSON output plus an optional JSON report. -`trim` and `normalize` are artifact-level commands outside the merge pipeline. -`trim` reads an existing seriatim output artifact and projects it by segment ID. -`normalize` reads transcript-like JSON and emits one of seriatim's supported -output schemas. Neither command runs merge preprocessing or postprocessing -modules. +`trim`, `normalize`, and `render` are artifact-level commands outside the merge +pipeline. `trim` reads an existing seriatim output artifact and projects it by +segment ID. `normalize` reads transcript-like JSON and emits one of seriatim's +supported output schemas. `render` reads an existing seriatim output artifact +and emits human-readable Markdown. None of these commands runs merge +preprocessing or postprocessing modules. The supported public output schemas are `seriatim-minimal`, `seriatim-intermediate`, and `seriatim-full`. For command and flag details, use @@ -66,9 +67,9 @@ collects report events, converts the final transcript, and writes optional reports. Built-in adapters and modules are registered from `internal/builtin`. CLI code in `internal/cli` should parse flags, build validated config values, -and delegate. `merge` delegates to `pipeline.Run`; `trim` and `normalize` -perform artifact-level orchestration and delegate deterministic parsing, -validation, and transformation work to their internal packages. +and delegate. `merge` delegates to `pipeline.Run`; `trim`, `normalize`, and +`render` perform artifact-level orchestration and delegate deterministic +parsing, validation, and transformation work to their internal packages. Config loading and validation belongs in `internal/config`. Filesystem reads and writes are adapter concerns and should not spread into pure transformation @@ -166,10 +167,10 @@ correction or annotation modules, inspect the package tests for overlap, coalesce, danglers, backchannel, filler, and autocorrect behavior. When changing artifact-level commands, inspect `internal/trim`, -`internal/normalize`, and their CLI tests. When changing public output shape or -schema validation, inspect `schema` and `internal/artifact` tests. Report and -diagnostic changes should be covered through the command or package tests that -emit the affected events. +`internal/normalize`, `internal/render`, and their CLI tests. When changing +public output shape or schema validation, inspect `schema` and +`internal/artifact` tests. Report and diagnostic changes should be covered +through the command or package tests that emit the affected events. ## Dependency Policy @@ -202,8 +203,8 @@ free of secrets or private transcript data. registry name. - Preserve deterministic ordering, final segment ID assignment, and schema validation before output acceptance. -- Keep `trim` and `normalize` artifact-level; do not run merge modules from - those commands. +- Keep `trim`, `normalize`, and `render` artifact-level; do not run merge + modules from those commands. - Keep public output schemas validated through `schema`. - Keep optional reports ordered, concise, and diagnostic. - Avoid broad dependencies without a concrete maintainability benefit. diff --git a/docs/policy/development.md b/docs/policy/development.md index 6fe3fc6..1af5b5c 100644 --- a/docs/policy/development.md +++ b/docs/policy/development.md @@ -16,6 +16,7 @@ It complements [architecture policy](architecture.md) and - `internal/artifact/`: conversion from internal merged model to public shapes. - `internal/trim/`: artifact-level trim logic. - `internal/normalize/`: artifact-level normalize parsing/building. +- `internal/render/`: artifact-level rendering and renderer registry. - `internal/*` domain packages: overlap, coalesce, danglers, filler, backchannel, speaker, autocorrect, report, model. - `schema/`: public structs plus embedded JSON Schemas and validation. @@ -36,6 +37,7 @@ go run ./cmd/seriatim --help go run ./cmd/seriatim merge --help go run ./cmd/seriatim trim --help go run ./cmd/seriatim normalize --help +go run ./cmd/seriatim render --help ``` Current toolchain note: diff --git a/examples/README.md b/examples/README.md index 95c92eb..188902b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,8 +1,7 @@ # Examples These are small synthetic, copyable example assets for the implemented CLI -commands. -This directory is the canonical examples home for documentation. +commands. This directory is the canonical examples home for documentation. ## Merge example @@ -55,6 +54,25 @@ go run ./cmd/seriatim trim \ --keep "1-2" ``` +## Render example + +Input artifact: + +- `render/input-intermediate.json` + +Expected Markdown output shape: + +- `render/output-markdown.md` + +Run: + +```sh +go run ./cmd/seriatim render \ + --input-file examples/render/input-intermediate.json \ + --output-file /tmp/seriatim-example-render.md \ + --format markdown +``` + ## YAML rule examples - `speakers.yml` diff --git a/examples/render/input-intermediate.json b/examples/render/input-intermediate.json new file mode 100644 index 0000000..1bb7f14 --- /dev/null +++ b/examples/render/input-intermediate.json @@ -0,0 +1,33 @@ +{ + "metadata": { + "application": "seriatim", + "version": "v-test", + "output_schema": "seriatim-intermediate" + }, + "segments": [ + { + "id": 1, + "start": 1, + "end": 4, + "speaker": "Eric Rakestraw", + "text": "Hello there." + }, + { + "id": 2, + "start": 5, + "end": 8, + "speaker": "Mike Brown", + "text": "Welcome back, everyone." + }, + { + "id": 3, + "start": 9, + "end": 10, + "speaker": "Eric Rakestraw", + "text": "Yeah.", + "categories": [ + "backchannel" + ] + } + ] +} diff --git a/examples/render/output-markdown.md b/examples/render/output-markdown.md new file mode 100644 index 0000000..3fcb95c --- /dev/null +++ b/examples/render/output-markdown.md @@ -0,0 +1,7 @@ +# Transcript + +[00:00:01–00:00:04] **Eric Rakestraw:** Hello there. + +[00:00:05–00:00:08] **Mike Brown:** Welcome back, everyone. + +[00:00:09–00:00:10] **Eric Rakestraw:** *Yeah.* diff --git a/internal/artifact/output_artifact.go b/internal/artifact/output_artifact.go new file mode 100644 index 0000000..c44eb32 --- /dev/null +++ b/internal/artifact/output_artifact.go @@ -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 +} diff --git a/internal/artifact/output_artifact_test.go b/internal/artifact/output_artifact_test.go new file mode 100644 index 0000000..77297a0 --- /dev/null +++ b/internal/artifact/output_artifact_test.go @@ -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 +} diff --git a/internal/cli/render.go b/internal/cli/render.go new file mode 100644 index 0000000..d31b0c7 --- /dev/null +++ b/internal/cli/render.go @@ -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 +} diff --git a/internal/cli/render_test.go b/internal/cli/render_test.go new file mode 100644 index 0000000..e74e28e --- /dev/null +++ b/internal/cli/render_test.go @@ -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) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 306525d..17eca90 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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 } diff --git a/internal/config/config.go b/internal/config/config.go index 3895f03..4ff3e8f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 02f19eb..d570d89 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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() diff --git a/internal/render/markdown.go b/internal/render/markdown.go new file mode 100644 index 0000000..be9a910 --- /dev/null +++ b/internal/render/markdown.go @@ -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) +} diff --git a/internal/render/markdown_test.go b/internal/render/markdown_test.go new file mode 100644 index 0000000..afaefee --- /dev/null +++ b/internal/render/markdown_test.go @@ -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) + } +} diff --git a/internal/render/model.go b/internal/render/model.go new file mode 100644 index 0000000..4cdaa15 --- /dev/null +++ b/internal/render/model.go @@ -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 +} diff --git a/internal/render/normalize.go b/internal/render/normalize.go new file mode 100644 index 0000000..e9a7624 --- /dev/null +++ b/internal/render/normalize.go @@ -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 +} diff --git a/internal/render/normalize_test.go b/internal/render/normalize_test.go new file mode 100644 index 0000000..1861e8b --- /dev/null +++ b/internal/render/normalize_test.go @@ -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 +} diff --git a/internal/render/registry.go b/internal/render/registry.go new file mode 100644 index 0000000..bc621cc --- /dev/null +++ b/internal/render/registry.go @@ -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 +} diff --git a/internal/render/registry_test.go b/internal/render/registry_test.go new file mode 100644 index 0000000..e6bea6e --- /dev/null +++ b/internal/render/registry_test.go @@ -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") + } +} diff --git a/internal/render/run.go b/internal/render/run.go new file mode 100644 index 0000000..2adf4e9 --- /dev/null +++ b/internal/render/run.go @@ -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 +} diff --git a/internal/trim/artifact.go b/internal/trim/artifact.go index 81bd365..10a4096 100644 --- a/internal/trim/artifact.go +++ b/internal/trim/artifact.go @@ -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.