23 Commits

Author SHA1 Message Date
0fc92f3643 Implemented Markdown escaping for render output
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-24 18:13:05 -05:00
0dfd06c349 Rewrite troubleshooting docs for current render and artifact behavior 2026-05-24 23:03:19 +00:00
da3720693d Refine render documentation for required flags and workflow examples 2026-05-24 23:01:41 +00:00
6dfc1ea527 Harden render validation tests for input paths and empty transcript output 2026-05-24 23:00:10 +00:00
761d70bbc6 Add required-flags render CLI coverage 2026-05-24 22:58:56 +00:00
451cc19418 Add deterministic Markdown render coverage for renderer behavior 2026-05-24 22:57:36 +00:00
c37ea70dcb Implement artifact-level render command with Markdown output and update docs 2026-05-24 22:55:56 +00:00
a90859114a Add render command roadmap 2026-05-24 17:07:47 -05:00
9202ccddb9 Implemented the remaining trim artifact cleanup
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-24 16:58:10 -05:00
f40d4add91 Refresh docs for finalized cleanup architecture 2026-05-24 15:11:11 +00:00
16bb12face Complete cleanup sweep and align internal artifact docs 2026-05-24 15:08:03 +00:00
f18e2428dc Reduce config test setup duplication with option builders 2026-05-24 15:06:35 +00:00
3b64e784a1 Deduplicate schema segment semantics validation 2026-05-24 15:04:33 +00:00
3744d229a2 Centralize segment reference fallback in model 2026-05-24 15:03:15 +00:00
9bbe1fb7f1 Extract shared CLI flag wiring helpers 2026-05-24 15:01:33 +00:00
b7a66f6cc4 Refactor single-input config path normalization helpers 2026-05-24 14:59:14 +00:00
c8efdb53d3 Centralize deterministic JSON file writing 2026-05-24 14:57:12 +00:00
ab4b252b08 Move trim command orchestration into internal trim package 2026-05-24 14:54:54 +00:00
e9028e08a4 Unify trim selector projection across output schemas 2026-05-24 14:52:08 +00:00
332884f887 Centralize output schema names in schema package 2026-05-24 14:49:18 +00:00
e5173c78fe Added a staged roadmap to address the issues identified by the code audit 2026-05-24 09:41:03 -05:00
546be2ab92 Remove the completed documentation roadmap 2026-05-24 09:33:51 -05:00
7743b397a6 Audit code quality and deduplication opportunities 2026-05-24 09:32:54 -05:00
53 changed files with 3390 additions and 1219 deletions

View File

@@ -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
@@ -27,19 +31,19 @@ go run ./cmd/seriatim merge \
- Configuration reference: [docs/config.md](docs/config.md)
- Operations guide: [docs/operations.md](docs/operations.md)
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- Integrations:
- Integration references:
- [docs/integrations/whisperx-json.md](docs/integrations/whisperx-json.md)
- [docs/integrations/output-schemas.md](docs/integrations/output-schemas.md)
- Development architecture policy: [docs/policy/architecture.md](docs/policy/architecture.md)
- Contributor workflow: [docs/policy/development.md](docs/policy/development.md)
- Documentation policy: [docs/policy/documentation.md](docs/policy/documentation.md)
- Internal implementation docs:
- Development policies:
- [docs/policy/architecture.md](docs/policy/architecture.md)
- [docs/policy/development.md](docs/policy/development.md)
- [docs/policy/documentation.md](docs/policy/documentation.md)
- Internal implementation references:
- [docs/internal/pipeline.md](docs/internal/pipeline.md)
- [docs/internal/artifacts.md](docs/internal/artifacts.md)
- [docs/internal/modules.md](docs/internal/modules.md)
- Public JSON schemas:
- Public JSON schema files:
- [schema/minimal-output.schema.json](schema/minimal-output.schema.json)
- [schema/intermediate-output.schema.json](schema/intermediate-output.schema.json)
- [schema/full-output.schema.json](schema/full-output.schema.json)
- Synthetic examples: [examples/README.md](examples/README.md)
- Documentation roadmap: [docs/roadmap/documentation.md](docs/roadmap/documentation.md)

View File

@@ -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,35 @@ 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:SSHH: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.
- `render` has no `--report-file` output in the current implementation.
- 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 +190,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.
@@ -179,4 +218,3 @@ go run ./cmd/seriatim normalize \
- [../schema/minimal-output.schema.json](../schema/minimal-output.schema.json)
- [../schema/intermediate-output.schema.json](../schema/intermediate-output.schema.json)
- [../schema/full-output.schema.json](../schema/full-output.schema.json)
- Documentation roadmap: [roadmap/documentation.md](roadmap/documentation.md)

View File

@@ -23,6 +23,18 @@ 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 `--input-file`, `--output-file`, and `--format`.
Current supported format 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 +164,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)
@@ -165,4 +182,3 @@ All commands:
- [../schema/minimal-output.schema.json](../schema/minimal-output.schema.json)
- [../schema/intermediate-output.schema.json](../schema/intermediate-output.schema.json)
- [../schema/full-output.schema.json](../schema/full-output.schema.json)
- Documentation roadmap: [roadmap/documentation.md](roadmap/documentation.md)

View File

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

View File

@@ -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,62 +19,80 @@ 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
`internal/trim` is artifact-level projection, not merge reprocessing.
`internal/trim` handles artifact-level projection and does not execute merge
pipeline modules.
Core flow:
Run layer (`run.go`):
1. Parse selector (`internal/trim/selector.go`).
2. Parse input artifact and detect schema (`ParseArtifactJSON`).
3. Apply keep/remove projection with sequential ID renumbering.
4. Recompute overlap groups only for full-schema artifacts.
5. Optionally convert output schema when supported.
6. Validate output artifact before write.
1. Parse selector from validated config.
2. Read and parse input artifact JSON.
3. Apply trim projection through schema-aware artifact handling.
4. Resolve output schema (preserve input schema unless overridden).
5. Validate output artifact.
6. Write output JSON.
7. Optionally write report JSON with `trim-audit`.
Schema-conversion limits:
Apply layer (`apply.go`):
- full -> intermediate/minimal supported.
- intermediate -> minimal supported.
- minimal -> intermediate supported.
- intermediate/minimal -> full is rejected.
- one shared projection policy for selector mode, input ID validation, selected
ID existence checks, keep/remove filtering, removed IDs, and old-to-new ID
mappings
- schema-specific segment reconstruction for full/intermediate/minimal outputs
- overlap-group recomputation only for full-schema outputs
Artifact conversion layer (`artifact.go`):
- schema-preserving trim application
- supported schema conversions:
- full -> intermediate/minimal
- intermediate -> minimal
- minimal -> intermediate
- rejected conversion:
- intermediate/minimal -> full
Trim invariants:
- selected IDs must exist in input.
- input IDs must be positive, unique, sequential.
- retained 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
@@ -103,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:SSHH: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
@@ -119,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.

View File

@@ -55,7 +55,8 @@ Output writer:
- `autocorrect`: applies YAML replacement rules when configured.
- `assign-ids`: assigns final sequential IDs.
- `validate-output`: validates selected public artifact shape.
- `json`: writes artifact JSON to `cfg.OutputFile`.
- `json`: writes artifact JSON to `cfg.OutputFile` through shared deterministic
JSON file writing.
Filesystem side effects are limited to:

View File

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

View File

@@ -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 examples/render/input-intermediate.json \
--output-file /tmp/seriatim-example-render.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

View File

@@ -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
@@ -197,12 +198,13 @@ free of secrets or private transcript data.
- Keep core/domain logic separate from CLI, config, filesystem, reporting, and
other adapter concerns.
- Centralize default configuration values as constants defined in internal/config/config.go.
- Keep modules narrowly scoped, explicitly configured, and composable by
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.

View File

@@ -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:
@@ -52,6 +54,7 @@ Current toolchain note:
- Keep validation in `internal/config` and package-specific validators.
- Return errors from deep logic; do not print inside internal packages.
- Preserve clear package boundaries between adapters and domain transforms.
- Define configuration defaults as constants in internal/config/config.go.
## Dependency policy

View File

@@ -1,586 +0,0 @@
# Documentation Roadmap
## Purpose
This roadmap defines the work required to bring seriatim's documentation into
compliance with `docs/policy/documentation.md` and the current implementation.
It is grounded in the repository as it exists now: the Go CLI, config loading,
pipeline modules, artifact commands, schemas, reports, samples, and tests.
Outside `docs/roadmap/`, documentation must describe only implemented
behavior. Planned, future, deprecated, experimental, or unimplemented work must
remain in roadmap documents until the code exists.
## Repository Documentation Inventory
- `README.md` - keep and rewrite. It currently mixes project orientation,
quickstart, full CLI reference, config/env reference, file formats, module
internals, limitations, and release build notes. Policy says README should be
concise and link to canonical docs.
- `docs/policy/documentation.md` - keep and lightly update only if the policy
itself changes. It is the controlling documentation layout and maintenance
policy.
- `docs/policy/architecture.md` - keep and lightly update as implementation
changes. It is the canonical development architecture policy.
- Root `architecture.md` - delete after salvage, or move only truly roadmap
material into `docs/roadmap/`. It is in the wrong canonical home and contains
future-oriented and aspirational claims.
- `docs/roadmap/documentation.md` - create new. This file is the planning
artifact for the documentation migration.
- `samples/` - split or move after audit. It contains sample raw transcripts,
merged artifacts, reports, `speakers.yml`, and `autocorrect.yml`, but
copyable examples belong under `examples/`. The raw sample data is large and
should be reviewed for privacy and maintainability before linking from docs.
- `schema/*.schema.json` - keep. These are public output contracts and should
be linked from documentation instead of duplicated in full.
- Missing canonical docs - create `docs/cli.md`, `docs/config.md`,
`docs/operations.md`, `docs/policy/development.md`, `docs/internal/`, and
likely `docs/troubleshooting.md`, `docs/integrations/`, and `examples/`.
## Policy Compliance Assessment
Required documents missing for seriatim's current shape as a modular, staged,
CLI/config-driven project:
- `docs/cli.md`
- `docs/config.md`
- `docs/operations.md`
- `docs/internal/`
- `docs/policy/development.md`
Recommended documents and directories missing:
- `docs/troubleshooting.md`
- maintained copyable examples under `examples/`
- concise integration notes under `docs/integrations/`
Existing compliance issues:
- `README.md` is too broad for its canonical scope. It should keep project
purpose, quickstart, and links, then delegate CLI, config, operations,
internals, and schema details.
- Root `architecture.md` is stale and in the wrong home. It includes future
input methods and formats, future output formats, dynamic plugin speculation,
an LLM non-goal, interface sketches that diverge from code, and other
development-policy content now covered by `docs/policy/architecture.md`.
- Non-roadmap docs should not carry forward claims about future defaults,
future formats, unimplemented plugin systems, or unimplemented alternate
input/output methods.
- Historical or deprecated wording, such as the old speaker map format, should
move out of the README unless it is still needed in troubleshooting or a
narrow migration note.
- There is no `examples/` directory. `samples/` exists but is not the canonical
examples home and should not be treated as copyable public examples without a
privacy and size audit.
- Links need verification after migration: README should link to all new
canonical docs, docs should link to schema files and maintained examples, and
no doc should link to the deleted root `architecture.md`.
## Target Documentation Set
### `README.md`
- Audience: users, administrators, and operators.
- Purpose: project orientation and shortest useful quickstart.
- Canonical scope: concise project purpose, elevator pitch, one minimal command,
and links to targeted docs.
- Recommended outline: project description; shortest merge command; command
summary; links to CLI, config, operations, architecture, development, schemas,
examples, and troubleshooting.
- Source of truth: current `README.md`, `internal/cli`, `internal/config`,
`cmd/seriatim/main.go`, and CLI tests.
- Acceptance criteria: no full flag tables, no full config reference, no module
manual, no future-feature claims, and all links resolve.
### `docs/cli.md`
- Audience: users, administrators, and operators.
- Purpose: canonical CLI reference and workflows.
- Canonical scope: shortest useful command, command overview, complete flag
reference, common workflows, diagnostics and report flags.
- Recommended outline: shortest useful command; global flags; `merge`; `trim`;
`normalize`; common workflows; exit/error behavior; links to config,
operations, examples, and schemas.
- Source of truth: `internal/cli/root.go`, `internal/cli/merge.go`,
`internal/cli/trim.go`, `internal/cli/normalize.go`, `internal/config`, and
`internal/cli/*_test.go`.
- Acceptance criteria: every documented flag, default, and required/mutually
exclusive rule matches code; package internals are linked rather than
explained in depth.
### `docs/config.md`
- Audience: administrators, operators, and advanced users.
- Purpose: canonical runtime configuration reference.
- Canonical scope: environment variables, default module lists, output schema
selection, `speakers.yml`, `autocorrect.yml`, path validation, and precedence.
- Recommended outline: config surfaces; output schema precedence; merge module
defaults; environment variables; speaker map YAML; autocorrect YAML; path and
validation rules; links to examples.
- Source of truth: `internal/config/config.go`, `internal/speaker/map.go`,
`internal/autocorrect/autocorrect.go`, `internal/config/config_test.go`,
`internal/speaker/map_test.go`, and `internal/autocorrect/autocorrect_test.go`.
- Acceptance criteria: all config fields and `SERIATIM_*` env vars match code;
unsupported config files or unimplemented formats are not described.
### `docs/operations.md`
- Audience: administrators and operators.
- Purpose: operational behavior for running commands safely.
- Canonical scope: file workflow, filesystem layout expectations, output and
report files, retry behavior, cleanup, validation failures, and operational
caveats.
- Recommended outline: normal workflow; input/output/report files; no durable
state; failure and retry behavior; reports and diagnostics; cleanup; privacy
considerations for transcript artifacts.
- Source of truth: `cmd/seriatim/main.go`, `internal/cli`, `internal/config`,
`internal/report`, `internal/builtin/output.go`, `internal/normalize`, and
trim/merge/normalize CLI tests.
- Acceptance criteria: clearly states there is no daemon, database, resume
state, remote storage, or background job state; does not invent recovery
workflows.
### `docs/policy/development.md`
- Audience: developers and coding agents.
- Purpose: contributor workflow and change guidance.
- Canonical scope: repository layout, build/test commands, coding conventions,
dependency policy, adding flags/config fields/modules/docs/examples.
- Recommended outline: repo layout; local checks; coding conventions; adding
CLI flags; adding config/env vars; adding modules/stages; schema changes;
examples and documentation updates.
- Source of truth: `docs/policy/documentation.md`,
`docs/policy/architecture.md`, `go.mod`, package layout, and test layout.
- Acceptance criteria: includes `go test ./...`; states there is no current
Makefile, taskfile, linter config, or automated doc checker; aligns with the
architecture policy.
### `docs/internal/pipeline.md`
- Audience: developers and coding agents.
- Purpose: implemented merge pipeline internals.
- Canonical scope: registry, stage interfaces, preprocessing state transitions,
module order, report event accumulation, final output/report writing.
- Recommended outline: purpose; inputs and outputs; stage contracts; registry
resolution; execution order; config fields used; adapters; failure behavior;
tests; invariants.
- Source of truth: `internal/pipeline`, `internal/builtin`, `internal/model`,
`internal/report`, `internal/pipeline/runner_test.go`,
`internal/builtin/*_test.go`, and `internal/cli/merge_test.go`.
- Acceptance criteria: describes only implemented sequential execution; does
not document concurrency, plugins, or future formats.
### `docs/internal/artifacts.md`
- Audience: developers and coding agents.
- Purpose: public artifact conversion and validation internals.
- Canonical scope: schema structs, embedded JSON Schemas, conversion from merged
model, trim/normalize artifact handling, and output validation.
- Recommended outline: artifact contracts; schema selection; conversion;
validation; trim projection; normalize canonicalization; tests; invariants.
- Source of truth: `schema`, `internal/artifact`, `internal/trim`,
`internal/normalize`, and related tests.
- Acceptance criteria: links to `schema/*.schema.json`; does not duplicate full
schemas or describe unavailable output formats.
### `docs/internal/modules.md`
- Audience: developers and coding agents.
- Purpose: implemented built-in module behavior and boundaries.
- Canonical scope: `json-files`, preprocessing modules, chronological merge,
postprocessing modules, and JSON output writer.
- Recommended outline: module list; inputs/outputs; config fields used; allowed
side effects; ordering constraints; failure behavior; tests; invariants.
- Source of truth: `internal/builtin`, `internal/overlap`, `internal/coalesce`,
`internal/danglers`, `internal/backchannel`, `internal/filler`,
`internal/autocorrect`, and package tests.
- Acceptance criteria: avoids full CLI/config duplication; identifies
order-sensitive transforms that must run before `assign-ids`.
### `docs/troubleshooting.md`
- Audience: users, administrators, and operators.
- Purpose: common failure symptoms and safe fixes.
- Canonical scope: implemented validation and runtime failures observed in
error paths and tests.
- Recommended outline: invalid JSON/input shape; missing required flags; invalid
output parent directory; invalid speaker/autocorrect YAML; unknown module;
invalid output schema; invalid trim selector; schema validation failure;
report write failure.
- Source of truth: `internal/config`, `internal/cli/*_test.go`,
`internal/trim/*_test.go`, `internal/normalize/*_test.go`,
`internal/speaker/*_test.go`, and `internal/autocorrect/*_test.go`.
- Acceptance criteria: each entry has symptom, likely cause, inspection step,
safe fix, and link; no speculative failure modes.
### `docs/integrations/whisperx-json.md`
- Audience: developers and coding agents.
- Purpose: external input JSON contract used by `merge`.
- Canonical scope: the supported WhisperX-like subset only.
- Recommended outline: top-level shape; required segment fields; optional word
timing fields; validation/failure behavior; how word timing affects overlap
resolution; links to CLI and examples.
- Source of truth: `internal/builtin/input.go`, merge CLI tests, and README
input-format material.
- Acceptance criteria: does not attempt to document full WhisperX behavior or
unsupported input formats.
### `docs/integrations/output-schemas.md`
- Audience: developers, coding agents, and artifact consumers.
- Purpose: orientation to public JSON output contracts.
- Canonical scope: minimal/intermediate/full schema roles and links to schema
files.
- Recommended outline: schema selection; minimal; intermediate; full; semantic
invariants; validation APIs; links to `schema/*.schema.json`.
- Source of truth: `schema/output.go`, `schema/*.schema.json`,
`schema/output_test.go`, and `internal/artifact`.
- Acceptance criteria: links to machine-readable schemas instead of copying
them in full.
### `examples/`
- Audience: users, administrators, operators, developers, and coding agents.
- Purpose: maintained copyable examples.
- Canonical scope: small synthetic inputs and config files for implemented
commands only.
- Source of truth: examples created during the documentation migration and
validated through actual command invocations.
- Acceptance criteria: examples are valid, free of secrets/private transcript
data, and linked from README, CLI, config, and operations docs.
## File-by-File Rewrite Guidance
### README
Cover what seriatim is, the shortest useful `merge` command, a brief command
summary, and links to canonical docs. Avoid full flag tables, config/env
reference, module internals, schema examples, troubleshooting details, future
formats, or release-history narrative. Inspect `internal/cli`, `internal/config`,
and CLI tests before updating commands.
### CLI Reference
Document actual `merge`, `trim`, and `normalize` flags from `internal/cli`.
Include required flags, defaults, mutually exclusive selector rules, schema
selection, report flags, and common workflows. Link to `docs/config.md` for
environment variables and YAML formats. Avoid internal package explanations.
Inspect `internal/cli/*_test.go` for edge cases and examples.
### Config Reference
Document all implemented config surfaces: flags that become config values,
`SERIATIM_OUTPUT_SCHEMA`, `SERIATIM_OVERLAP_WORD_RUN_GAP`,
`SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW`,
`SERIATIM_BACKCHANNEL_MAX_DURATION`, `SERIATIM_FILLER_MAX_DURATION`, module
lists, output schemas, `speakers.yml`, and `autocorrect.yml`. Avoid command
tutorials and unimplemented config files. Inspect `internal/config`,
`internal/speaker`, `internal/autocorrect`, and tests.
### Operations
Document filesystem-only command execution, output/report artifacts, validation
failures, retry behavior, and cleanup. Explicitly say there is no daemon,
database, remote storage, resume state, or background job state. Avoid
unimplemented recovery procedures.
### Development Policy
Document repository layout, `go test ./...`, package conventions,
standard-library-first dependency guidance, how to add flags/config/modules,
and documentation update expectations. State that no Makefile, taskfile,
linter config, or automated documentation checker currently exists.
### Internal Docs
Keep internal docs behavior-level and concise. Describe implemented inputs,
outputs, boundaries, config fields used, adapters, failure behavior, tests, and
invariants. Avoid future plugins, future input/output formats, concurrency, or
duplicating CLI/config reference material.
### Root `architecture.md`
Do not carry forward future input methods, future formats, future output
formats, LLM text, dynamic plugin speculation, or interface sketches that
diverge from code. Salvage only current-behavior details that are not already
covered in `docs/policy/architecture.md` and move any legitimate future ideas
under `docs/roadmap/`.
## Examples Plan
Create small synthetic examples under `examples/` rather than relying on the
current large `samples/raw` data.
- `examples/minimal-merge/`
- Purpose: shortest complete merge workflow with two small raw JSON files and
optional `speakers.yml`.
- Expected validity check: run `go run ./cmd/seriatim merge` with the example
files and validate JSON output is produced.
- Docs to link: README, `docs/cli.md`, `docs/config.md`,
`docs/operations.md`.
- `examples/normalize/`
- Purpose: normalize object-with-`segments` and bare segment array inputs.
- Expected validity check: run `go run ./cmd/seriatim normalize` for both
shapes.
- Docs to link: `docs/cli.md`, `docs/operations.md`, and any Audita/bare
array integration note if created.
- `examples/trim/`
- Purpose: trim a small existing seriatim artifact by `--keep` and/or
`--remove`.
- Expected validity check: run `go run ./cmd/seriatim trim` and validate
sequential retained IDs.
- Docs to link: `docs/cli.md`, `docs/operations.md`.
- `examples/speakers.yml` and `examples/autocorrect.yml`
- Purpose: copyable YAML rule examples if linked from `docs/config.md`.
- Expected validity check: load through merge command or package tests.
- Docs to link: `docs/config.md`, `docs/cli.md`.
Do not invent examples for unimplemented input methods, output formats,
services, or plugin systems. Do not reuse `samples/raw` as public examples
without privacy and size review.
## Internal Documentation Plan
### Pipeline
- Path: `docs/internal/pipeline.md`
- Purpose: document implemented merge pipeline orchestration.
- Inputs and outputs: `config.Config`, raw transcripts, canonical transcripts,
merged transcript, selected public artifact, optional report.
- Boundaries: registry and runner orchestration; no CLI flag parsing; no schema
details beyond output selection.
- Config fields used: input reader, module lists, output modules, output schema,
input/output/report files, timing thresholds passed through modules.
- Adapters used: input reader, output writer, report writer.
- Failure behavior: unknown modules, invalid preprocessing state, stage errors,
output/report write failures.
- Tests to inspect: `internal/pipeline/runner_test.go`,
`internal/builtin/*_test.go`, `internal/cli/merge_test.go`.
- Architectural invariants: deterministic sequential stage order, explicit
raw-to-canonical preprocessing state, output validation before acceptance.
### Artifacts and Schemas
- Path: `docs/internal/artifacts.md`
- Purpose: document public artifact conversion and validation internals.
- Inputs and outputs: merged model, schema structs, serialized JSON artifacts,
parsed trim/normalize artifacts.
- Boundaries: conversion and validation only; CLI docs own user-facing flags.
- Config fields used: output schema, output modules, input files for metadata.
- Adapters used: embedded JSON Schema files and JSON encoders/decoders.
- Failure behavior: schema validation errors, unsupported artifact/schema
conversion, invalid IDs/timing.
- Tests to inspect: `schema/output_test.go`,
`internal/artifact/transcript_test.go`, `internal/trim/*_test.go`,
`internal/normalize/*_test.go`.
- Architectural invariants: sequential IDs, selected schema validation, no
internal-only fields in public schemas.
### Built-In Modules
- Path: `docs/internal/modules.md`
- Purpose: document implemented module responsibilities and ordering
constraints.
- Inputs and outputs: raw transcripts, preprocess state, merged transcript,
report events, selected JSON output.
- Boundaries: module behavior only; no full CLI/config reference.
- Config fields used: speaker file, autocorrect file, coalesce gap, overlap word
gap, word run reorder window, backchannel/filler max durations.
- Adapters used: JSON input/output, speaker YAML, autocorrect YAML, report
events.
- Failure behavior: input validation errors, invalid YAML, unknown module names,
invalid output schema before write.
- Tests to inspect: `internal/builtin`, `internal/overlap`,
`internal/coalesce`, `internal/danglers`, `internal/backchannel`,
`internal/filler`, `internal/autocorrect`, and CLI merge tests.
- Architectural invariants: order-sensitive transforms run before `assign-ids`;
modules stay narrow and explicitly configured.
### Trim
- Path: include in `docs/internal/artifacts.md` or create
`docs/internal/trim.md` if artifacts doc grows too large.
- Purpose: document artifact-level segment projection.
- Inputs and outputs: existing seriatim artifact, selector, selected output
schema, optional report.
- Boundaries: no merge postprocessors; no raw WhisperX input.
- Config fields used: input/output/report files, keep/remove selector,
optional output schema, allow-empty.
- Adapters used: file I/O in CLI, artifact parsing/validation, report writer.
- Failure behavior: malformed selector, invalid artifact, missing selected IDs,
non-sequential input IDs, empty output unless allowed, unsupported schema
up-conversion.
- Tests to inspect: `internal/trim/*_test.go`, `internal/cli/trim_test.go`.
- Architectural invariants: preserve transcript order, renumber retained IDs,
recompute full-schema overlap groups, never run merge modules.
### Normalize
- Path: include in `docs/internal/artifacts.md` or create
`docs/internal/normalize.md` if artifacts doc grows too large.
- Purpose: document artifact-level transcript canonicalization.
- Inputs and outputs: transcript-like JSON object or bare array, selected
seriatim output schema, optional report.
- Boundaries: no merge preprocessing or postprocessing modules.
- Config fields used: input/output/report files, output schema, output modules.
- Adapters used: file I/O, JSON parsing, schema validation, report writer.
- Failure behavior: invalid JSON, unsupported top-level shape, invalid timing
after repair, unsupported output module/schema, report write failure.
- Tests to inspect: `internal/normalize/*_test.go`,
`internal/cli/normalize_test.go`.
- Architectural invariants: deterministic repair/sort/ID assignment, no
transcript text in normalize report events, no merge modules.
## Integration Documentation Plan
- `docs/integrations/whisperx-json.md`
- External system or contract: WhisperX-like JSON transcript subset.
- Current usage: `merge` reads a top-level `segments` array with required
segment timing/text and optional word timing.
- Version or compatibility notes: no explicit WhisperX version is encoded in
the repository; document only the accepted subset.
- Document: supported fields, validation, word timing behavior, errors.
- Do not document: full WhisperX schema, audio diarization, non-JSON formats.
- `docs/integrations/output-schemas.md`
- External system or contract: seriatim public JSON output contracts.
- Current usage: `merge`, `trim`, and `normalize` emit
`seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`.
- Version or compatibility notes: schemas are embedded from `schema/`; release
version metadata is injected through build info.
- Document: schema roles, semantic invariants, validation APIs, links to
schema files.
- Do not document: unimplemented output formats or full schema copies.
- YAML rule files
- Prefer documenting speaker and autocorrect YAML contracts in
`docs/config.md`. Create `docs/integrations/yaml-rule-files.md` only if the
config reference becomes too large.
- Audita-style bare arrays
- Cover under `docs/cli.md` normalize behavior unless maintainers need a
separate integration note. Do not generalize beyond implemented bare segment
arrays.
- No external CLI/API/service docs are needed now. The repository implements no
external CLI, network API, daemon, remote storage, or service integration.
## Recommended Implementation Sequence
### Stage 1: Write Documentation Roadmap
- Goal: review and finalize this roadmap as the implementation plan for the
documentation migration.
- Files: `docs/roadmap/documentation.md` only.
- Repository areas inspected: documentation policy, architecture policy,
`README.md`, root `architecture.md`, and CLI/config/pipeline/schema/report
code and tests.
- Completion status: complete (2026-05-24).
- Completion evidence:
- `go test ./...` passed.
- `git status --short` confirmed no unrelated working-tree changes before
roadmap-only edits.
- Acceptance criteria: roadmap is present, action-oriented, and constrained to
implemented behavior outside `docs/roadmap/`.
- Suggested validation commands: `go test ./...`; `git status --short`.
- Prompt size: one implementation prompt.
### Stage 2: User-Facing Canonical Docs and Slim README
- Goal: move user reference material out of README into canonical docs.
- Files: update `README.md`; create `docs/cli.md` and `docs/config.md`.
- Repository areas to inspect: `internal/cli`, `internal/config`,
`internal/speaker`, `internal/autocorrect`, CLI/config tests.
- Acceptance criteria: README is concise; CLI/config docs match flags, defaults,
env vars, YAML formats, and validation; no roadmap-only content appears.
- Suggested validation commands: `go test ./...`;
`go run ./cmd/seriatim --help`;
`go run ./cmd/seriatim merge --help`;
`go run ./cmd/seriatim trim --help`;
`go run ./cmd/seriatim normalize --help`;
stale-term grep from the validation plan.
- Prompt size: one prompt if concise; split if README rewrite or config
reference grows too large.
### Stage 3: Operations and Troubleshooting
- Goal: document runtime operation, reports, failure behavior, and common fixes.
- Files: create `docs/operations.md` and `docs/troubleshooting.md`.
- Repository areas to inspect: `cmd/seriatim/main.go`, `internal/cli`,
`internal/config`, `internal/report`, output writer, normalize/trim/merge
tests.
- Acceptance criteria: docs describe filesystem-only operation and current
failure modes; no daemon, resume, remote storage, or recovery behavior is
invented.
- Suggested validation commands: `go test ./...`; manual link review.
- Prompt size: one prompt.
### Stage 4: Developer and Internal Docs
- Goal: create developer workflow and implemented internal component docs.
- Files: create `docs/policy/development.md`,
`docs/internal/pipeline.md`, `docs/internal/artifacts.md`, and
`docs/internal/modules.md`.
- Repository areas to inspect: architecture policy, pipeline, modules, schema,
artifact conversion, trim/normalize packages, tests.
- Acceptance criteria: docs preserve boundaries, avoid CLI/config duplication,
and identify tests/invariants for future changes.
- Suggested validation commands: `go test ./...`; grep for unimplemented
future-format/plugin/concurrency claims outside roadmap.
- Prompt size: split into development policy and internal docs if needed.
### Stage 5: Integrations and Examples
- Goal: add concise integration notes and maintained synthetic examples.
- Files: create `docs/integrations/whisperx-json.md`,
`docs/integrations/output-schemas.md`, and `examples/*`; decide whether
`samples/` should remain separate.
- Repository areas to inspect: `internal/builtin/input.go`, `schema`,
`internal/artifact`, CLI tests, existing `samples/`.
- Acceptance criteria: examples are small, synthetic, valid, and linked from
relevant docs; integration docs document only implemented contracts.
- Suggested validation commands: `go test ./...`; run documented example
`go run` commands; validate example YAML through command paths.
- Prompt size: split if examples need tests or sample cleanup decisions.
### Stage 6: Stale Documentation Cleanup
- Goal: remove wrong-home and stale documentation after canonical replacements
exist.
- Files: delete or relocate root `architecture.md`; remove stale material from
README; update links across docs.
- Repository areas to inspect: all docs, README, roadmap, root files.
- Acceptance criteria: no links to deleted root `architecture.md`; no
unimplemented behavior outside `docs/roadmap/`; canonical homes are respected.
- Suggested validation commands: `go test ./...`; stale-term grep; manual link
check; `git status --short`.
- Prompt size: one prompt.
## Validation Plan
Use these checks during or after documentation migration:
- Run `go test ./...`.
- Run `go run ./cmd/seriatim --help`.
- Run `go run ./cmd/seriatim merge --help`.
- Run `go run ./cmd/seriatim trim --help`.
- Run `go run ./cmd/seriatim normalize --help`.
- Once examples exist, run each documented example command and verify output is
produced in a temporary path.
- Load example YAML through the merge command or package tests.
- Validate example JSON through existing CLI/schema paths where practical.
- Grep outside `docs/roadmap/` for stale or roadmap-only terms:
`Future input`, `Future output`, `LLM`, `plugin`, `SRT`, `VTT`, `.tar.gz`,
`URI`, `old format`, `not implemented yet`, and
`runtime default may change`.
- Manually check links unless a link checker is added. No automated
documentation checker currently exists.
- Verify docs and examples contain no secrets, private transcript data, API
keys, tokens, passwords, or private infrastructure details.
## Open Questions
- Should `samples/` be removed, kept as non-doc sample data, or replaced by
small synthetic `examples/`? Recommendation: create small synthetic examples
first, then audit `samples/` for privacy, size, and ongoing maintenance before
deleting or linking it.
- Should Audita-style bare-array normalization have a separate integration doc?
Recommendation: cover it in `docs/cli.md` normalize behavior unless a
stronger external-contract requirement emerges.

446
docs/roadmap/render.md Normal file
View File

@@ -0,0 +1,446 @@
# Render Command Roadmap
## Purpose and scope
This roadmap defines the future implementation plan for a top-level
`seriatim render` command. The first supported render format will be Markdown.
`render` should consume an existing normalized seriatim JSON artifact and emit a
human-facing presentation artifact. JSON remains the canonical machine-readable
seriatim artifact. Markdown output is disposable and reproducible from JSON.
This is a roadmap document. Do not update current-behavior docs until `render`
is implemented. The active architecture policy for this repository is
`docs/policy/architecture.md`; `docs/architecture.md` does not exist in this
checkout.
## Non-goals
The first implementation must not:
- accept raw WhisperX JSON input;
- run merge, trim, normalize, overlap resolution, coalescing, autocorrect, or
other merge-time transformations;
- change existing JSON artifact schemas;
- implement custom templates;
- implement Markdown-to-JSON round-tripping;
- implement paragraph or speaker-turn coalescing;
- implement SRT, VTT, TXT, HTML, or other non-Markdown renderers;
- add render report output;
- expose internal category labels, overlap metadata, or debug provenance by
default.
Paragraphing, speaker-turn grouping, additional render formats, custom
templates, and render reports may be considered later after the Markdown
renderer is stable.
## User-facing UX
Initial command:
seriatim render --input-file transcript.json --output-file transcript.md --format markdown
Required flags:
| Flag | Description |
| --- | --- |
| `--input-file` | Existing normalized seriatim JSON artifact. |
| `--output-file` | Rendered output path. |
| `--format` | Public output format name. Initially only `markdown`. |
Initial optional flags:
| Flag | Default | Description |
| --- | --- | --- |
| `--title` | `Transcript` | Markdown document title. |
| `--include-timestamps` | `true` | Include segment start/end timestamps. |
| `--include-segment-ids` | `false` | Include segment IDs for reference. |
| `--include-metadata` | `false` | Include artifact metadata block. |
Use public CLI terminology `format`. Use internal implementation terminology
`renderer`.
## Input and output contracts
Input:
- Must be an existing seriatim JSON output artifact.
- Must validate as one of the current public schemas:
`seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`.
- Must not be interpreted as raw merge input or WhisperX JSON.
- Must not be transformed semantically before rendering.
Output:
- Initial format is Markdown.
- The output file is presentation-oriented, not canonical data.
- Output should be overwritten consistently with existing file-output behavior
unless implementation finds a conflicting repository policy.
- Markdown output should be deterministic for identical input and render config.
Render model:
- Normalize all supported input schemas into a small internal render model.
- Segment fields should include ID, start, end, speaker, text, and categories.
- Full-schema-only fields such as source/provenance and overlap groups should
not be required by renderers.
## Markdown rendering policy
Default Markdown output should optimize for human reading.
Rules:
- Start with `# {title}`.
- Use stable `HH:MM:SS` timestamps with seconds precision.
- Use an en dash between start and end timestamps.
- Omit segment IDs by default.
- Omit metadata by default.
- Render speaker names in bold.
- Render segment text as normal prose unless category hints apply.
- Do not expose internal category names by default.
- Do not expose unknown categories by default.
- Do not fail on unknown categories.
- Omit overlap/debug metadata by default.
Category hints:
- `background` text should be italicized.
- `backchannel` text may be italicized.
- `filler` text may be italicized.
- Unknown categories should be ignored.
Example default shape:
# 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.*
The roadmap uses an ASCII hyphen in the example for source compatibility.
Implementation should use an en dash in the rendered Markdown output.
## Internal architecture
Add a new `internal/render` package for render-specific behavior.
Responsibilities:
- read or accept parsed seriatim artifacts through a neutral artifact helper;
- normalize full/intermediate/minimal artifacts into a render model;
- expose a renderer registry keyed by public format names;
- provide the initial `markdown` renderer;
- keep renderer code free of CLI, filesystem path, environment variable, and
report concerns.
Artifact parsing:
- Do not import `internal/trim` only to parse render input.
- Move or generalize artifact parsing into a neutral artifact helper that both
`trim` and `render` can use.
- Keep schema validation through `schema`.
Command boundary:
- CLI code should parse flags, construct validated config, and delegate.
- Config validation should live in `internal/config`.
- Filesystem read/write orchestration should live in `internal/render` or a
narrow artifact/render run layer, following the `trim` and `normalize`
artifact-level command pattern.
- Use existing JSON/text file writing conventions where practical.
Report support:
- Do not add `--report-file` in the initial implementation.
- Rendering is presentation output rather than semantic transformation, so
reports are lower priority.
## Validation and error handling
Validation should fail fast with contextual errors:
- missing `--input-file`, `--output-file`, or `--format`;
- input path does not exist or is a directory;
- output parent directory does not exist;
- malformed JSON;
- JSON that does not validate as a seriatim minimal/intermediate/full artifact;
- unsupported `--format`;
- output file write failure.
Important behavior:
- Raw WhisperX-style JSON must fail because it is not a seriatim output
artifact.
- Unknown segment categories must not fail rendering.
- Empty transcripts should render deterministically.
- Negative or inverted timing should fail through existing schema validation.
- Commands should return errors to the root command; internal packages should
not print.
## Testing strategy
Add tests at the package level that owns each behavior:
- artifact parsing/normalization tests for all three public schemas;
- rejection tests for malformed JSON and raw WhisperX-like input;
- registry tests for resolving `markdown` and rejecting unknown formats;
- Markdown renderer tests for title, timestamps, speaker bolding, italicized
category hints, unknown category handling, metadata flags, and segment ID
flags;
- config tests for required flags, path validation, and format validation;
- CLI tests for command registration, end-to-end Markdown output, and error
behavior;
- full repository test after integration.
Required validation commands after implementation:
go test ./internal/render ./internal/config ./internal/cli ./schema
go test ./...
go run ./cmd/seriatim --help
go run ./cmd/seriatim render --help
## Documentation updates required
Do not update current-behavior docs until the command is implemented.
After implementation, update:
- `README.md`: add `render` to the concise command summary if useful.
- `docs/cli.md`: add render command reference and workflow.
- `docs/config.md`: document render flags only if they belong in config
reference.
- `docs/operations.md`: add render to the file workflow.
- `docs/internal/artifacts.md`: describe artifact parsing/render model
internals.
- `examples/`: add a small synthetic Markdown render example if practical.
## Open decisions
No blocking decisions remain for the initial roadmap.
Defaults chosen for the first implementation:
- initial format: `markdown`;
- initial title: `Transcript`;
- timestamps included by default;
- segment IDs omitted by default;
- metadata omitted by default;
- no initial render reports;
- no initial templates;
- no initial paragraph or speaker-turn coalescing.
## Staged implementation plan
### Stage 1: artifact reader and render model
Objective:
- Add neutral artifact parsing and normalization support for render input.
Likely packages:
- `internal/artifact`
- `internal/render`
- `schema`
- `internal/trim`, only if shared parsing moves out of trim
Implementation details:
- Move or generalize current trim artifact parsing into a neutral artifact
helper that accepts minimal, intermediate, and full seriatim artifacts.
- Keep validation through `schema`.
- Add a render model with normalized segment fields: ID, start, end, speaker,
text, categories.
- Preserve source artifact order and existing segment IDs.
- Do not add Markdown rendering in this stage.
Tests:
- Parse and normalize full, intermediate, and minimal artifacts.
- Reject malformed JSON.
- Reject raw WhisperX-like JSON.
- Preserve categories where present and use empty categories where absent.
Acceptance criteria:
- Render model can be produced from all current seriatim output schemas.
- Raw input formats are not accepted.
- Trim remains behavior-compatible if artifact parsing is shared.
### Stage 2: renderer registry and Markdown renderer
Objective:
- Add renderer resolution and initial deterministic Markdown rendering.
Likely packages:
- `internal/render`
Implementation details:
- Add renderer interface and registry keyed by public format name.
- Register `markdown`.
- Add Markdown options for title, timestamps, segment IDs, and metadata.
- Format timestamps as `HH:MM:SS` with seconds precision.
- Italicize text for `background`, `backchannel`, and `filler`.
- Ignore unknown categories.
- Keep renderer independent of CLI/config/filesystem.
Tests:
- Resolve `markdown`.
- Reject unknown renderer names.
- Render default transcript shape.
- Render without timestamps.
- Render with segment IDs.
- Render metadata only when requested.
- Render category hint italics.
- Ignore unknown categories without error.
Acceptance criteria:
- Markdown output is deterministic and human-readable.
- Renderer package has no Cobra, config, environment, or filesystem-path
dependency.
### Stage 3: render command configuration and CLI wiring
Objective:
- Add `seriatim render` as a top-level command.
Likely packages:
- `internal/config`
- `internal/cli`
- `internal/render`
Implementation details:
- Add `RenderOptions` and `RenderConfig`.
- Validate required input, output, and format flags.
- Reuse existing single-input and output-path validation helpers.
- Add `newRenderCommand`.
- Register render in root command.
- Add flags: `--input-file`, `--output-file`, `--format`, `--title`,
`--include-timestamps`, `--include-segment-ids`, `--include-metadata`.
- Add `render.Run(ctx, cfg)` for artifact-level orchestration.
Tests:
- Config required flag validation.
- Config format validation.
- CLI command is recognized.
- CLI end-to-end Markdown render from a small artifact.
- Root help includes `render`.
Acceptance criteria:
- `seriatim render --input-file transcript.json --output-file transcript.md --format markdown` works.
- CLI code remains thin and delegates to config/render packages.
### Stage 4: validation, errors, and schema coverage
Objective:
- Harden user-facing failure behavior and all schema variants.
Likely packages:
- `internal/render`
- `internal/config`
- `internal/cli`
Implementation details:
- Wrap input read, artifact parse, unsupported format, and output write errors
with useful context.
- Verify raw WhisperX-style input fails with an artifact validation error.
- Verify empty transcripts render deterministically.
- Verify output parent directory validation matches other commands.
Tests:
- Unsupported `--format`.
- Missing or directory input file.
- Malformed JSON.
- Raw WhisperX-like JSON.
- Output parent missing.
- Minimal, intermediate, and full schema CLI coverage.
Acceptance criteria:
- Error behavior matches repository conventions.
- All supported JSON artifact schemas are covered by tests.
### Stage 5: documentation updates after implementation
Objective:
- Update current-behavior docs only after render exists.
Likely files:
- `README.md`
- `docs/cli.md`
- `docs/config.md`
- `docs/operations.md`
- `docs/internal/artifacts.md`
- `examples/README.md`
Implementation details:
- Add concise user-facing render docs.
- Keep full flag reference in `docs/cli.md`.
- Keep config docs limited to actual render flags and path validation.
- Add a small synthetic render example if practical.
- Do not describe future render formats as implemented.
Tests:
- Run example command if an example is added.
- Run full Go tests after doc/example changes.
Acceptance criteria:
- Non-roadmap docs describe only implemented render behavior.
- README remains concise.
### Stage 6: final integration hardening
Objective:
- Verify the feature is complete, deterministic, and aligned with architecture.
Likely packages:
- `cmd/seriatim`
- `internal/cli`
- `internal/config`
- `internal/render`
- `internal/artifact`
- `schema`
Implementation details:
- Run full tests and CLI help checks.
- Review package imports for boundary drift.
- Confirm no merge modules are invoked by render.
- Confirm no report flag slipped into v1.
- Confirm future formats can register without renaming the command.
Tests:
- `go test ./...`
- `go run ./cmd/seriatim --help`
- `go run ./cmd/seriatim render --help`
Acceptance criteria:
- All tests pass.
- Render remains artifact-level and downstream-only.
- Markdown output is reproducible from JSON input and render flags.

View File

@@ -4,30 +4,31 @@ Each entry includes symptom, likely cause, inspection step, and safe fix.
## Missing required flags
- Symptom: command fails with messages like `--input-file is required`, `--output-file is required`, or `exactly one of --keep or --remove is required`.
- Likely cause: required command flags were omitted.
- Inspection: run command help for the failing command:
- Symptom: command fails with messages like `--input-file is required`, `--output-file is required`, `--format is required`, or `exactly one of --keep or --remove is required`.
- Likely cause: one or more required flags were omitted.
- Inspection: run help for the failing command:
- `go run ./cmd/seriatim merge --help`
- `go run ./cmd/seriatim trim --help`
- `go run ./cmd/seriatim normalize --help`
- `go run ./cmd/seriatim render --help`
- Safe fix: provide all required flags; for `trim`, provide exactly one selector mode (`--keep` or `--remove`).
## Invalid output or report path
- Symptom: errors like `--output-file parent directory ...` or `--report-file parent directory ...`.
- Likely cause: parent directory does not exist, is not a directory, or path points to an unusable target.
- Inspection: verify paths:
- Likely cause: parent directory does not exist, is not a directory, or the target path is unusable.
- Inspection: verify parent path and permissions:
- `dirname <path>`
- `ls -ld <parent-dir>`
- Safe fix: create/fix the parent directory and rerun; avoid using directory paths directly as output/report file targets.
- Safe fix: create or fix the parent directory and rerun. Use a file path (not a directory path) for output/report targets.
## Invalid merge input JSON
- Symptom: merge fails with messages like `parse input file`, `must contain top-level segments array`, `segment 0 missing numeric start`, or `segment 0 words must be an array`.
- Likely cause: malformed JSON or unsupported/missing fields in a merge input file.
- Inspection: validate input JSON and required fields (`start`, `end`, `text`):
- Inspection: validate JSON and required segment fields (`start`, `end`, `text`):
- `jq . <input-file>`
- Safe fix: correct the JSON structure and segment/word field types, then rerun `merge`.
- Safe fix: correct JSON structure and segment/word field types, then rerun `merge`.
## Invalid normalize input shape
@@ -38,13 +39,22 @@ Each entry includes symptom, likely cause, inspection step, and safe fix.
- `jq 'keys' <input-file>` (for object input)
- Safe fix: reshape input into one supported form and rerun `normalize`.
## Invalid render input artifact
- Symptom: render fails with messages like `input JSON is malformed` or `input JSON is not a valid seriatim output artifact`.
- Likely cause: input is malformed JSON or not one of the supported seriatim output schemas.
- Inspection:
- `jq . <input-file>`
- compare input shape against `schema/minimal-output.schema.json`, `schema/intermediate-output.schema.json`, and `schema/full-output.schema.json`
- Safe fix: render only a valid existing seriatim artifact (`seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`).
## Invalid speaker map or autocorrect YAML
- Symptom: merge fails with errors such as `must contain at least one match rule`, `must include speaker`, `must include target`, or duplicate match/speaker validation failures.
- Likely cause: YAML rule file structure/content does not match expected schema.
- Likely cause: YAML rule file structure/content does not match expected contract.
- Inspection: check YAML validity and required top-level keys:
- `speakers.yml` requires top-level `match` rules.
- `autocorrect.yml` requires top-level `autocorrect` rules.
- `speakers.yml` requires top-level `match` rules
- `autocorrect.yml` requires top-level `autocorrect` rules
- Safe fix: correct YAML structure and rule content, then rerun `merge`.
## Unknown module names
@@ -54,14 +64,18 @@ Each entry includes symptom, likely cause, inspection step, and safe fix.
- Inspection: compare provided module names against defaults in CLI help and config docs.
- Safe fix: use implemented module names only or remove unsupported modules from comma-separated lists.
## Invalid output schema value
## Invalid format or schema values
- Symptom: errors like `--output-schema must be one of ...`.
- Likely cause: unsupported schema value from flag or `SERIATIM_OUTPUT_SCHEMA`.
- Inspection: check effective value:
- Symptom:
- render: `--format must be "markdown"`
- merge/normalize/trim: `--output-schema must be one of ...`
- Likely cause: unsupported `--format` or `--output-schema` value.
- Inspection:
- command flags
- `echo "$SERIATIM_OUTPUT_SCHEMA"`
- Safe fix: use one of `seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`.
- `echo "$SERIATIM_OUTPUT_SCHEMA"` (for merge/normalize defaults)
- Safe fix:
- render: use `--format markdown`
- output schema: use `seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`
## Invalid trim selector
@@ -73,23 +87,23 @@ Each entry includes symptom, likely cause, inspection step, and safe fix.
- list: `1-10,15,20-25`
- Safe fix: correct selector syntax and rerun `trim`.
## Schema validation failures
## Artifact or schema validation failures
- Symptom: errors such as `validate-output: ...` in merge or `input JSON is not a valid seriatim output artifact` in trim.
- Symptom: errors such as `validate-output: ...`, `input JSON is not a valid seriatim output artifact`, or related schema-validation errors.
- Likely cause:
- merge module order/config produced invalid final artifact (for example, validating before IDs are assigned), or
- trim input is not a valid seriatim artifact.
- merge module order/config produced an invalid output artifact, or
- trim/render input is not a valid seriatim output artifact.
- Inspection:
- for merge: inspect customized module ordering flags.
- for trim: verify input artifact against known seriatim schema files in `schema/`.
- for merge: inspect customized module ordering flags
- for trim/render: validate input against schema files in `schema/`
- Safe fix:
- restore valid merge postprocessing order ending with assigned IDs before validation, or
- provide a valid seriatim artifact as trim input.
- merge: restore a valid postprocessing order ending with assigned IDs before output validation
- trim/render: provide a valid seriatim artifact as input
## Report write failure
- Symptom: errors like `write --report-file ...` or file-create failures when report writing is requested.
- Likely cause: report path is not writable or is an invalid target (for example a directory path).
- Likely cause: report path is not writable or points to an invalid target.
- Inspection:
- `ls -ld <report-parent-dir>`
- verify `--report-file` is a file path, not a directory

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
# Transcript
[00:00:0100:00:04] **Eric Rakestraw:** Hello there.
[00:00:0500:00:08] **Mike Brown:** Welcome back, everyone.
[00:00:0900:00:10] **Eric Rakestraw:** *Yeah.*

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
}

View File

@@ -2,10 +2,9 @@ package builtin
import (
"context"
"encoding/json"
"os"
"gitea.maximumdirect.net/eric/seriatim/internal/config"
"gitea.maximumdirect.net/eric/seriatim/internal/jsonfile"
"gitea.maximumdirect.net/eric/seriatim/internal/report"
)
@@ -20,15 +19,7 @@ func (jsonOutputWriter) Write(ctx context.Context, out any, rpt report.Report, c
return nil, err
}
file, err := os.Create(cfg.OutputFile)
if err != nil {
return nil, err
}
defer file.Close()
enc := json.NewEncoder(file)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
if err := jsonfile.Write(cfg.OutputFile, out); err != nil {
return nil, err
}

31
internal/cli/flags.go Normal file
View File

@@ -0,0 +1,31 @@
package cli
import (
"github.com/spf13/cobra"
"gitea.maximumdirect.net/eric/seriatim/internal/config"
)
func addOutputFileFlag(cmd *cobra.Command, target *string) {
cmd.Flags().StringVar(target, "output-file", "", "output transcript JSON file")
}
func addReportFileFlag(cmd *cobra.Command, target *string) {
cmd.Flags().StringVar(target, "report-file", "", "optional report JSON file")
}
func addOutputModulesFlag(cmd *cobra.Command, target *string) {
cmd.Flags().StringVar(target, "output-modules", config.DefaultOutputModules, "comma-separated output modules")
}
func addMergeOutputSchemaFlag(cmd *cobra.Command, target *string) {
cmd.Flags().StringVar(target, "output-schema", config.DefaultOutputSchema, "output JSON schema: seriatim-minimal, seriatim-intermediate (default), or seriatim-full")
}
func addNormalizeOutputSchemaFlag(cmd *cobra.Command, target *string) {
cmd.Flags().StringVar(target, "output-schema", config.DefaultOutputSchema, "output JSON schema: seriatim-minimal, seriatim-intermediate, or seriatim-full")
}
func addTrimOutputSchemaFlag(cmd *cobra.Command, target *string) {
cmd.Flags().StringVar(target, "output-schema", "", "optional output JSON schema override: seriatim-minimal, seriatim-intermediate, or seriatim-full")
}

View File

@@ -31,13 +31,13 @@ func newMergeCommand() *cobra.Command {
flags := cmd.Flags()
flags.StringArrayVar(&opts.InputFiles, "input-file", nil, "input transcript file; may be repeated")
flags.StringVar(&opts.OutputFile, "output-file", "", "output transcript JSON file")
flags.StringVar(&opts.ReportFile, "report-file", "", "optional report JSON file")
addOutputFileFlag(cmd, &opts.OutputFile)
addReportFileFlag(cmd, &opts.ReportFile)
flags.StringVar(&opts.SpeakersFile, "speakers", "", "speaker map file")
flags.StringVar(&opts.AutocorrectFile, "autocorrect", "", "autocorrect rules file")
flags.StringVar(&opts.InputReader, "input-reader", config.DefaultInputReader, "input reader module")
flags.StringVar(&opts.OutputModules, "output-modules", config.DefaultOutputModules, "comma-separated output modules")
flags.StringVar(&opts.OutputSchema, "output-schema", config.DefaultOutputSchema, "output JSON schema: seriatim-minimal, seriatim-intermediate (default), or seriatim-full")
addOutputModulesFlag(cmd, &opts.OutputModules)
addMergeOutputSchemaFlag(cmd, &opts.OutputSchema)
flags.StringVar(&opts.PreprocessingModules, "preprocessing-modules", config.DefaultPreprocessingModules, "comma-separated preprocessing modules")
flags.StringVar(&opts.PostprocessingModules, "postprocessing-modules", config.DefaultPostprocessingModules, "comma-separated postprocessing modules")
flags.StringVar(&opts.CoalesceGap, "coalesce-gap", config.DefaultCoalesceGapValue, "maximum same-speaker gap in seconds for coalesce")

View File

@@ -30,10 +30,10 @@ func newNormalizeCommand() *cobra.Command {
flags := cmd.Flags()
flags.StringVar(&opts.InputFile, "input-file", "", "input transcript JSON file")
flags.StringVar(&opts.OutputFile, "output-file", "", "output transcript JSON file")
flags.StringVar(&opts.ReportFile, "report-file", "", "optional report JSON file")
flags.StringVar(&opts.OutputSchema, "output-schema", config.DefaultOutputSchema, "output JSON schema: seriatim-minimal, seriatim-intermediate, or seriatim-full")
flags.StringVar(&opts.OutputModules, "output-modules", config.DefaultOutputModules, "comma-separated output modules")
addOutputFileFlag(cmd, &opts.OutputFile)
addReportFileFlag(cmd, &opts.ReportFile)
addNormalizeOutputSchemaFlag(cmd, &opts.OutputSchema)
addOutputModulesFlag(cmd, &opts.OutputModules)
return cmd
}

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
}

274
internal/cli/render_test.go Normal file
View File

@@ -0,0 +1,274 @@
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 TestRenderWorksWithRequiredFlagsOnly(t *testing.T) {
dir := t.TempDir()
input := writeJSONFile(t, dir, "input.json", `{
"metadata": {
"application": "seriatim",
"version": "v-test",
"output_schema": "seriatim-minimal"
},
"segments": [
{"id": 1, "start": 1, "end": 2, "speaker": "Eric", "text": "Hello there."}
]
}`)
output := writeJSONFile(t, dir, "output.md", "")
err := executeRender(
"--input-file", input,
"--output-file", output,
"--format", config.RenderFormatMarkdown,
)
if err != nil {
t.Fatalf("render with required flags failed: %v", err)
}
data := readFile(t, output)
if !strings.Contains(data, "# Transcript") {
t.Fatalf("missing default title:\n%s", data)
}
if !strings.Contains(data, "[00:00:0100:00:02] **Eric:** Hello there.") {
t.Fatalf("missing rendered 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 TestRenderEmptyTranscriptIsDeterministic(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", "")
run := func() string {
err := executeRender(
"--input-file", input,
"--output-file", output,
"--format", config.RenderFormatMarkdown,
)
if err != nil {
t.Fatalf("render failed: %v", err)
}
return readFile(t, output)
}
first := run()
second := run()
if first != second {
t.Fatalf("empty transcript render is not deterministic:\nfirst:\n%s\nsecond:\n%s", first, second)
}
if first != "# Transcript\n" {
t.Fatalf("unexpected empty transcript output:\n%s", first)
}
}
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

@@ -1,41 +1,12 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"sort"
"github.com/spf13/cobra"
"gitea.maximumdirect.net/eric/seriatim/internal/config"
"gitea.maximumdirect.net/eric/seriatim/internal/report"
triminternal "gitea.maximumdirect.net/eric/seriatim/internal/trim"
"gitea.maximumdirect.net/eric/seriatim/internal/trim"
)
type trimAuditReport struct {
Operation string `json:"operation"`
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
InputSchema string `json:"input_schema"`
OutputSchema string `json:"output_schema"`
Mode string `json:"mode"`
Selector string `json:"selector"`
SelectedIDs []int `json:"selected_ids"`
AllowEmpty bool `json:"allow_empty"`
InputSegmentCount int `json:"input_segment_count"`
RetainedSegmentCount int `json:"retained_segment_count"`
RemovedSegmentCount int `json:"removed_segment_count"`
RemovedInputIDs []int `json:"removed_input_ids"`
OldToNewIDMapping []trimIDMapping `json:"old_to_new_id_mapping"`
OverlapGroupsRecomputed bool `json:"overlap_groups_recomputed"`
}
type trimIDMapping struct {
OldID int `json:"old_id"`
NewID int `json:"new_id"`
}
func newTrimCommand() *cobra.Command {
var opts config.TrimOptions
@@ -53,139 +24,18 @@ func newTrimCommand() *cobra.Command {
return err
}
selector, err := triminternal.ParseSelector(cfg.Selector)
if err != nil {
return fmt.Errorf("invalid selector %q: %w", cfg.Selector, err)
}
data, err := os.ReadFile(cfg.InputFile)
if err != nil {
return fmt.Errorf("read --input-file %q: %w", cfg.InputFile, err)
}
artifact, err := triminternal.ParseArtifactJSON(data)
if err != nil {
return fmt.Errorf("--input-file %q: %w", cfg.InputFile, err)
}
inputSegmentCount := artifact.SegmentCount()
inputSchema := artifact.Schema
mode := triminternal.ModeKeep
if cfg.Mode == "remove" {
mode = triminternal.ModeRemove
}
trimmed, err := triminternal.ApplyArtifact(artifact, triminternal.Options{
Mode: mode,
Selector: selector,
AllowEmpty: cfg.AllowEmpty,
})
if err != nil {
return err
}
outputSchema := artifact.Schema
if cfg.OutputSchema != "" {
outputSchema = cfg.OutputSchema
}
outputArtifact, err := triminternal.ConvertArtifact(trimmed.Artifact, outputSchema)
if err != nil {
return err
}
if err := triminternal.ValidateArtifact(outputArtifact); err != nil {
return fmt.Errorf("validate trimmed output: %w", err)
}
if err := writeOutputJSON(cfg.OutputFile, outputArtifact.Value()); err != nil {
return err
}
if cfg.ReportFile != "" {
audit := trimAuditReport{
Operation: "trim",
InputFile: cfg.InputFile,
OutputFile: cfg.OutputFile,
InputSchema: inputSchema,
OutputSchema: outputArtifact.Schema,
Mode: cfg.Mode,
Selector: cfg.Selector,
SelectedIDs: selector.IDs(),
AllowEmpty: cfg.AllowEmpty,
InputSegmentCount: inputSegmentCount,
RetainedSegmentCount: len(trimmed.OldToNewID),
RemovedSegmentCount: len(trimmed.RemovedIDs),
RemovedInputIDs: append([]int(nil), trimmed.RemovedIDs...),
OldToNewIDMapping: orderedIDMapping(trimmed.OldToNewID),
OverlapGroupsRecomputed: trimmed.OverlapGroupsRecomputed,
}
auditJSON, err := json.Marshal(audit)
if err != nil {
return fmt.Errorf("marshal trim audit report: %w", err)
}
rpt := report.Report{
Metadata: report.Metadata{
Application: outputArtifact.Application(),
Version: outputArtifact.Version(),
InputReader: "trim-artifact",
InputFiles: []string{cfg.InputFile},
OutputModules: []string{"json"},
},
Events: []report.Event{
report.Info("trim", "trim", fmt.Sprintf("trimmed %d input segment(s) into %d output segment(s) with mode=%s", inputSegmentCount, outputArtifact.SegmentCount(), cfg.Mode)),
report.Info("trim", "trim-audit", string(auditJSON)),
report.Info("trim", "validate-output", fmt.Sprintf("validated %d output segment(s)", outputArtifact.SegmentCount())),
report.Info("output", "json", "wrote transcript JSON"),
},
}
if err := report.WriteJSON(cfg.ReportFile, rpt); err != nil {
return err
}
}
return nil
return trim.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", "", "output transcript JSON file")
flags.StringVar(&opts.ReportFile, "report-file", "", "optional report JSON file")
addOutputFileFlag(cmd, &opts.OutputFile)
addReportFileFlag(cmd, &opts.ReportFile)
flags.StringVar(&opts.Keep, "keep", "", "segment ID selector to keep (for example: 1-10,15)")
flags.StringVar(&opts.Remove, "remove", "", "segment ID selector to remove (for example: 1-10,15)")
flags.StringVar(&opts.OutputSchema, "output-schema", "", "optional output JSON schema override: seriatim-minimal, seriatim-intermediate, or seriatim-full")
addTrimOutputSchemaFlag(cmd, &opts.OutputSchema)
flags.BoolVar(&opts.AllowEmpty, "allow-empty", false, "allow trimming to an empty transcript")
return cmd
}
func writeOutputJSON(path string, value any) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
enc := json.NewEncoder(file)
enc.SetIndent("", " ")
return enc.Encode(value)
}
func orderedIDMapping(mapping map[int]int) []trimIDMapping {
keys := make([]int, 0, len(mapping))
for oldID := range mapping {
keys = append(keys, oldID)
}
sort.Ints(keys)
pairs := make([]trimIDMapping, 0, len(keys))
for _, oldID := range keys {
pairs = append(pairs, trimIDMapping{
OldID: oldID,
NewID: mapping[oldID],
})
}
return pairs
}

View File

@@ -12,6 +12,29 @@ import (
"gitea.maximumdirect.net/eric/seriatim/schema"
)
type trimAuditReport struct {
Operation string `json:"operation"`
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
InputSchema string `json:"input_schema"`
OutputSchema string `json:"output_schema"`
Mode string `json:"mode"`
Selector string `json:"selector"`
SelectedIDs []int `json:"selected_ids"`
AllowEmpty bool `json:"allow_empty"`
InputSegmentCount int `json:"input_segment_count"`
RetainedSegmentCount int `json:"retained_segment_count"`
RemovedSegmentCount int `json:"removed_segment_count"`
RemovedInputIDs []int `json:"removed_input_ids"`
OldToNewIDMapping []trimIDMapping `json:"old_to_new_id_mapping"`
OverlapGroupsRecomputed bool `json:"overlap_groups_recomputed"`
}
type trimIDMapping struct {
OldID int `json:"old_id"`
NewID int `json:"new_id"`
}
func TestTrimKeepModeEndToEnd(t *testing.T) {
dir := t.TempDir()
input := writeTrimFullFixture(t, dir, "input.json")

View File

@@ -160,13 +160,7 @@ func (r run) coalescedSegment(id int) model.Segment {
}
func segmentRef(segment model.Segment) string {
if segment.SourceSegmentIndex != nil {
return fmt.Sprintf("%s#%d", segment.Source, *segment.SourceSegmentIndex)
}
if segment.SourceRef != "" {
return segment.SourceRef
}
return segment.Source
return model.SegmentReference(segment)
}
func isSkippableInterjection(segment model.Segment) bool {

View File

@@ -8,12 +8,16 @@ import (
"sort"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/seriatim/schema"
)
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
@@ -27,9 +31,9 @@ const (
WordRunReorderWindowEnv = "SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"
BackchannelMaxDurationEnv = "SERIATIM_BACKCHANNEL_MAX_DURATION"
FillerMaxDurationEnv = "SERIATIM_FILLER_MAX_DURATION"
OutputSchemaMinimal = "seriatim-minimal"
OutputSchemaIntermediate = "seriatim-intermediate"
OutputSchemaFull = "seriatim-full"
OutputSchemaMinimal = schema.OutputSchemaMinimal
OutputSchemaIntermediate = schema.OutputSchemaIntermediate
OutputSchemaFull = schema.OutputSchemaFull
)
// MergeOptions captures raw CLI option values before validation.
@@ -67,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
@@ -106,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{
@@ -210,11 +236,8 @@ func NewMergeConfig(opts MergeOptions) (Config, error) {
// NewTrimConfig validates raw trim options and returns normalized config.
func NewTrimConfig(opts TrimOptions) (TrimConfig, error) {
inputFile := filepath.Clean(strings.TrimSpace(opts.InputFile))
if strings.TrimSpace(opts.InputFile) == "" {
return TrimConfig{}, errors.New("--input-file is required")
}
if err := requireFile(inputFile, "--input-file"); err != nil {
inputFile, err := normalizeSingleInputFile(opts.InputFile, "--input-file")
if err != nil {
return TrimConfig{}, err
}
@@ -223,12 +246,9 @@ func NewTrimConfig(opts TrimOptions) (TrimConfig, error) {
return TrimConfig{}, err
}
reportFile := ""
if strings.TrimSpace(opts.ReportFile) != "" {
reportFile, err = normalizeOutputPath(opts.ReportFile, "--report-file")
if err != nil {
return TrimConfig{}, err
}
reportFile, err := normalizeOptionalOutputPath(opts.ReportFile, "--report-file")
if err != nil {
return TrimConfig{}, err
}
keep := strings.TrimSpace(opts.Keep)
@@ -267,11 +287,8 @@ func NewTrimConfig(opts TrimOptions) (TrimConfig, error) {
// NewNormalizeConfig validates raw normalize options and returns normalized config.
func NewNormalizeConfig(opts NormalizeOptions) (NormalizeConfig, error) {
inputFile := filepath.Clean(strings.TrimSpace(opts.InputFile))
if strings.TrimSpace(opts.InputFile) == "" {
return NormalizeConfig{}, errors.New("--input-file is required")
}
if err := requireFile(inputFile, "--input-file"); err != nil {
inputFile, err := normalizeSingleInputFile(opts.InputFile, "--input-file")
if err != nil {
return NormalizeConfig{}, err
}
@@ -280,12 +297,9 @@ func NewNormalizeConfig(opts NormalizeOptions) (NormalizeConfig, error) {
return NormalizeConfig{}, err
}
reportFile := ""
if strings.TrimSpace(opts.ReportFile) != "" {
reportFile, err = normalizeOutputPath(opts.ReportFile, "--report-file")
if err != nil {
return NormalizeConfig{}, err
}
reportFile, err := normalizeOptionalOutputPath(opts.ReportFile, "--report-file")
if err != nil {
return NormalizeConfig{}, err
}
outputSchema, err := resolveOutputSchema(opts.OutputSchema)
@@ -313,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 == "" {
@@ -332,12 +382,12 @@ func parseModuleList(value string) ([]string, error) {
}
func validateOutputSchema(value string) error {
switch value {
case OutputSchemaMinimal, OutputSchemaIntermediate, OutputSchemaFull:
if schema.ValidOutputSchemaName(value) {
return nil
default:
return fmt.Errorf("--output-schema must be one of %q, %q, or %q", OutputSchemaMinimal, OutputSchemaIntermediate, OutputSchemaFull)
}
names := schema.OutputSchemaNames()
return fmt.Errorf("--output-schema must be one of %q, %q, or %q", names[0], names[1], names[2])
}
func resolveOutputSchema(value string) (string, error) {
@@ -381,6 +431,26 @@ func normalizeInputFiles(paths []string) ([]string, error) {
return normalized, nil
}
func normalizeSingleInputFile(path string, flag string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", fmt.Errorf("%s is required", flag)
}
clean := filepath.Clean(path)
if err := requireFile(clean, flag); err != nil {
return "", err
}
return clean, nil
}
func normalizeOptionalOutputPath(path string, flag string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", nil
}
return normalizeOutputPath(path, flag)
}
func normalizeOutputPath(path string, flag string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
@@ -475,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

@@ -538,15 +538,9 @@ func TestCoalesceGapUsesValidOverride(t *testing.T) {
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "merged.json")
cfg, err := NewMergeConfig(MergeOptions{
InputFiles: []string{input},
OutputFile: output,
InputReader: DefaultInputReader,
OutputModules: DefaultOutputModules,
PreprocessingModules: DefaultPreprocessingModules,
PostprocessingModules: DefaultPostprocessingModules,
CoalesceGap: "1.5",
})
opts := validMergeOptions(input, output)
opts.CoalesceGap = "1.5"
cfg, err := NewMergeConfig(opts)
if err != nil {
t.Fatalf("config failed: %v", err)
}
@@ -560,15 +554,9 @@ func TestCoalesceGapAllowsZero(t *testing.T) {
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "merged.json")
cfg, err := NewMergeConfig(MergeOptions{
InputFiles: []string{input},
OutputFile: output,
InputReader: DefaultInputReader,
OutputModules: DefaultOutputModules,
PreprocessingModules: DefaultPreprocessingModules,
PostprocessingModules: DefaultPostprocessingModules,
CoalesceGap: "0",
})
opts := validMergeOptions(input, output)
opts.CoalesceGap = "0"
cfg, err := NewMergeConfig(opts)
if err != nil {
t.Fatalf("config failed: %v", err)
}
@@ -593,15 +581,9 @@ func TestCoalesceGapRejectsInvalidOverride(t *testing.T) {
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "merged.json")
_, err := NewMergeConfig(MergeOptions{
InputFiles: []string{input},
OutputFile: output,
InputReader: DefaultInputReader,
OutputModules: DefaultOutputModules,
PreprocessingModules: DefaultPreprocessingModules,
PostprocessingModules: DefaultPostprocessingModules,
CoalesceGap: test.value,
})
opts := validMergeOptions(input, output)
opts.CoalesceGap = test.value
_, err := NewMergeConfig(opts)
if err == nil {
t.Fatal("expected error")
}
@@ -639,20 +621,16 @@ func TestNewTrimConfigRequiresExactlyOneSelectorFlag(t *testing.T) {
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "trimmed.json")
_, err := NewTrimConfig(TrimOptions{
InputFile: input,
OutputFile: output,
})
opts := validTrimOptions(input, output)
opts.Keep = ""
_, err := NewTrimConfig(opts)
if err == nil || !strings.Contains(err.Error(), "exactly one of --keep or --remove is required") {
t.Fatalf("expected missing selector error, got %v", err)
}
_, err = NewTrimConfig(TrimOptions{
InputFile: input,
OutputFile: output,
Keep: "1",
Remove: "2",
})
opts = validTrimOptions(input, output)
opts.Remove = "2"
_, err = NewTrimConfig(opts)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutually exclusive selector error, got %v", err)
}
@@ -664,14 +642,13 @@ func TestNewTrimConfigAcceptsOutputSchemaOverride(t *testing.T) {
output := filepath.Join(dir, "trimmed.json")
reportPath := filepath.Join(dir, "report.json")
cfg, err := NewTrimConfig(TrimOptions{
InputFile: input,
OutputFile: output,
ReportFile: reportPath,
Remove: "3-5",
OutputSchema: OutputSchemaMinimal,
AllowEmpty: true,
})
opts := validTrimOptions(input, output)
opts.Keep = ""
opts.Remove = "3-5"
opts.ReportFile = reportPath
opts.OutputSchema = OutputSchemaMinimal
opts.AllowEmpty = true
cfg, err := NewTrimConfig(opts)
if err != nil {
t.Fatalf("config failed: %v", err)
}
@@ -692,17 +669,30 @@ func TestNewTrimConfigAcceptsOutputSchemaOverride(t *testing.T) {
}
}
func TestNewTrimConfigTreatsWhitespaceReportFileAsOmitted(t *testing.T) {
dir := t.TempDir()
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "trimmed.json")
opts := validTrimOptions(input, output)
opts.ReportFile = " \t "
cfg, err := NewTrimConfig(opts)
if err != nil {
t.Fatalf("config failed: %v", err)
}
if cfg.ReportFile != "" {
t.Fatalf("report file = %q, want empty", cfg.ReportFile)
}
}
func TestNewTrimConfigRejectsInvalidOutputSchemaOverride(t *testing.T) {
dir := t.TempDir()
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "trimmed.json")
_, err := NewTrimConfig(TrimOptions{
InputFile: input,
OutputFile: output,
Keep: "1",
OutputSchema: "compact",
})
opts := validTrimOptions(input, output)
opts.OutputSchema = "compact"
_, err := NewTrimConfig(opts)
if err == nil {
t.Fatal("expected output schema validation error")
}
@@ -731,10 +721,8 @@ func TestNewNormalizeConfigRequiresOutputFile(t *testing.T) {
dir := t.TempDir()
input := writeTempFile(t, dir, "input.json")
_, err := NewNormalizeConfig(NormalizeOptions{
InputFile: input,
OutputModules: DefaultOutputModules,
})
opts := validNormalizeOptions(input, "")
_, err := NewNormalizeConfig(opts)
if err == nil {
t.Fatal("expected output-file required error")
}
@@ -749,11 +737,8 @@ func TestNewNormalizeConfigResolvesOutputSchemaDefaultAndEnv(t *testing.T) {
output := filepath.Join(dir, "normalized.json")
t.Setenv(OutputSchemaEnv, "")
cfg, err := NewNormalizeConfig(NormalizeOptions{
InputFile: input,
OutputFile: output,
OutputModules: DefaultOutputModules,
})
opts := validNormalizeOptions(input, output)
cfg, err := NewNormalizeConfig(opts)
if err != nil {
t.Fatalf("config failed: %v", err)
}
@@ -762,11 +747,7 @@ func TestNewNormalizeConfigResolvesOutputSchemaDefaultAndEnv(t *testing.T) {
}
t.Setenv(OutputSchemaEnv, OutputSchemaMinimal)
cfg, err = NewNormalizeConfig(NormalizeOptions{
InputFile: input,
OutputFile: output,
OutputModules: DefaultOutputModules,
})
cfg, err = NewNormalizeConfig(opts)
if err != nil {
t.Fatalf("config failed: %v", err)
}
@@ -780,12 +761,9 @@ func TestNewNormalizeConfigRejectsInvalidOutputSchema(t *testing.T) {
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "normalized.json")
_, err := NewNormalizeConfig(NormalizeOptions{
InputFile: input,
OutputFile: output,
OutputSchema: "compact",
OutputModules: DefaultOutputModules,
})
opts := validNormalizeOptions(input, output)
opts.OutputSchema = "compact"
_, err := NewNormalizeConfig(opts)
if err == nil {
t.Fatal("expected output schema error")
}
@@ -799,11 +777,9 @@ func TestNewNormalizeConfigRejectsUnknownOutputModule(t *testing.T) {
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "normalized.json")
_, err := NewNormalizeConfig(NormalizeOptions{
InputFile: input,
OutputFile: output,
OutputModules: "json,yaml",
})
opts := validNormalizeOptions(input, output)
opts.OutputModules = "json,yaml"
_, err := NewNormalizeConfig(opts)
if err == nil {
t.Fatal("expected output module error")
}
@@ -812,6 +788,153 @@ func TestNewNormalizeConfigRejectsUnknownOutputModule(t *testing.T) {
}
}
func TestNewNormalizeConfigTreatsWhitespaceReportFileAsOmitted(t *testing.T) {
dir := t.TempDir()
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "normalized.json")
opts := validNormalizeOptions(input, output)
opts.ReportFile = "\n\t "
cfg, err := NewNormalizeConfig(opts)
if err != nil {
t.Fatalf("config failed: %v", err)
}
if cfg.ReportFile != "" {
t.Fatalf("report file = %q, want empty", cfg.ReportFile)
}
}
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 TestNewRenderConfigRejectsMissingAndDirectoryInputFile(t *testing.T) {
dir := t.TempDir()
output := filepath.Join(dir, "rendered.md")
missingInput := filepath.Join(dir, "missing.json")
_, err := NewRenderConfig(validRenderOptions(missingInput, output))
if err == nil {
t.Fatal("expected missing input-file error")
}
if !strings.Contains(err.Error(), "--input-file") {
t.Fatalf("unexpected error: %v", err)
}
inputDir := filepath.Join(dir, "input-dir")
if err := os.MkdirAll(inputDir, 0o700); err != nil {
t.Fatalf("mkdir input dir: %v", err)
}
_, err = NewRenderConfig(validRenderOptions(inputDir, output))
if err == nil {
t.Fatal("expected directory input-file error")
}
if !strings.Contains(err.Error(), "is a directory, not a file") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNewRenderConfigRejectsMissingOutputParent(t *testing.T) {
dir := t.TempDir()
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "missing-parent", "rendered.md")
_, err := NewRenderConfig(validRenderOptions(input, output))
if err == nil {
t.Fatal("expected output parent directory error")
}
if !strings.Contains(err.Error(), "--output-file parent directory") {
t.Fatalf("unexpected error: %v", err)
}
}
func assertPositiveFloatEnvValidation(t *testing.T, envName string) {
t.Helper()
@@ -832,14 +955,7 @@ func assertPositiveFloatEnvValidation(t *testing.T, envName string) {
input := writeTempFile(t, dir, "input.json")
output := filepath.Join(dir, "merged.json")
_, err := NewMergeConfig(MergeOptions{
InputFiles: []string{input},
OutputFile: output,
InputReader: DefaultInputReader,
OutputModules: DefaultOutputModules,
PreprocessingModules: DefaultPreprocessingModules,
PostprocessingModules: DefaultPostprocessingModules,
})
_, err := NewMergeConfig(validMergeOptions(input, output))
if err == nil {
t.Fatal("expected error")
}
@@ -850,6 +966,45 @@ func assertPositiveFloatEnvValidation(t *testing.T, envName string) {
}
}
func validMergeOptions(inputFile string, outputFile string) MergeOptions {
return MergeOptions{
InputFiles: []string{inputFile},
OutputFile: outputFile,
InputReader: DefaultInputReader,
OutputModules: DefaultOutputModules,
PreprocessingModules: DefaultPreprocessingModules,
PostprocessingModules: DefaultPostprocessingModules,
}
}
func validTrimOptions(inputFile string, outputFile string) TrimOptions {
return TrimOptions{
InputFile: inputFile,
OutputFile: outputFile,
Keep: "1",
}
}
func validNormalizeOptions(inputFile string, outputFile string) NormalizeOptions {
return NormalizeOptions{
InputFile: inputFile,
OutputFile: outputFile,
OutputModules: DefaultOutputModules,
}
}
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,28 @@
package jsonfile
import (
"encoding/json"
"fmt"
"os"
)
// Write creates or truncates path and writes deterministic indented JSON.
func Write(path string, value any) (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)
}
}()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(value); err != nil {
return fmt.Errorf("encode %q: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,69 @@
package jsonfile
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteFormatsWithTwoSpaceIndentAndTrailingNewline(t *testing.T) {
type payload struct {
Name string `json:"name"`
Items []int `json:"items"`
}
path := filepath.Join(t.TempDir(), "out.json")
value := payload{
Name: "alpha",
Items: []int{1, 2},
}
if err := Write(path, value); err != nil {
t.Fatalf("write failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read output: %v", err)
}
got := string(data)
want := "{\n \"name\": \"alpha\",\n \"items\": [\n 1,\n 2\n ]\n}\n"
if got != want {
t.Fatalf("formatted JSON mismatch\nwant:\n%s\ngot:\n%s", want, got)
}
}
func TestWriteProducesValidJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "out.json")
value := map[string]any{
"application": "seriatim",
"segments": []map[string]any{
{
"id": 1,
"speaker": "A",
"text": "hello",
},
},
}
if err := Write(path, value); err != nil {
t.Fatalf("write failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read output: %v", err)
}
if !strings.HasSuffix(string(data), "\n") {
t.Fatalf("output missing trailing newline: %q", string(data))
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
}

View File

@@ -1,5 +1,7 @@
package model
import "fmt"
// RawTranscript is a loaded input document before canonical normalization.
type RawTranscript struct {
Source string `json:"source"`
@@ -61,6 +63,17 @@ type Segment struct {
OverlapGroupID int `json:"overlap_group_id,omitempty"`
}
// SegmentReference returns the best available external reference for a segment.
func SegmentReference(segment Segment) string {
if segment.Source != "" && segment.SourceSegmentIndex != nil {
return fmt.Sprintf("%s#%d", segment.Source, *segment.SourceSegmentIndex)
}
if segment.SourceRef != "" {
return segment.SourceRef
}
return ""
}
// Word preserves optional word-level timing data.
type Word struct {
Text string `json:"text"`

View File

@@ -0,0 +1,41 @@
package model
import "testing"
func TestSegmentReferenceUsesSourceAndIndexWhenAvailable(t *testing.T) {
index := 3
segment := Segment{
Source: "input.json",
SourceSegmentIndex: &index,
SourceRef: "word-run:1:2:3",
}
got := SegmentReference(segment)
want := "input.json#3"
if got != want {
t.Fatalf("reference = %q, want %q", got, want)
}
}
func TestSegmentReferenceFallsBackToSourceRef(t *testing.T) {
segment := Segment{
Source: "input.json",
SourceRef: "coalesce:2",
}
got := SegmentReference(segment)
want := "coalesce:2"
if got != want {
t.Fatalf("reference = %q, want %q", got, want)
}
}
func TestSegmentReferenceReturnsEmptyWhenNoReferenceFieldsPresent(t *testing.T) {
segment := Segment{
Source: "input.json",
}
if got := SegmentReference(segment); got != "" {
t.Fatalf("reference = %q, want empty", got)
}
}

View File

@@ -4,12 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
"gitea.maximumdirect.net/eric/seriatim/internal/buildinfo"
"gitea.maximumdirect.net/eric/seriatim/internal/config"
"gitea.maximumdirect.net/eric/seriatim/internal/jsonfile"
"gitea.maximumdirect.net/eric/seriatim/internal/report"
)
@@ -47,7 +47,7 @@ func Run(ctx context.Context, cfg config.NormalizeConfig) error {
return err
}
if err := writeOutputJSON(cfg.OutputFile, built.Output); err != nil {
if err := jsonfile.Write(cfg.OutputFile, built.Output); err != nil {
return err
}
@@ -118,18 +118,3 @@ func Run(ctx context.Context, cfg config.NormalizeConfig) error {
return nil
}
func writeOutputJSON(path string, value any) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(value); err != nil {
return fmt.Errorf("encode normalize output JSON: %w", err)
}
return nil
}

View File

@@ -1,7 +1,6 @@
package overlap
import (
"fmt"
"sort"
"gitea.maximumdirect.net/eric/seriatim/internal/model"
@@ -121,13 +120,7 @@ func distinctSpeakers(segments []model.Segment, indices []int) []string {
// SegmentRef returns the stable overlap reference for a segment.
func SegmentRef(segment model.Segment) string {
if segment.SourceSegmentIndex != nil {
return fmt.Sprintf("%s#%d", segment.Source, *segment.SourceSegmentIndex)
}
if segment.SourceRef != "" {
return segment.SourceRef
}
return segment.Source
return model.SegmentReference(segment)
}
func clearExisting(in *model.MergedTranscript) {

View File

@@ -0,0 +1,97 @@
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, "# "+escapeMarkdownInline(title), "")
if opts.IncludeMetadata {
lines = append(lines,
fmt.Sprintf("- Application: %s", escapeMarkdownInline(transcript.Metadata.Application)),
fmt.Sprintf("- Version: %s", escapeMarkdownInline(transcript.Metadata.Version)),
fmt.Sprintf("- Output schema: %s", escapeMarkdownInline(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 := escapeMarkdownInline(segment.Text)
if shouldItalicize(segment.Categories) {
text = "*" + text + "*"
}
parts = append(parts, fmt.Sprintf("**%s:** %s", escapeMarkdownInline(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 escapeMarkdownInline(value string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
"`", "\\`",
"*", "\\*",
"_", "\\_",
"{", "\\{",
"}", "\\}",
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"#", "\\#",
"+", "\\+",
"!", "\\!",
"|", "\\|",
"<", "\\<",
">", "\\>",
)
return replacer.Replace(value)
}
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,227 @@
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 TestMarkdownRendererEscapesUserProvidedMarkdown(t *testing.T) {
transcript := Transcript{
Schema: "seriatim-intermediate",
Metadata: Metadata{
Application: "seriatim *cli*",
Version: "v[test]",
},
Segments: []Segment{
{
ID: 1,
Start: 1,
End: 2,
Speaker: "Dr. *A_[1]",
Text: "Use *literal* [link](target) and `code` \\ slash!",
},
{
ID: 2,
Start: 2,
End: 3,
Speaker: "Narrator",
Text: "_aside_ with | pipe",
Categories: []string{"background"},
},
},
}
output, err := MarkdownRenderer{}.Render(transcript, Options{
Title: "# Planning [notes]",
IncludeTimestamps: false,
IncludeMetadata: true,
})
if err != nil {
t.Fatalf("render markdown: %v", err)
}
assertContains(t, output, "# \\# Planning \\[notes\\]")
assertContains(t, output, "- Application: seriatim \\*cli\\*")
assertContains(t, output, "- Version: v\\[test\\]")
assertContains(t, output, "**Dr. \\*A\\_\\[1\\]:** Use \\*literal\\* \\[link\\]\\(target\\) and \\`code\\` \\\\ slash\\!")
assertContains(t, output, "**Narrator:** *\\_aside\\_ with \\| pipe*")
}
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)
}
}
func TestMarkdownRendererIsDeterministic(t *testing.T) {
transcript := Transcript{
Schema: "seriatim-intermediate",
Metadata: Metadata{
Application: "seriatim",
Version: "v-test",
},
Segments: []Segment{
{ID: 1, Start: 1.2, End: 4.4, Speaker: "Eric", Text: "Hello there.", Categories: []string{"unknown-tag"}},
{ID: 2, Start: 65.1, End: 68.8, Speaker: "Mike", Text: "Yeah.", Categories: []string{"backchannel"}},
},
}
opts := Options{
Title: "Transcript",
IncludeTimestamps: true,
IncludeSegmentIDs: true,
IncludeMetadata: true,
}
first, err := MarkdownRenderer{}.Render(transcript, opts)
if err != nil {
t.Fatalf("first render failed: %v", err)
}
second, err := MarkdownRenderer{}.Render(transcript, opts)
if err != nil {
t.Fatalf("second render failed: %v", err)
}
if first != second {
t.Fatalf("render output is not deterministic:\nfirst:\n%s\nsecond:\n%s", first, second)
}
if !strings.Contains(first, "[00:00:0100:00:04] [#1] **Eric:** Hello there.") {
t.Fatalf("expected HH:MM:SS timestamp formatting:\n%s", first)
}
if !strings.Contains(first, "[00:01:0500:01:09] [#2] **Mike:** *Yeah.*") {
t.Fatalf("expected HH:MM:SS timestamp formatting:\n%s", first)
}
}
func assertContains(t *testing.T, value string, want string) {
t.Helper()
if !strings.Contains(value, want) {
t.Fatalf("expected output to contain %q:\n%s", want, value)
}
}

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,9 +1,6 @@
package report
import (
"encoding/json"
"os"
)
import "gitea.maximumdirect.net/eric/seriatim/internal/jsonfile"
// Severity classifies report events.
type Severity string
@@ -62,13 +59,5 @@ func Warning(stage string, module string, message string) Event {
// WriteJSON writes a deterministic JSON report.
func WriteJSON(path string, rpt Report) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
enc := json.NewEncoder(file)
enc.SetIndent("", " ")
return enc.Encode(rpt)
return jsonfile.Write(path, rpt)
}

View File

@@ -44,54 +44,29 @@ type MinimalResult struct {
RemovedIDs []int
}
type projection struct {
retainedIndexes []int
oldToNewID map[int]int
removedIDs []int
}
// Apply trims a full seriatim output transcript by segment ID.
func Apply(input schema.Transcript, opts Options) (Result, error) {
if err := validateMode(opts.Mode); err != nil {
return Result{}, err
}
selected := opts.Selector.IDs()
if len(selected) == 0 {
return Result{}, fmt.Errorf("selector cannot be empty")
}
inputIDs := make([]int, len(input.Segments))
for index, segment := range input.Segments {
inputIDs[index] = segment.ID
}
idIndex, err := validateInputIDs(inputIDs)
proj, err := projectSegmentIDs(inputIDs, opts)
if err != nil {
return Result{}, err
}
if err := validateSelectedIDsExist(selected, idIndex); err != nil {
return Result{}, err
}
kept := make([]schema.Segment, 0, len(input.Segments))
removed := make([]int, 0, len(input.Segments))
oldToNew := make(map[int]int, len(input.Segments))
for _, segment := range input.Segments {
keep := opts.Mode == ModeKeep && opts.Selector.Contains(segment.ID)
if opts.Mode == ModeRemove {
keep = !opts.Selector.Contains(segment.ID)
}
if !keep {
removed = append(removed, segment.ID)
continue
}
rewritten := copySegment(segment)
rewritten.ID = len(kept) + 1
kept := make([]schema.Segment, len(proj.retainedIndexes))
for outputIndex, inputIndex := range proj.retainedIndexes {
rewritten := copySegment(input.Segments[inputIndex])
rewritten.ID = outputIndex + 1
rewritten.OverlapGroupID = 0
kept = append(kept, rewritten)
oldToNew[segment.ID] = rewritten.ID
}
if len(kept) == 0 && !opts.AllowEmpty {
return Result{}, fmt.Errorf("trim operation produced an empty transcript; set AllowEmpty to true to permit this")
kept[outputIndex] = rewritten
}
kept, groups := recomputeOverlapGroups(kept)
@@ -104,62 +79,35 @@ func Apply(input schema.Transcript, opts Options) (Result, error) {
out.OverlapGroups = groups
return Result{
Transcript: out,
OldToNewID: oldToNew,
RemovedIDs: removed,
OldToNewID: proj.oldToNewID,
RemovedIDs: proj.removedIDs,
}, nil
}
// ApplyIntermediate trims an intermediate seriatim output transcript by
// segment ID.
func ApplyIntermediate(input schema.IntermediateTranscript, opts Options) (IntermediateResult, error) {
if err := validateMode(opts.Mode); err != nil {
return IntermediateResult{}, err
}
selected := opts.Selector.IDs()
if len(selected) == 0 {
return IntermediateResult{}, fmt.Errorf("selector cannot be empty")
}
inputIDs := make([]int, len(input.Segments))
for index, segment := range input.Segments {
inputIDs[index] = segment.ID
}
idIndex, err := validateInputIDs(inputIDs)
proj, err := projectSegmentIDs(inputIDs, opts)
if err != nil {
return IntermediateResult{}, err
}
if err := validateSelectedIDsExist(selected, idIndex); err != nil {
return IntermediateResult{}, err
}
kept := make([]schema.IntermediateSegment, 0, len(input.Segments))
removed := make([]int, 0, len(input.Segments))
oldToNew := make(map[int]int, len(input.Segments))
for _, segment := range input.Segments {
keep := opts.Mode == ModeKeep && opts.Selector.Contains(segment.ID)
if opts.Mode == ModeRemove {
keep = !opts.Selector.Contains(segment.ID)
}
if !keep {
removed = append(removed, segment.ID)
continue
}
kept := make([]schema.IntermediateSegment, len(proj.retainedIndexes))
for outputIndex, inputIndex := range proj.retainedIndexes {
segment := input.Segments[inputIndex]
rewritten := schema.IntermediateSegment{
ID: len(kept) + 1,
ID: outputIndex + 1,
Start: segment.Start,
End: segment.End,
Speaker: segment.Speaker,
Text: segment.Text,
Categories: append([]string(nil), segment.Categories...),
}
kept = append(kept, rewritten)
oldToNew[segment.ID] = rewritten.ID
}
if len(kept) == 0 && !opts.AllowEmpty {
return IntermediateResult{}, fmt.Errorf("trim operation produced an empty transcript; set AllowEmpty to true to permit this")
kept[outputIndex] = rewritten
}
return IntermediateResult{
@@ -171,60 +119,33 @@ func ApplyIntermediate(input schema.IntermediateTranscript, opts Options) (Inter
},
Segments: kept,
},
OldToNewID: oldToNew,
RemovedIDs: removed,
OldToNewID: proj.oldToNewID,
RemovedIDs: proj.removedIDs,
}, nil
}
// ApplyMinimal trims a minimal seriatim output transcript by segment ID.
func ApplyMinimal(input schema.MinimalTranscript, opts Options) (MinimalResult, error) {
if err := validateMode(opts.Mode); err != nil {
return MinimalResult{}, err
}
selected := opts.Selector.IDs()
if len(selected) == 0 {
return MinimalResult{}, fmt.Errorf("selector cannot be empty")
}
inputIDs := make([]int, len(input.Segments))
for index, segment := range input.Segments {
inputIDs[index] = segment.ID
}
idIndex, err := validateInputIDs(inputIDs)
proj, err := projectSegmentIDs(inputIDs, opts)
if err != nil {
return MinimalResult{}, err
}
if err := validateSelectedIDsExist(selected, idIndex); err != nil {
return MinimalResult{}, err
}
kept := make([]schema.MinimalSegment, 0, len(input.Segments))
removed := make([]int, 0, len(input.Segments))
oldToNew := make(map[int]int, len(input.Segments))
for _, segment := range input.Segments {
keep := opts.Mode == ModeKeep && opts.Selector.Contains(segment.ID)
if opts.Mode == ModeRemove {
keep = !opts.Selector.Contains(segment.ID)
}
if !keep {
removed = append(removed, segment.ID)
continue
}
kept := make([]schema.MinimalSegment, len(proj.retainedIndexes))
for outputIndex, inputIndex := range proj.retainedIndexes {
segment := input.Segments[inputIndex]
rewritten := schema.MinimalSegment{
ID: len(kept) + 1,
ID: outputIndex + 1,
Start: segment.Start,
End: segment.End,
Speaker: segment.Speaker,
Text: segment.Text,
}
kept = append(kept, rewritten)
oldToNew[segment.ID] = rewritten.ID
}
if len(kept) == 0 && !opts.AllowEmpty {
return MinimalResult{}, fmt.Errorf("trim operation produced an empty transcript; set AllowEmpty to true to permit this")
kept[outputIndex] = rewritten
}
return MinimalResult{
@@ -236,11 +157,53 @@ func ApplyMinimal(input schema.MinimalTranscript, opts Options) (MinimalResult,
},
Segments: kept,
},
OldToNewID: oldToNew,
RemovedIDs: removed,
OldToNewID: proj.oldToNewID,
RemovedIDs: proj.removedIDs,
}, nil
}
func projectSegmentIDs(ids []int, opts Options) (projection, error) {
if err := validateMode(opts.Mode); err != nil {
return projection{}, err
}
selected := opts.Selector.IDs()
if len(selected) == 0 {
return projection{}, fmt.Errorf("selector cannot be empty")
}
idIndex, err := validateInputIDs(ids)
if err != nil {
return projection{}, err
}
if err := validateSelectedIDsExist(selected, idIndex); err != nil {
return projection{}, err
}
result := projection{
retainedIndexes: make([]int, 0, len(ids)),
oldToNewID: make(map[int]int, len(ids)),
removedIDs: make([]int, 0, len(ids)),
}
for index, id := range ids {
keep := opts.Mode == ModeKeep && opts.Selector.Contains(id)
if opts.Mode == ModeRemove {
keep = !opts.Selector.Contains(id)
}
if !keep {
result.removedIDs = append(result.removedIDs, id)
continue
}
result.retainedIndexes = append(result.retainedIndexes, index)
result.oldToNewID[id] = len(result.retainedIndexes)
}
if len(result.retainedIndexes) == 0 && !opts.AllowEmpty {
return projection{}, fmt.Errorf("trim operation produced an empty transcript; set AllowEmpty to true to permit this")
}
return result, nil
}
func validateMode(mode Mode) error {
switch mode {
case ModeKeep, ModeRemove:

View File

@@ -399,6 +399,106 @@ func TestApplyMinimalDoesNotIncludeOverlapGroups(t *testing.T) {
}
}
func TestApplySelectorPolicyIsSharedAcrossSchemas(t *testing.T) {
type testCase struct {
name string
opts Options
wantTexts []string
wantOldToNew map[int]int
wantRemoved []int
wantSegmentCount int
wantErrorSubstring string
}
cases := []testCase{
{
name: "keep preserves input order regardless of selector order",
opts: Options{Mode: ModeKeep, Selector: mustParseSelector(t, "4,1,3")},
wantTexts: []string{"alpha", "gamma", "delta"},
wantOldToNew: map[int]int{1: 1, 3: 2, 4: 3},
wantRemoved: []int{2},
wantSegmentCount: 3,
},
{
name: "remove reports deterministic renumbering metadata",
opts: Options{Mode: ModeRemove, Selector: mustParseSelector(t, "2,4")},
wantTexts: []string{"alpha", "gamma"},
wantOldToNew: map[int]int{1: 1, 3: 2},
wantRemoved: []int{2, 4},
wantSegmentCount: 2,
},
{
name: "missing selected id returns error",
opts: Options{Mode: ModeKeep, Selector: mustParseSelector(t, "9")},
wantErrorSubstring: "does not exist",
},
{
name: "empty selector returns error",
opts: Options{Mode: ModeKeep, Selector: Selector{}},
wantErrorSubstring: "selector cannot be empty",
},
{
name: "invalid mode returns error",
opts: Options{Mode: Mode("bad"), Selector: mustParseSelector(t, "1")},
wantErrorSubstring: `invalid trim mode "bad"`,
},
{
name: "empty output blocked when allow empty is false",
opts: Options{Mode: ModeRemove, Selector: mustParseSelector(t, "1-4")},
wantErrorSubstring: "empty transcript",
},
{
name: "empty output allowed when allow empty is true",
opts: Options{Mode: ModeRemove, Selector: mustParseSelector(t, "1-4"), AllowEmpty: true},
wantTexts: []string{},
wantOldToNew: map[int]int{},
wantRemoved: []int{1, 2, 3, 4},
wantSegmentCount: 0,
},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
fullInput := fullTranscriptFixture()
intermediateInput := intermediateFixture()
minimalInput := minimalFixture()
fullResult, fullErr := Apply(fullInput, test.opts)
intermediateResult, intermediateErr := ApplyIntermediate(intermediateInput, test.opts)
minimalResult, minimalErr := ApplyMinimal(minimalInput, test.opts)
if test.wantErrorSubstring != "" {
assertErrorContains(t, fullErr, test.wantErrorSubstring)
assertErrorContains(t, intermediateErr, test.wantErrorSubstring)
assertErrorContains(t, minimalErr, test.wantErrorSubstring)
return
}
if fullErr != nil {
t.Fatalf("apply full failed: %v", fullErr)
}
if intermediateErr != nil {
t.Fatalf("apply intermediate failed: %v", intermediateErr)
}
if minimalErr != nil {
t.Fatalf("apply minimal failed: %v", minimalErr)
}
assertIntSlice(t, extractFullIDs(fullResult.Transcript.Segments), extractSequentialIDs(test.wantSegmentCount))
assertIntSlice(t, extractIntermediateIDs(intermediateResult.Transcript.Segments), extractSequentialIDs(test.wantSegmentCount))
assertIntSlice(t, extractMinimalIDs(minimalResult.Transcript.Segments), extractSequentialIDs(test.wantSegmentCount))
assertStringSlice(t, extractFullTexts(fullResult.Transcript.Segments), test.wantTexts)
assertStringSlice(t, extractIntermediateTexts(intermediateResult.Transcript.Segments), test.wantTexts)
assertStringSlice(t, extractMinimalTexts(minimalResult.Transcript.Segments), test.wantTexts)
assertIntMap(t, fullResult.OldToNewID, test.wantOldToNew)
assertIntMap(t, intermediateResult.OldToNewID, test.wantOldToNew)
assertIntMap(t, minimalResult.OldToNewID, test.wantOldToNew)
assertIntSlice(t, fullResult.RemovedIDs, test.wantRemoved)
assertIntSlice(t, intermediateResult.RemovedIDs, test.wantRemoved)
assertIntSlice(t, minimalResult.RemovedIDs, test.wantRemoved)
})
}
}
func TestApplyOutputInvariantsValidAfterRenumberAndOverlapRecompute(t *testing.T) {
input := overlapTranscriptFixture()
selector := mustParseSelector(t, "2,1")
@@ -666,3 +766,108 @@ func equalStringSlices(got []string, want []string) bool {
}
return true
}
func assertErrorContains(t *testing.T, err error, substring string) {
t.Helper()
if err == nil {
t.Fatalf("expected error containing %q", substring)
}
if !strings.Contains(err.Error(), substring) {
t.Fatalf("error %q does not contain %q", err.Error(), substring)
}
}
func assertStringSlice(t *testing.T, got []string, want []string) {
t.Helper()
if !equalStringSlices(got, want) {
t.Fatalf("slice = %v, want %v", got, want)
}
}
func extractSequentialIDs(count int) []int {
ids := make([]int, count)
for index := range ids {
ids[index] = index + 1
}
return ids
}
func extractFullIDs(segments []schema.Segment) []int {
ids := make([]int, len(segments))
for index, segment := range segments {
ids[index] = segment.ID
}
return ids
}
func extractIntermediateIDs(segments []schema.IntermediateSegment) []int {
ids := make([]int, len(segments))
for index, segment := range segments {
ids[index] = segment.ID
}
return ids
}
func extractMinimalIDs(segments []schema.MinimalSegment) []int {
ids := make([]int, len(segments))
for index, segment := range segments {
ids[index] = segment.ID
}
return ids
}
func extractFullTexts(segments []schema.Segment) []string {
texts := make([]string, len(segments))
for index, segment := range segments {
texts[index] = segment.Text
}
return texts
}
func extractIntermediateTexts(segments []schema.IntermediateSegment) []string {
texts := make([]string, len(segments))
for index, segment := range segments {
texts[index] = segment.Text
}
return texts
}
func extractMinimalTexts(segments []schema.MinimalSegment) []string {
texts := make([]string, len(segments))
for index, segment := range segments {
texts[index] = segment.Text
}
return texts
}
func intermediateFixture() schema.IntermediateTranscript {
return schema.IntermediateTranscript{
Metadata: schema.IntermediateMetadata{
Application: "seriatim",
Version: "v-test",
OutputSchema: schema.OutputSchemaIntermediate,
},
Segments: []schema.IntermediateSegment{
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "alpha", Categories: []string{"word-run"}},
{ID: 2, Start: 2, End: 3, Speaker: "Bob", Text: "beta", Categories: []string{"filler", "backchannel"}},
{ID: 3, Start: 3, End: 4, Speaker: "Carol", Text: "gamma", Categories: []string{"normal"}},
{ID: 4, Start: 4, End: 5, Speaker: "Dan", Text: "delta", Categories: []string{"normal"}},
},
}
}
func minimalFixture() schema.MinimalTranscript {
return schema.MinimalTranscript{
Metadata: schema.MinimalMetadata{
Application: "seriatim",
Version: "v-test",
OutputSchema: schema.OutputSchemaMinimal,
},
Segments: []schema.MinimalSegment{
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "alpha"},
{ID: 2, Start: 2, End: 3, Speaker: "Bob", Text: "beta"},
{ID: 3, Start: 3, End: 4, Speaker: "Carol", Text: "gamma"},
{ID: 4, Start: 4, End: 5, Speaker: "Dan", Text: "delta"},
},
}
}

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 = "seriatim-minimal"
SchemaIntermediate = "seriatim-intermediate"
SchemaFull = "seriatim-full"
SchemaMinimal = artifactpkg.OutputSchemaMinimal
SchemaIntermediate = artifactpkg.OutputSchemaIntermediate
SchemaFull = artifactpkg.OutputSchemaFull
)
// Artifact stores a parsed seriatim output artifact of one supported schema.
@@ -31,62 +31,39 @@ 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.
func ValidateArtifact(artifact Artifact) error {
switch artifact.Schema {
case SchemaFull:
if artifact.Full == nil {
return fmt.Errorf("full artifact payload is missing")
payload, err := artifact.fullPayload()
if err != nil {
return err
}
return schema.ValidateTranscript(*artifact.Full)
return schema.ValidateTranscript(*payload)
case SchemaIntermediate:
if artifact.Intermediate == nil {
return fmt.Errorf("intermediate artifact payload is missing")
payload, err := artifact.intermediatePayload()
if err != nil {
return err
}
return schema.ValidateIntermediateTranscript(*artifact.Intermediate)
return schema.ValidateIntermediateTranscript(*payload)
case SchemaMinimal:
if artifact.Minimal == nil {
return fmt.Errorf("minimal artifact payload is missing")
payload, err := artifact.minimalPayload()
if err != nil {
return err
}
return schema.ValidateMinimalTranscript(*artifact.Minimal)
return schema.ValidateMinimalTranscript(*payload)
default:
return fmt.Errorf("unsupported artifact schema %q", artifact.Schema)
}
@@ -188,10 +165,11 @@ func (artifact Artifact) Version() string {
func ApplyArtifact(input Artifact, opts Options) (ApplyArtifactResult, error) {
switch input.Schema {
case SchemaFull:
if input.Full == nil {
return ApplyArtifactResult{}, fmt.Errorf("full artifact payload is missing")
payload, err := input.fullPayload()
if err != nil {
return ApplyArtifactResult{}, err
}
result, err := Apply(*input.Full, opts)
result, err := Apply(*payload, opts)
if err != nil {
return ApplyArtifactResult{}, err
}
@@ -206,10 +184,11 @@ func ApplyArtifact(input Artifact, opts Options) (ApplyArtifactResult, error) {
OverlapGroupsRecomputed: true,
}, nil
case SchemaIntermediate:
if input.Intermediate == nil {
return ApplyArtifactResult{}, fmt.Errorf("intermediate artifact payload is missing")
payload, err := input.intermediatePayload()
if err != nil {
return ApplyArtifactResult{}, err
}
result, err := ApplyIntermediate(*input.Intermediate, opts)
result, err := ApplyIntermediate(*payload, opts)
if err != nil {
return ApplyArtifactResult{}, err
}
@@ -224,10 +203,11 @@ func ApplyArtifact(input Artifact, opts Options) (ApplyArtifactResult, error) {
OverlapGroupsRecomputed: false,
}, nil
case SchemaMinimal:
if input.Minimal == nil {
return ApplyArtifactResult{}, fmt.Errorf("minimal artifact payload is missing")
payload, err := input.minimalPayload()
if err != nil {
return ApplyArtifactResult{}, err
}
result, err := ApplyMinimal(*input.Minimal, opts)
result, err := ApplyMinimal(*payload, opts)
if err != nil {
return ApplyArtifactResult{}, err
}
@@ -254,18 +234,19 @@ func ConvertArtifact(input Artifact, outputSchema string) (Artifact, error) {
switch input.Schema {
case SchemaFull:
if input.Full == nil {
return Artifact{}, fmt.Errorf("full artifact payload is missing")
payload, err := input.fullPayload()
if err != nil {
return Artifact{}, err
}
switch outputSchema {
case SchemaIntermediate:
out := intermediateFromFull(*input.Full)
out := intermediateFromFull(*payload)
return Artifact{
Schema: SchemaIntermediate,
Intermediate: &out,
}, nil
case SchemaMinimal:
out := minimalFromFull(*input.Full)
out := minimalFromFull(*payload)
return Artifact{
Schema: SchemaMinimal,
Minimal: &out,
@@ -274,12 +255,13 @@ func ConvertArtifact(input Artifact, outputSchema string) (Artifact, error) {
return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema)
}
case SchemaIntermediate:
if input.Intermediate == nil {
return Artifact{}, fmt.Errorf("intermediate artifact payload is missing")
payload, err := input.intermediatePayload()
if err != nil {
return Artifact{}, err
}
switch outputSchema {
case SchemaMinimal:
out := minimalFromIntermediate(*input.Intermediate)
out := minimalFromIntermediate(*payload)
return Artifact{
Schema: SchemaMinimal,
Minimal: &out,
@@ -290,12 +272,13 @@ func ConvertArtifact(input Artifact, outputSchema string) (Artifact, error) {
return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema)
}
case SchemaMinimal:
if input.Minimal == nil {
return Artifact{}, fmt.Errorf("minimal artifact payload is missing")
payload, err := input.minimalPayload()
if err != nil {
return Artifact{}, err
}
switch outputSchema {
case SchemaIntermediate:
out := intermediateFromMinimal(*input.Minimal)
out := intermediateFromMinimal(*payload)
return Artifact{
Schema: SchemaIntermediate,
Intermediate: &out,
@@ -310,6 +293,27 @@ func ConvertArtifact(input Artifact, outputSchema string) (Artifact, error) {
}
}
func (artifact Artifact) fullPayload() (*schema.Transcript, error) {
if artifact.Full == nil {
return nil, fmt.Errorf("full artifact payload is missing")
}
return artifact.Full, nil
}
func (artifact Artifact) intermediatePayload() (*schema.IntermediateTranscript, error) {
if artifact.Intermediate == nil {
return nil, fmt.Errorf("intermediate artifact payload is missing")
}
return artifact.Intermediate, nil
}
func (artifact Artifact) minimalPayload() (*schema.MinimalTranscript, error) {
if artifact.Minimal == nil {
return nil, fmt.Errorf("minimal artifact payload is missing")
}
return artifact.Minimal, nil
}
func intermediateFromFull(input schema.Transcript) schema.IntermediateTranscript {
segments := make([]schema.IntermediateSegment, len(input.Segments))
for index, segment := range input.Segments {

View File

@@ -128,6 +128,61 @@ func TestConvertArtifactMinimalToFullFails(t *testing.T) {
}
}
func TestValidateArtifactRejectsMissingPayloads(t *testing.T) {
tests := []struct {
name string
artifact Artifact
want string
}{
{
name: "full",
artifact: Artifact{Schema: SchemaFull},
want: "full artifact payload is missing",
},
{
name: "intermediate",
artifact: Artifact{Schema: SchemaIntermediate},
want: "intermediate artifact payload is missing",
},
{
name: "minimal",
artifact: Artifact{Schema: SchemaMinimal},
want: "minimal artifact payload is missing",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateArtifact(test.artifact)
assertErrorContains(t, err, test.want)
})
}
}
func TestApplyArtifactRejectsMissingPayload(t *testing.T) {
_, err := ApplyArtifact(Artifact{Schema: SchemaFull}, Options{})
assertErrorContains(t, err, "full artifact payload is missing")
}
func TestConvertArtifactRejectsMissingPayloadWhenConversionRequested(t *testing.T) {
_, err := ConvertArtifact(Artifact{Schema: SchemaFull}, SchemaMinimal)
assertErrorContains(t, err, "full artifact payload is missing")
}
func TestConvertArtifactSameSchemaDoesNotRequirePayload(t *testing.T) {
artifact := Artifact{Schema: SchemaFull}
converted, err := ConvertArtifact(artifact, SchemaFull)
if err != nil {
t.Fatalf("convert failed: %v", err)
}
if converted.Schema != SchemaFull {
t.Fatalf("schema = %q, want %q", converted.Schema, SchemaFull)
}
if converted.Full != nil {
t.Fatalf("full payload = %#v, want nil", converted.Full)
}
}
func mustMarshalJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)

156
internal/trim/run.go Normal file
View File

@@ -0,0 +1,156 @@
package trim
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"gitea.maximumdirect.net/eric/seriatim/internal/config"
"gitea.maximumdirect.net/eric/seriatim/internal/jsonfile"
"gitea.maximumdirect.net/eric/seriatim/internal/report"
)
type auditReport struct {
Operation string `json:"operation"`
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
InputSchema string `json:"input_schema"`
OutputSchema string `json:"output_schema"`
Mode string `json:"mode"`
Selector string `json:"selector"`
SelectedIDs []int `json:"selected_ids"`
AllowEmpty bool `json:"allow_empty"`
InputSegmentCount int `json:"input_segment_count"`
RetainedSegmentCount int `json:"retained_segment_count"`
RemovedSegmentCount int `json:"removed_segment_count"`
RemovedInputIDs []int `json:"removed_input_ids"`
OldToNewIDMapping []idMapping `json:"old_to_new_id_mapping"`
OverlapGroupsRecomputed bool `json:"overlap_groups_recomputed"`
}
type idMapping struct {
OldID int `json:"old_id"`
NewID int `json:"new_id"`
}
// Run executes artifact-level trim orchestration.
func Run(ctx context.Context, cfg config.TrimConfig) error {
if err := ctx.Err(); err != nil {
return err
}
selector, err := ParseSelector(cfg.Selector)
if err != nil {
return fmt.Errorf("invalid selector %q: %w", cfg.Selector, err)
}
data, err := os.ReadFile(cfg.InputFile)
if err != nil {
return fmt.Errorf("read --input-file %q: %w", cfg.InputFile, err)
}
artifact, err := ParseArtifactJSON(data)
if err != nil {
return fmt.Errorf("--input-file %q: %w", cfg.InputFile, err)
}
inputSegmentCount := artifact.SegmentCount()
inputSchema := artifact.Schema
mode := ModeKeep
if cfg.Mode == "remove" {
mode = ModeRemove
}
trimmed, err := ApplyArtifact(artifact, Options{
Mode: mode,
Selector: selector,
AllowEmpty: cfg.AllowEmpty,
})
if err != nil {
return err
}
outputSchema := artifact.Schema
if cfg.OutputSchema != "" {
outputSchema = cfg.OutputSchema
}
outputArtifact, err := ConvertArtifact(trimmed.Artifact, outputSchema)
if err != nil {
return err
}
if err := ValidateArtifact(outputArtifact); err != nil {
return fmt.Errorf("validate trimmed output: %w", err)
}
if err := jsonfile.Write(cfg.OutputFile, outputArtifact.Value()); err != nil {
return err
}
if cfg.ReportFile == "" {
return nil
}
audit := auditReport{
Operation: "trim",
InputFile: cfg.InputFile,
OutputFile: cfg.OutputFile,
InputSchema: inputSchema,
OutputSchema: outputArtifact.Schema,
Mode: cfg.Mode,
Selector: cfg.Selector,
SelectedIDs: selector.IDs(),
AllowEmpty: cfg.AllowEmpty,
InputSegmentCount: inputSegmentCount,
RetainedSegmentCount: len(trimmed.OldToNewID),
RemovedSegmentCount: len(trimmed.RemovedIDs),
RemovedInputIDs: append([]int(nil), trimmed.RemovedIDs...),
OldToNewIDMapping: orderedIDMapping(trimmed.OldToNewID),
OverlapGroupsRecomputed: trimmed.OverlapGroupsRecomputed,
}
auditJSON, err := json.Marshal(audit)
if err != nil {
return fmt.Errorf("marshal trim audit report: %w", err)
}
rpt := report.Report{
Metadata: report.Metadata{
Application: outputArtifact.Application(),
Version: outputArtifact.Version(),
InputReader: "trim-artifact",
InputFiles: []string{cfg.InputFile},
OutputModules: []string{"json"},
},
Events: []report.Event{
report.Info("trim", "trim", fmt.Sprintf("trimmed %d input segment(s) into %d output segment(s) with mode=%s", inputSegmentCount, outputArtifact.SegmentCount(), cfg.Mode)),
report.Info("trim", "trim-audit", string(auditJSON)),
report.Info("trim", "validate-output", fmt.Sprintf("validated %d output segment(s)", outputArtifact.SegmentCount())),
report.Info("output", "json", "wrote transcript JSON"),
},
}
if err := report.WriteJSON(cfg.ReportFile, rpt); err != nil {
return err
}
return nil
}
func orderedIDMapping(mapping map[int]int) []idMapping {
keys := make([]int, 0, len(mapping))
for oldID := range mapping {
keys = append(keys, oldID)
}
sort.Ints(keys)
pairs := make([]idMapping, 0, len(keys))
for _, oldID := range keys {
pairs = append(pairs, idMapping{
OldID: oldID,
NewID: mapping[oldID],
})
}
return pairs
}

28
internal/trim/run_test.go Normal file
View File

@@ -0,0 +1,28 @@
package trim
import (
"context"
"errors"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/seriatim/internal/config"
)
func TestRunReturnsContextErrorBeforeWork(t *testing.T) {
dir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := Run(ctx, config.TrimConfig{
InputFile: filepath.Join(dir, "input.json"),
OutputFile: filepath.Join(dir, "output.json"),
Mode: "keep",
Selector: "1",
OutputSchema: "",
AllowEmpty: false,
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}

View File

@@ -14,6 +14,10 @@ import (
var schemaFS embed.FS
const (
OutputSchemaMinimal = "seriatim-minimal"
OutputSchemaIntermediate = "seriatim-intermediate"
OutputSchemaFull = "seriatim-full"
fullOutputSchemaPath = "full-output.schema.json"
intermediateOutputSchemaPath = "intermediate-output.schema.json"
minimalOutputSchemaPath = "minimal-output.schema.json"
@@ -115,6 +119,25 @@ type OverlapGroup struct {
Resolution string `json:"resolution"`
}
// ValidOutputSchemaName reports whether value is a supported output schema name.
func ValidOutputSchemaName(value string) bool {
switch value {
case OutputSchemaMinimal, OutputSchemaIntermediate, OutputSchemaFull:
return true
default:
return false
}
}
// OutputSchemaNames returns supported output schema names in validation order.
func OutputSchemaNames() []string {
return []string{
OutputSchemaMinimal,
OutputSchemaIntermediate,
OutputSchemaFull,
}
}
// ValidateTranscript validates a full transcript against the public JSON
// schema and seriatim-specific semantic rules.
func ValidateTranscript(transcript Transcript) error {
@@ -228,15 +251,17 @@ func outputSchema(schemaPath string) (*jsonschema.Schema, error) {
}
func validateSemantics(transcript Transcript) error {
segments := make([]segmentSemantics, len(transcript.Segments))
for index, segment := range transcript.Segments {
wantID := index + 1
if segment.ID != wantID {
return fmt.Errorf("segment %d has id %d; want %d", index, segment.ID, wantID)
}
if segment.End < segment.Start {
return fmt.Errorf("segment %d has end %.3f before start %.3f", index, segment.End, segment.Start)
segments[index] = segmentSemantics{
id: segment.ID,
start: segment.Start,
end: segment.End,
}
}
if err := validateSegmentSemantics(segments); err != nil {
return err
}
for index, group := range transcript.OverlapGroups {
if group.End < group.Start {
return fmt.Errorf("overlap_group %d has end %.3f before start %.3f", index, group.End, group.Start)
@@ -246,26 +271,43 @@ func validateSemantics(transcript Transcript) error {
}
func validateIntermediateSemantics(transcript IntermediateTranscript) error {
segments := make([]segmentSemantics, len(transcript.Segments))
for index, segment := range transcript.Segments {
wantID := index + 1
if segment.ID != wantID {
return fmt.Errorf("segment %d has id %d; want %d", index, segment.ID, wantID)
}
if segment.End < segment.Start {
return fmt.Errorf("segment %d has end %.3f before start %.3f", index, segment.End, segment.Start)
segments[index] = segmentSemantics{
id: segment.ID,
start: segment.Start,
end: segment.End,
}
}
return nil
return validateSegmentSemantics(segments)
}
func validateMinimalSemantics(transcript MinimalTranscript) error {
segments := make([]segmentSemantics, len(transcript.Segments))
for index, segment := range transcript.Segments {
wantID := index + 1
if segment.ID != wantID {
return fmt.Errorf("segment %d has id %d; want %d", index, segment.ID, wantID)
segments[index] = segmentSemantics{
id: segment.ID,
start: segment.Start,
end: segment.End,
}
if segment.End < segment.Start {
return fmt.Errorf("segment %d has end %.3f before start %.3f", index, segment.End, segment.Start)
}
return validateSegmentSemantics(segments)
}
type segmentSemantics struct {
id int
start float64
end float64
}
func validateSegmentSemantics(segments []segmentSemantics) error {
for index, segment := range segments {
wantID := index + 1
if segment.id != wantID {
return fmt.Errorf("segment %d has id %d; want %d", index, segment.id, wantID)
}
if segment.end < segment.start {
return fmt.Errorf("segment %d has end %.3f before start %.3f", index, segment.end, segment.start)
}
}
return nil

View File

@@ -5,6 +5,43 @@ import (
"testing"
)
func TestValidOutputSchemaName(t *testing.T) {
valid := []string{
OutputSchemaMinimal,
OutputSchemaIntermediate,
OutputSchemaFull,
}
for _, name := range valid {
if !ValidOutputSchemaName(name) {
t.Fatalf("expected %q to be valid", name)
}
}
invalid := []string{"", "compact", "minimal", "seriatim"}
for _, name := range invalid {
if ValidOutputSchemaName(name) {
t.Fatalf("expected %q to be invalid", name)
}
}
}
func TestOutputSchemaNames(t *testing.T) {
names := OutputSchemaNames()
want := []string{
OutputSchemaMinimal,
OutputSchemaIntermediate,
OutputSchemaFull,
}
if len(names) != len(want) {
t.Fatalf("len(names) = %d, want %d", len(names), len(want))
}
for index := range want {
if names[index] != want[index] {
t.Fatalf("names[%d] = %q, want %q", index, names[index], want[index])
}
}
}
func TestValidateTranscriptAcceptsValidTranscript(t *testing.T) {
transcript := validTranscript()