Compare commits
20 Commits
e5173c78fe
...
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fc92f3643 | |||
| 0dfd06c349 | |||
| da3720693d | |||
| 6dfc1ea527 | |||
| 761d70bbc6 | |||
| 451cc19418 | |||
| c37ea70dcb | |||
| a90859114a | |||
| 9202ccddb9 | |||
| f40d4add91 | |||
| 16bb12face | |||
| f18e2428dc | |||
| 3b64e784a1 | |||
| 3744d229a2 | |||
| 9bbe1fb7f1 | |||
| b7a66f6cc4 | |||
| c8efdb53d3 | |||
| ab4b252b08 | |||
| e9028e08a4 | |||
| 332884f887 |
20
README.md
20
README.md
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
`seriatim` is a Go CLI for transcript artifact processing.
|
`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
|
## Quickstart
|
||||||
|
|
||||||
@@ -20,6 +23,7 @@ go run ./cmd/seriatim merge \
|
|||||||
- `merge`: merge one or more input transcript JSON files.
|
- `merge`: merge one or more input transcript JSON files.
|
||||||
- `trim`: keep/remove segment IDs from an existing seriatim artifact.
|
- `trim`: keep/remove segment IDs from an existing seriatim artifact.
|
||||||
- `normalize`: canonicalize transcript-like JSON into a seriatim artifact.
|
- `normalize`: canonicalize transcript-like JSON into a seriatim artifact.
|
||||||
|
- `render`: render an existing seriatim artifact as Markdown.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -27,19 +31,19 @@ go run ./cmd/seriatim merge \
|
|||||||
- Configuration reference: [docs/config.md](docs/config.md)
|
- Configuration reference: [docs/config.md](docs/config.md)
|
||||||
- Operations guide: [docs/operations.md](docs/operations.md)
|
- Operations guide: [docs/operations.md](docs/operations.md)
|
||||||
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
||||||
- Integrations:
|
- Integration references:
|
||||||
- [docs/integrations/whisperx-json.md](docs/integrations/whisperx-json.md)
|
- [docs/integrations/whisperx-json.md](docs/integrations/whisperx-json.md)
|
||||||
- [docs/integrations/output-schemas.md](docs/integrations/output-schemas.md)
|
- [docs/integrations/output-schemas.md](docs/integrations/output-schemas.md)
|
||||||
- Development architecture policy: [docs/policy/architecture.md](docs/policy/architecture.md)
|
- Development policies:
|
||||||
- Contributor workflow: [docs/policy/development.md](docs/policy/development.md)
|
- [docs/policy/architecture.md](docs/policy/architecture.md)
|
||||||
- Documentation policy: [docs/policy/documentation.md](docs/policy/documentation.md)
|
- [docs/policy/development.md](docs/policy/development.md)
|
||||||
- Internal implementation docs:
|
- [docs/policy/documentation.md](docs/policy/documentation.md)
|
||||||
|
- Internal implementation references:
|
||||||
- [docs/internal/pipeline.md](docs/internal/pipeline.md)
|
- [docs/internal/pipeline.md](docs/internal/pipeline.md)
|
||||||
- [docs/internal/artifacts.md](docs/internal/artifacts.md)
|
- [docs/internal/artifacts.md](docs/internal/artifacts.md)
|
||||||
- [docs/internal/modules.md](docs/internal/modules.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/minimal-output.schema.json](schema/minimal-output.schema.json)
|
||||||
- [schema/intermediate-output.schema.json](schema/intermediate-output.schema.json)
|
- [schema/intermediate-output.schema.json](schema/intermediate-output.schema.json)
|
||||||
- [schema/full-output.schema.json](schema/full-output.schema.json)
|
- [schema/full-output.schema.json](schema/full-output.schema.json)
|
||||||
- Synthetic examples: [examples/README.md](examples/README.md)
|
- Synthetic examples: [examples/README.md](examples/README.md)
|
||||||
- Documentation roadmap: [docs/roadmap/documentation.md](docs/roadmap/documentation.md)
|
|
||||||
|
|||||||
40
docs/cli.md
40
docs/cli.md
@@ -16,6 +16,7 @@ go run ./cmd/seriatim merge \
|
|||||||
| `merge` | Merge one or more raw transcript JSON inputs into one seriatim artifact. |
|
| `merge` | Merge one or more raw transcript JSON inputs into one seriatim artifact. |
|
||||||
| `trim` | Keep or remove segment IDs from an existing seriatim artifact. |
|
| `trim` | Keep or remove segment IDs from an existing seriatim artifact. |
|
||||||
| `normalize` | Canonicalize transcript-like JSON into a seriatim artifact. |
|
| `normalize` | Canonicalize transcript-like JSON into a seriatim artifact. |
|
||||||
|
| `render` | Render an existing seriatim artifact as Markdown. |
|
||||||
|
|
||||||
Root usage:
|
Root usage:
|
||||||
|
|
||||||
@@ -130,6 +131,35 @@ Flags:
|
|||||||
- Does not run merge modules.
|
- Does not run merge modules.
|
||||||
- When `--output-schema` is omitted, schema resolution is: `SERIATIM_OUTPUT_SCHEMA` -> default `seriatim-intermediate`.
|
- When `--output-schema` is omitted, schema resolution is: `SERIATIM_OUTPUT_SCHEMA` -> default `seriatim-intermediate`.
|
||||||
|
|
||||||
|
## `render`
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```text
|
||||||
|
seriatim render [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
| Flag | Required | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `--input-file string` | Yes | none | Input seriatim artifact JSON file. |
|
||||||
|
| `--output-file string` | Yes | none | Rendered output file path. |
|
||||||
|
| `--format string` | Yes | none | Output format. Current supported value: `markdown`. |
|
||||||
|
| `--title string` | No | `Transcript` | Markdown document title. |
|
||||||
|
| `--include-timestamps` | No | `true` | Include `[HH:MM:SS–HH:MM:SS]` per segment. |
|
||||||
|
| `--include-segment-ids` | No | `false` | Include `[#id]` marker per segment. |
|
||||||
|
| `--include-metadata` | No | `false` | Include artifact metadata block near the top. |
|
||||||
|
|
||||||
|
`render` behavior:
|
||||||
|
|
||||||
|
- Input must be a valid existing seriatim output artifact (`seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`).
|
||||||
|
- Raw WhisperX-style JSON is rejected.
|
||||||
|
- `render` does not execute merge/trim/normalize transformations.
|
||||||
|
- `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
|
## Common workflows
|
||||||
|
|
||||||
Merge with a speaker map and report output:
|
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
|
--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
|
## Exit and errors
|
||||||
|
|
||||||
- Commands return exit code `0` on success.
|
- 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/minimal-output.schema.json](../schema/minimal-output.schema.json)
|
||||||
- [../schema/intermediate-output.schema.json](../schema/intermediate-output.schema.json)
|
- [../schema/intermediate-output.schema.json](../schema/intermediate-output.schema.json)
|
||||||
- [../schema/full-output.schema.json](../schema/full-output.schema.json)
|
- [../schema/full-output.schema.json](../schema/full-output.schema.json)
|
||||||
- Documentation roadmap: [roadmap/documentation.md](roadmap/documentation.md)
|
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ For `trim`:
|
|||||||
- If `--output-schema` is omitted, output preserves the input artifact schema.
|
- 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`.
|
- 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
|
## Merge module defaults
|
||||||
|
|
||||||
Default merge module selections:
|
Default merge module selections:
|
||||||
@@ -152,6 +164,11 @@ All commands:
|
|||||||
- Validates `--output-schema` through the same schema set as `merge`.
|
- Validates `--output-schema` through the same schema set as `merge`.
|
||||||
- Currently accepts only `json` in `--output-modules`.
|
- Currently accepts only `json` in `--output-modules`.
|
||||||
|
|
||||||
|
`render`:
|
||||||
|
|
||||||
|
- Requires `--input-file`, `--output-file`, and `--format`.
|
||||||
|
- Validates `--format` as `markdown`.
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
- CLI reference: [cli.md](cli.md)
|
- CLI reference: [cli.md](cli.md)
|
||||||
@@ -165,4 +182,3 @@ All commands:
|
|||||||
- [../schema/minimal-output.schema.json](../schema/minimal-output.schema.json)
|
- [../schema/minimal-output.schema.json](../schema/minimal-output.schema.json)
|
||||||
- [../schema/intermediate-output.schema.json](../schema/intermediate-output.schema.json)
|
- [../schema/intermediate-output.schema.json](../schema/intermediate-output.schema.json)
|
||||||
- [../schema/full-output.schema.json](../schema/full-output.schema.json)
|
- [../schema/full-output.schema.json](../schema/full-output.schema.json)
|
||||||
- Documentation roadmap: [roadmap/documentation.md](roadmap/documentation.md)
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ seriatim emits one of three public JSON output contracts:
|
|||||||
- `seriatim-intermediate`
|
- `seriatim-intermediate`
|
||||||
- `seriatim-full`
|
- `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
|
## Schema roles
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Describes public artifact conversion and validation internals for merge output,
|
Describes implemented artifact parsing, conversion, validation, and render-model
|
||||||
trim, and normalize.
|
normalization internals.
|
||||||
|
|
||||||
## Artifact contracts
|
## Artifact contracts
|
||||||
|
|
||||||
@@ -19,62 +19,80 @@ Machine-readable schemas:
|
|||||||
- `schema/intermediate-output.schema.json`
|
- `schema/intermediate-output.schema.json`
|
||||||
- `schema/minimal-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-full` -> `artifact.FromMerged`
|
||||||
- `seriatim-intermediate` -> `artifact.IntermediateFromMerged`
|
- `seriatim-intermediate` -> `artifact.IntermediateFromMerged`
|
||||||
- `seriatim-minimal` -> `artifact.MinimalFromMerged`
|
- `seriatim-minimal` -> `artifact.MinimalFromMerged`
|
||||||
|
- unknown/empty -> intermediate fallback
|
||||||
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`)
|
|
||||||
|
|
||||||
## Trim internals
|
## 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`).
|
1. Parse selector from validated config.
|
||||||
2. Parse input artifact and detect schema (`ParseArtifactJSON`).
|
2. Read and parse input artifact JSON.
|
||||||
3. Apply keep/remove projection with sequential ID renumbering.
|
3. Apply trim projection through schema-aware artifact handling.
|
||||||
4. Recompute overlap groups only for full-schema artifacts.
|
4. Resolve output schema (preserve input schema unless overridden).
|
||||||
5. Optionally convert output schema when supported.
|
5. Validate output artifact.
|
||||||
6. Validate output artifact before write.
|
6. Write output JSON.
|
||||||
|
7. Optionally write report JSON with `trim-audit`.
|
||||||
|
|
||||||
Schema-conversion limits:
|
Apply layer (`apply.go`):
|
||||||
|
|
||||||
- full -> intermediate/minimal supported.
|
- one shared projection policy for selector mode, input ID validation, selected
|
||||||
- intermediate -> minimal supported.
|
ID existence checks, keep/remove filtering, removed IDs, and old-to-new ID
|
||||||
- minimal -> intermediate supported.
|
mappings
|
||||||
- intermediate/minimal -> full is rejected.
|
- 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:
|
Trim invariants:
|
||||||
|
|
||||||
- selected IDs must exist in input.
|
- selected IDs must exist in input
|
||||||
- input IDs must be positive, unique, sequential.
|
- input IDs must be positive, unique, sequential
|
||||||
- retained order follows input transcript order.
|
- retained segment order follows input transcript order
|
||||||
- output IDs are reassigned to `1..N`.
|
- output IDs are reassigned to `1..N`
|
||||||
|
|
||||||
## Normalize internals
|
## Normalize internals
|
||||||
|
|
||||||
@@ -103,13 +121,64 @@ Run layer (`normalize.go`):
|
|||||||
|
|
||||||
Normalize invariant:
|
Normalize invariant:
|
||||||
|
|
||||||
- report events do not embed transcript text.
|
- report events do not embed transcript text
|
||||||
|
|
||||||
|
## Render internals
|
||||||
|
|
||||||
|
`internal/render` is an artifact-level, downstream-only renderer.
|
||||||
|
|
||||||
|
Model normalization (`normalize.go`):
|
||||||
|
|
||||||
|
- converts full/intermediate/minimal artifacts into a common render model
|
||||||
|
- preserves segment order and segment IDs
|
||||||
|
- normalizes per-segment fields to ID, start, end, speaker, text, categories
|
||||||
|
- emits empty categories slice when categories are absent in input
|
||||||
|
|
||||||
|
Renderer registry (`registry.go`):
|
||||||
|
|
||||||
|
- resolves renderers by public format name
|
||||||
|
- currently registers `markdown`
|
||||||
|
|
||||||
|
Markdown renderer (`markdown.go`):
|
||||||
|
|
||||||
|
- writes title header `# {title}`
|
||||||
|
- renders optional `[HH:MM:SS–HH:MM:SS]` timestamps
|
||||||
|
- renders optional `[#id]` segment references
|
||||||
|
- renders `**speaker:** text`
|
||||||
|
- italicizes text when categories include `background`, `backchannel`, or
|
||||||
|
`filler`
|
||||||
|
- ignores unknown categories
|
||||||
|
- optionally includes metadata summary block
|
||||||
|
|
||||||
|
Run layer (`run.go`):
|
||||||
|
|
||||||
|
1. Read input artifact JSON.
|
||||||
|
2. Parse via shared output-artifact parser.
|
||||||
|
3. Normalize to render model.
|
||||||
|
4. Resolve renderer by `--format`.
|
||||||
|
5. Render text output.
|
||||||
|
6. Write output file.
|
||||||
|
|
||||||
|
Render invariants:
|
||||||
|
|
||||||
|
- does not run merge/trim/normalize modules
|
||||||
|
- does not expose report output
|
||||||
|
- deterministic for identical input artifact and render flags
|
||||||
|
|
||||||
|
## Validation behavior
|
||||||
|
|
||||||
|
`schema/output.go` validates both structure and semantics:
|
||||||
|
|
||||||
|
- embedded JSON Schema validation via `jsonschema/v6`
|
||||||
|
- semantic checks for sequential segment IDs starting at `1`
|
||||||
|
- semantic checks for non-inverted segment timing (`end >= start`)
|
||||||
|
- full schema overlap-group timing checks (`group.end >= group.start`)
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- CLI flag semantics belong to `docs/cli.md`.
|
- CLI flag semantics belong to `docs/cli.md`.
|
||||||
- Runtime config/env surfaces belong to `docs/config.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
|
## Failure behavior
|
||||||
|
|
||||||
@@ -119,22 +188,29 @@ Representative failure classes:
|
|||||||
- schema validation failure for parsed artifact or built output
|
- schema validation failure for parsed artifact or built output
|
||||||
- unsupported schema conversion path (trim)
|
- unsupported schema conversion path (trim)
|
||||||
- selector or input-ID consistency errors (trim)
|
- selector or input-ID consistency errors (trim)
|
||||||
|
- unsupported renderer format (render)
|
||||||
- output/report file write failures from command paths
|
- output/report file write failures from command paths
|
||||||
|
|
||||||
## Tests to inspect before changes
|
## Tests to inspect before changes
|
||||||
|
|
||||||
- `schema/output_test.go`
|
- `schema/output_test.go`
|
||||||
- `internal/artifact/transcript_test.go`
|
- `internal/artifact/transcript_test.go`
|
||||||
|
- `internal/artifact/output_artifact_test.go`
|
||||||
- `internal/trim/selector_test.go`
|
- `internal/trim/selector_test.go`
|
||||||
- `internal/trim/artifact_test.go`
|
- `internal/trim/artifact_test.go`
|
||||||
- `internal/trim/apply_test.go`
|
- `internal/trim/apply_test.go`
|
||||||
- `internal/normalize/parse_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/trim_test.go`
|
||||||
- `internal/cli/normalize_test.go`
|
- `internal/cli/normalize_test.go`
|
||||||
|
- `internal/cli/render_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- Public artifacts are validated through `schema` before acceptance.
|
- Public artifacts are validated through `schema` before acceptance.
|
||||||
- Segment IDs in emitted artifacts are sequential and deterministic.
|
- Segment IDs in emitted artifacts are sequential and deterministic.
|
||||||
- Internal-only fields are not emitted in minimal/intermediate contracts.
|
- 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.
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ Output writer:
|
|||||||
- `autocorrect`: applies YAML replacement rules when configured.
|
- `autocorrect`: applies YAML replacement rules when configured.
|
||||||
- `assign-ids`: assigns final sequential IDs.
|
- `assign-ids`: assigns final sequential IDs.
|
||||||
- `validate-output`: validates selected public artifact shape.
|
- `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:
|
Filesystem side effects are limited to:
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ coalesce gap and overlap thresholds).
|
|||||||
- Pipeline does not parse CLI flags.
|
- Pipeline does not parse CLI flags.
|
||||||
- Pipeline does not normalize raw CLI strings.
|
- Pipeline does not normalize raw CLI strings.
|
||||||
- Pipeline delegates conversion to public output contracts to `internal/artifact`.
|
- 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
|
## Failure behavior
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ This document covers runtime operation of the implemented CLI commands:
|
|||||||
- `merge`
|
- `merge`
|
||||||
- `trim`
|
- `trim`
|
||||||
- `normalize`
|
- `normalize`
|
||||||
|
- `render`
|
||||||
|
|
||||||
## Runtime model
|
## 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.
|
- `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.
|
- `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.
|
- `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
|
## Normal workflow
|
||||||
|
|
||||||
@@ -80,11 +82,28 @@ go run ./cmd/seriatim normalize \
|
|||||||
--report-file normalize-report.json
|
--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
|
## 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:
|
Optional report output:
|
||||||
|
|
||||||
@@ -92,6 +111,7 @@ Optional report output:
|
|||||||
- `merge` report metadata records reader/modules and event sequence.
|
- `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.
|
- `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.
|
- `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
|
## Failure and retry behavior
|
||||||
|
|
||||||
@@ -106,9 +126,10 @@ Retry guidance:
|
|||||||
2. Re-run the same command.
|
2. Re-run the same command.
|
||||||
3. If a prior run created a partial or unwanted output/report file, remove it and rerun.
|
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
|
## 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.
|
- Store outputs in controlled directories with appropriate OS permissions.
|
||||||
- Share report files carefully; they include file paths and processing diagnostics.
|
- 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.
|
- 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
|
## Related docs
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ must describe current behavior only; planned or speculative work belongs under
|
|||||||
## Project Shape
|
## Project Shape
|
||||||
|
|
||||||
seriatim is a Go CLI for transcript artifact processing. The implemented
|
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
|
`merge` reads one or more JSON transcript files, optionally maps input files to
|
||||||
canonical speakers, runs a registry-selected preprocessing chain, merges
|
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,
|
registry-selected postprocessing chain, validates the selected output schema,
|
||||||
and writes JSON output plus an optional JSON report.
|
and writes JSON output plus an optional JSON report.
|
||||||
|
|
||||||
`trim` and `normalize` are artifact-level commands outside the merge pipeline.
|
`trim`, `normalize`, and `render` are artifact-level commands outside the merge
|
||||||
`trim` reads an existing seriatim output artifact and projects it by segment ID.
|
pipeline. `trim` reads an existing seriatim output artifact and projects it by
|
||||||
`normalize` reads transcript-like JSON and emits one of seriatim's supported
|
segment ID. `normalize` reads transcript-like JSON and emits one of seriatim's
|
||||||
output schemas. Neither command runs merge preprocessing or postprocessing
|
supported output schemas. `render` reads an existing seriatim output artifact
|
||||||
modules.
|
and emits human-readable Markdown. None of these commands runs merge
|
||||||
|
preprocessing or postprocessing modules.
|
||||||
|
|
||||||
The supported public output schemas are `seriatim-minimal`,
|
The supported public output schemas are `seriatim-minimal`,
|
||||||
`seriatim-intermediate`, and `seriatim-full`. For command and flag details, use
|
`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`.
|
reports. Built-in adapters and modules are registered from `internal/builtin`.
|
||||||
|
|
||||||
CLI code in `internal/cli` should parse flags, build validated config values,
|
CLI code in `internal/cli` should parse flags, build validated config values,
|
||||||
and delegate. `merge` delegates to `pipeline.Run`; `trim` and `normalize`
|
and delegate. `merge` delegates to `pipeline.Run`; `trim`, `normalize`, and
|
||||||
perform artifact-level orchestration and delegate deterministic parsing,
|
`render` perform artifact-level orchestration and delegate deterministic
|
||||||
validation, and transformation work to their internal packages.
|
parsing, validation, and transformation work to their internal packages.
|
||||||
|
|
||||||
Config loading and validation belongs in `internal/config`. Filesystem reads and
|
Config loading and validation belongs in `internal/config`. Filesystem reads and
|
||||||
writes are adapter concerns and should not spread into pure transformation
|
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.
|
coalesce, danglers, backchannel, filler, and autocorrect behavior.
|
||||||
|
|
||||||
When changing artifact-level commands, inspect `internal/trim`,
|
When changing artifact-level commands, inspect `internal/trim`,
|
||||||
`internal/normalize`, and their CLI tests. When changing public output shape or
|
`internal/normalize`, `internal/render`, and their CLI tests. When changing
|
||||||
schema validation, inspect `schema` and `internal/artifact` tests. Report and
|
public output shape or schema validation, inspect `schema` and
|
||||||
diagnostic changes should be covered through the command or package tests that
|
`internal/artifact` tests. Report and diagnostic changes should be covered
|
||||||
emit the affected events.
|
through the command or package tests that emit the affected events.
|
||||||
|
|
||||||
## Dependency Policy
|
## Dependency Policy
|
||||||
|
|
||||||
@@ -202,8 +203,8 @@ free of secrets or private transcript data.
|
|||||||
registry name.
|
registry name.
|
||||||
- Preserve deterministic ordering, final segment ID assignment, and schema
|
- Preserve deterministic ordering, final segment ID assignment, and schema
|
||||||
validation before output acceptance.
|
validation before output acceptance.
|
||||||
- Keep `trim` and `normalize` artifact-level; do not run merge modules from
|
- Keep `trim`, `normalize`, and `render` artifact-level; do not run merge
|
||||||
those commands.
|
modules from those commands.
|
||||||
- Keep public output schemas validated through `schema`.
|
- Keep public output schemas validated through `schema`.
|
||||||
- Keep optional reports ordered, concise, and diagnostic.
|
- Keep optional reports ordered, concise, and diagnostic.
|
||||||
- Avoid broad dependencies without a concrete maintainability benefit.
|
- Avoid broad dependencies without a concrete maintainability benefit.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ It complements [architecture policy](architecture.md) and
|
|||||||
- `internal/artifact/`: conversion from internal merged model to public shapes.
|
- `internal/artifact/`: conversion from internal merged model to public shapes.
|
||||||
- `internal/trim/`: artifact-level trim logic.
|
- `internal/trim/`: artifact-level trim logic.
|
||||||
- `internal/normalize/`: artifact-level normalize parsing/building.
|
- `internal/normalize/`: artifact-level normalize parsing/building.
|
||||||
|
- `internal/render/`: artifact-level rendering and renderer registry.
|
||||||
- `internal/*` domain packages: overlap, coalesce, danglers, filler,
|
- `internal/*` domain packages: overlap, coalesce, danglers, filler,
|
||||||
backchannel, speaker, autocorrect, report, model.
|
backchannel, speaker, autocorrect, report, model.
|
||||||
- `schema/`: public structs plus embedded JSON Schemas and validation.
|
- `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 merge --help
|
||||||
go run ./cmd/seriatim trim --help
|
go run ./cmd/seriatim trim --help
|
||||||
go run ./cmd/seriatim normalize --help
|
go run ./cmd/seriatim normalize --help
|
||||||
|
go run ./cmd/seriatim render --help
|
||||||
```
|
```
|
||||||
|
|
||||||
Current toolchain note:
|
Current toolchain note:
|
||||||
|
|||||||
@@ -1,591 +0,0 @@
|
|||||||
# Pre-1.0 Code Quality And Deduplication Audit
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
seriatim's current codebase is in good shape for a limited pre-1.0 cleanup pass. The main command paths are explicit, tests cover the public CLI and important transformation behavior, and the implemented architecture mostly matches the policy in `docs/policy/architecture.md`: merge uses a registry-driven pipeline, while trim and normalize operate at the artifact level.
|
|
||||||
|
|
||||||
The top three refactoring targets before 1.0 are:
|
|
||||||
|
|
||||||
1. Centralize public output schema names, schema validation selection, and artifact schema switching.
|
|
||||||
2. Reduce duplicated trim projection logic across full, intermediate, and minimal artifacts.
|
|
||||||
3. Move trim command orchestration out of `internal/cli` so CLI code remains a thin adapter like merge and normalize.
|
|
||||||
|
|
||||||
No major architectural risk appears to block 1.0. The best next step is a series of small, behavior-preserving refactors protected by the existing CLI, trim, normalize, artifact, schema, and pipeline tests.
|
|
||||||
|
|
||||||
## Repository Map Reviewed
|
|
||||||
|
|
||||||
Reviewed documentation and policy:
|
|
||||||
|
|
||||||
- `README.md`
|
|
||||||
- `docs/cli.md`
|
|
||||||
- `docs/config.md`
|
|
||||||
- `docs/operations.md`
|
|
||||||
- `docs/troubleshooting.md`
|
|
||||||
- `docs/policy/architecture.md`
|
|
||||||
- `docs/policy/development.md`
|
|
||||||
- `docs/internal/pipeline.md`
|
|
||||||
- `docs/internal/artifacts.md`
|
|
||||||
- `docs/internal/modules.md`
|
|
||||||
- `docs/integrations/output-schemas.md`
|
|
||||||
- `docs/integrations/whisperx-json.md`
|
|
||||||
- `docs/roadmap/documentation.md`
|
|
||||||
|
|
||||||
Reviewed implementation areas:
|
|
||||||
|
|
||||||
- `cmd/seriatim`: root process error handling.
|
|
||||||
- `internal/cli`: Cobra command setup and command-level tests for `merge`, `trim`, and `normalize`.
|
|
||||||
- `internal/config`: validated config construction, defaults, environment variables, path validation, and config tests.
|
|
||||||
- `internal/pipeline`: registry resolution, stage ordering, preprocessing state validation, and pipeline execution.
|
|
||||||
- `internal/builtin`: built-in input, preprocess, merge, postprocess, and output modules.
|
|
||||||
- `internal/artifact`: conversion from merged model to public output artifacts.
|
|
||||||
- `internal/trim`: selector parsing, artifact parsing/conversion, trimming, and trim tests.
|
|
||||||
- `internal/normalize`: artifact-level parsing, repair, building, reporting, and normalize tests.
|
|
||||||
- `internal/report`: report model and JSON writer.
|
|
||||||
- `internal/overlap`, `internal/coalesce`, `internal/danglers`, `internal/backchannel`, `internal/filler`, `internal/autocorrect`, `internal/speaker`: implemented modules and module-specific tests.
|
|
||||||
- `schema`: public Go schema types, embedded JSON Schemas, semantic validation, and schema tests.
|
|
||||||
- `examples` and `samples`: checked layout and role, not every sample payload line-by-line.
|
|
||||||
|
|
||||||
The reviewed execution paths were:
|
|
||||||
|
|
||||||
- `seriatim merge`: Cobra options, `config.NewMergeConfig`, `pipeline.Run`, built-in registry, output writer, optional report.
|
|
||||||
- `seriatim trim`: Cobra options, `config.NewTrimConfig`, artifact parsing, trim application, schema conversion, output writing, optional report.
|
|
||||||
- `seriatim normalize`: Cobra options, `config.NewNormalizeConfig`, `normalize.Run`, artifact parsing/building, output writing, optional report.
|
|
||||||
|
|
||||||
No `internal/app`, `internal/stage`, `internal/modules`, `internal/validators`, `internal/adapters`, `internal/storage`, `internal/manifest`, `pkg`, or top-level `tests` directories exist in the current layout. Equivalent responsibilities are implemented in the packages listed above.
|
|
||||||
|
|
||||||
## High-Confidence Deduplication Opportunities
|
|
||||||
|
|
||||||
### Centralize Output Schema Names And Schema Selection
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/config`
|
|
||||||
- `internal/trim`
|
|
||||||
- `internal/artifact`
|
|
||||||
- `internal/normalize`
|
|
||||||
- `schema`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- `internal/config` defines `seriatim-minimal`, `seriatim-intermediate`, and `seriatim-full` as output schema constants and validates CLI/config values.
|
|
||||||
- `internal/trim/artifact.go` defines another set of constants with the same string values.
|
|
||||||
- `internal/artifact`, `internal/normalize`, and `internal/trim` each switch over the same schema names to select conversion or validation behavior.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- Output schema names are part of the public interface. If a schema name, default, or validation error changes in one package but not another, `merge`, `trim`, and `normalize` can drift.
|
|
||||||
- The current duplication is small but central enough that future schema changes would require edits in several packages.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Keep one canonical set of public schema names, preferably outside command-specific config construction.
|
|
||||||
- Expose narrow helpers for schema validation and display text where needed.
|
|
||||||
- Preserve the current semantic difference that `merge` and `normalize` resolve `SERIATIM_OUTPUT_SCHEMA` and default to intermediate, while `trim` preserves the input artifact schema unless `--output-schema` is supplied.
|
|
||||||
- Do not build a broad schema registry unless a new implemented schema makes the switch statements materially harder to maintain.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- `internal/config` tests for schema defaults, env precedence, invalid values, and trim override behavior.
|
|
||||||
- `internal/cli` tests for merge, trim, and normalize schema flags.
|
|
||||||
- `internal/artifact`, `internal/trim`, `internal/normalize`, and `schema` tests for selected shape validation.
|
|
||||||
|
|
||||||
Risk level: Low to medium. Public behavior must stay byte-compatible where tests assert shapes and diagnostics.
|
|
||||||
|
|
||||||
### Reduce Duplicated Trim Projection Logic Across Artifact Shapes
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/trim/apply.go`
|
|
||||||
- `internal/trim/apply_test.go`
|
|
||||||
- `internal/cli/trim_test.go`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- `Apply`, `ApplyIntermediate`, and `ApplyMinimal` each validate mode, reject empty selectors, collect input IDs, validate sequential IDs, verify selected IDs exist, apply keep/remove policy, renumber retained segments from 1, build old-to-new mappings, collect removed IDs, and enforce `AllowEmpty`.
|
|
||||||
- Only the segment shape and full-schema overlap group recomputation differ.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- Trim is user-facing artifact surgery. Drift in ID validation, empty-output handling, or keep/remove behavior across schemas would produce confusing public differences.
|
|
||||||
- The duplicated loops make future fixes to selector behavior or ID policy likely to require changes in three places.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Extract a small projection helper that operates on ordered segment IDs and returns retained indexes, old-to-new ID mapping, removed IDs, and empty-output validation.
|
|
||||||
- Keep schema-specific reconstruction local to each artifact shape.
|
|
||||||
- Keep full-schema overlap recomputation separate; it is a real semantic difference and should remain obvious.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- Existing `internal/trim/apply_test.go` coverage for keep/remove, renumbering, sequential ID validation, empty output, schema preservation, and overlap recomputation.
|
|
||||||
- Existing `internal/cli/trim_test.go` coverage for end-to-end artifact behavior and report audit fields.
|
|
||||||
- Add one table test that asserts the same selector policy across all three schemas.
|
|
||||||
|
|
||||||
Risk level: Medium. The refactor touches public trim behavior, but the duplicated policy is well covered.
|
|
||||||
|
|
||||||
### Move Trim Orchestration Out Of The CLI Adapter
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/cli/trim.go`
|
|
||||||
- `internal/trim`
|
|
||||||
- `internal/config`
|
|
||||||
- `internal/report`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- `merge` parses flags, builds config, and delegates to `pipeline.Run`.
|
|
||||||
- `normalize` parses flags, builds config, and delegates to `normalize.Run`.
|
|
||||||
- `trim` parses flags and config, but also reads files, parses artifacts, applies domain logic, converts schemas, validates output, writes output JSON, builds audit payloads, and writes reports directly in `internal/cli`.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- This is the clearest boundary drift from the architecture policy. CLI should stay an adapter for flag parsing and command dispatch.
|
|
||||||
- Keeping trim orchestration in CLI makes it harder to test trim as an application service without Cobra and makes report/output behavior easier to diverge from normalize.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Add a `trim.Run(ctx, cfg)` or similarly narrow artifact-level service in `internal/trim`.
|
|
||||||
- Move artifact reading, selector parsing, apply/convert/validate/write/report orchestration into that service.
|
|
||||||
- Leave Cobra flag definitions and `config.NewTrimConfig` calls in `internal/cli`.
|
|
||||||
- Keep trim audit fields stable unless tests and docs are intentionally updated.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- Keep existing `internal/cli/trim_test.go` end-to-end tests.
|
|
||||||
- Add direct `internal/trim` service tests for report generation and output schema conversion once the orchestration moves.
|
|
||||||
|
|
||||||
Risk level: Medium. It is mostly a move, but report event wording and error wrapping must remain stable.
|
|
||||||
|
|
||||||
### Centralize Pretty JSON File Writing
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/builtin/output.go`
|
|
||||||
- `internal/cli/trim.go`
|
|
||||||
- `internal/normalize/normalize.go`
|
|
||||||
- `internal/report/report.go`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- Several packages create a file with `os.Create`, use `json.NewEncoder`, set a two-space indent, encode a value, and defer close.
|
|
||||||
- Error wrapping differs by call site: normalize wraps encode errors, trim and merge output mostly return raw errors, and report writing returns raw encode errors.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- JSON artifacts and reports are core outputs. Formatting and write error semantics should not drift accidentally.
|
|
||||||
- Centralizing this low-level operation would simplify future changes such as consistent close error handling or atomic write policy, if such behavior is ever implemented.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Introduce a small internal helper for deterministic JSON file writing.
|
|
||||||
- Keep report construction in `internal/report`; only share the file-writing mechanics.
|
|
||||||
- Do not introduce atomic writes or temporary files as part of this cleanup unless that behavior is intentionally designed and documented.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- Existing CLI tests that read output JSON for all commands.
|
|
||||||
- Existing report tests through merge, trim, and normalize CLI paths.
|
|
||||||
- A small helper-level test can verify indentation and trailing newline if those become explicit guarantees.
|
|
||||||
|
|
||||||
Risk level: Low.
|
|
||||||
|
|
||||||
## Medium-Confidence Opportunities
|
|
||||||
|
|
||||||
### Simplify Artifact Schema Switching In `internal/trim`
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/trim/artifact.go`
|
|
||||||
- `internal/trim/artifact_test.go`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- `Artifact.Value`, `SegmentCount`, `Application`, `Version`, `ValidateArtifact`, `ApplyArtifact`, and `ConvertArtifact` all switch on the same schema discriminator and nil-check the same payload pointers.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- The wrapper works, but the repeated switch boilerplate makes it easier to miss one accessor when adding a field or changing error behavior.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- After centralizing schema names, consider small methods or private helpers that reduce repeated nil-check/access patterns.
|
|
||||||
- Avoid a generic visitor framework unless it directly removes the existing boilerplate without hiding schema-specific conversion rules.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- `internal/trim/artifact_test.go`
|
|
||||||
- `internal/cli/trim_test.go` schema conversion cases.
|
|
||||||
|
|
||||||
Risk level: Low to medium.
|
|
||||||
|
|
||||||
### Share Common Config Path Validation Helpers For Single-Input Commands
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/config/config.go`
|
|
||||||
- `internal/config/config_test.go`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- `NewTrimConfig` and `NewNormalizeConfig` both trim, clean, require, and stat a single `--input-file`.
|
|
||||||
- They also share `--output-file` and optional `--report-file` normalization behavior.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- Config validation is a public command contract. Even small drift in missing file errors, directory errors, or output path parent checks would be user-visible.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Add private helpers for required single input files and optional output/report paths.
|
|
||||||
- Keep merge's multi-input normalization separate because it deduplicates and sorts repeated `--input-file` values.
|
|
||||||
- Preserve the intentional output schema difference between trim and normalize.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- Existing `internal/config` tests for trim and normalize input/output/report errors.
|
|
||||||
- Existing CLI tests for report path failures.
|
|
||||||
|
|
||||||
Risk level: Low.
|
|
||||||
|
|
||||||
### Add Narrow CLI Flag Helper Functions
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/cli/merge.go`
|
|
||||||
- `internal/cli/trim.go`
|
|
||||||
- `internal/cli/normalize.go`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- `--input-file`, `--output-file`, `--report-file`, `--output-schema`, and `--output-modules` are defined in multiple commands with related help text and defaults.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- Flag names and help text are part of the user interface. Minor drift between commands can make docs and tests harder to keep accurate.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Add small flag registration helpers only for shared flags whose semantics are genuinely the same.
|
|
||||||
- Do not introduce a command factory; current command files are short and readable.
|
|
||||||
- Keep trim's `--output-schema` help/default distinct because omitted trim schema preserves the input artifact schema.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- Existing command recognition and behavior tests.
|
|
||||||
- Add help-output assertions only if the helper refactor changes how help text is generated.
|
|
||||||
|
|
||||||
Risk level: Low.
|
|
||||||
|
|
||||||
### Centralize Segment Provenance Reference Formatting Where Semantics Match
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/overlap`
|
|
||||||
- `internal/coalesce`
|
|
||||||
- `internal/danglers`
|
|
||||||
- `internal/model`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- Several modules construct or interpret provenance references such as `source#index`, `word-run:group:speaker:run`, `coalesce:n`, and `resolve-danglers:n`.
|
|
||||||
- `internal/overlap` and `internal/coalesce` both prefer `Source` plus `SourceSegmentIndex` and fall back to `SourceRef`.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- Provenance references appear in public full output and reports/tests rely on deterministic values. A formatting mismatch could affect downstream consumers.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Introduce a very small helper for the common `source#index` or "best available segment reference" behavior.
|
|
||||||
- Leave module-specific generated prefixes local unless another module needs to parse or construct them with the same semantics.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- `internal/overlap` resolve/detect tests.
|
|
||||||
- `internal/coalesce` tests.
|
|
||||||
- Merge CLI tests that assert `source_ref`, `derived_from`, and overlap group segment references.
|
|
||||||
|
|
||||||
Risk level: Medium. Public full-output provenance must remain stable.
|
|
||||||
|
|
||||||
### Share Category Tagging Mechanics Between Backchannel And Filler Carefully
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `internal/backchannel`
|
|
||||||
- `internal/filler`
|
|
||||||
- `internal/builtin`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- Backchannel and filler modules both normalize text by removing punctuation, collapse fields, reject empty text, enforce a maximum word count of three, enforce a max duration, match regex patterns, avoid duplicate categories, and append a category.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- The policies are similar enough that a bug fix to normalization or duplicate category handling may need to be applied twice.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Consider a tiny private shared helper for tag application mechanics if another tagger is added or if a bug is found in the common logic.
|
|
||||||
- Keep the category names, regex lists, and duration defaults in their current packages.
|
|
||||||
- Do not create a broad classifier framework before 1.0.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- `internal/backchannel/backchannel_test.go`
|
|
||||||
- `internal/filler/filler_test.go`
|
|
||||||
- Merge CLI tests that assert category tagging and report events.
|
|
||||||
|
|
||||||
Risk level: Low to medium.
|
|
||||||
|
|
||||||
### Reduce Repetition In Schema Semantic Validation
|
|
||||||
|
|
||||||
Affected files/packages:
|
|
||||||
|
|
||||||
- `schema/output.go`
|
|
||||||
- `schema/output_test.go`
|
|
||||||
|
|
||||||
Duplicated or near-duplicated behavior:
|
|
||||||
|
|
||||||
- Full, intermediate, and minimal semantic validators each enforce sequential segment IDs and non-decreasing timing.
|
|
||||||
- Full validation also checks overlap group timing, which is intentionally schema-specific.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
|
|
||||||
- Sequential IDs and timing are cross-schema public invariants. Drift in error wording or strictness would be confusing.
|
|
||||||
|
|
||||||
Recommended refactor:
|
|
||||||
|
|
||||||
- Consider a small helper that validates ordered ID/timing pairs for all segment shapes.
|
|
||||||
- Keep full overlap group validation separate.
|
|
||||||
|
|
||||||
Suggested tests:
|
|
||||||
|
|
||||||
- Existing schema validation tests for missing/non-sequential IDs and invalid timing.
|
|
||||||
- Add cross-schema semantic validation cases if helper extraction touches all three shapes.
|
|
||||||
|
|
||||||
Risk level: Low.
|
|
||||||
|
|
||||||
## Boundary And Responsibility Concerns
|
|
||||||
|
|
||||||
The main boundary concern is `internal/cli/trim.go`. It currently holds command parsing, artifact I/O, selector parsing, domain execution, schema conversion, output validation, output writing, report assembly, and audit sorting. That orchestration belongs in `internal/trim` under the current architecture because trim is an artifact-level application command, not CLI-specific behavior.
|
|
||||||
|
|
||||||
`internal/normalize` already provides a better pattern: the CLI builds `config.NormalizeConfig` and delegates to `normalize.Run`. `merge` follows the same boundary direction by delegating to `pipeline.Run`.
|
|
||||||
|
|
||||||
Other boundary observations:
|
|
||||||
|
|
||||||
- YAML loading for speaker maps and autocorrect rules lives in narrow packages and is called from built-in modules. This is acceptable for current behavior, although future refactors could split file loading from pure transformation if tests start needing adapter-free module execution.
|
|
||||||
- `schema` owns public schema structs, embedded JSON Schema validation, and semantic validation. That is an appropriate boundary. Avoid moving command defaults or CLI wording into `schema`.
|
|
||||||
- `internal/report` owns report data structures and report JSON writing. It should not grow command-specific audit policy, but it can reasonably share low-level deterministic JSON writing.
|
|
||||||
|
|
||||||
## Path, Key, And Naming Construction Review
|
|
||||||
|
|
||||||
seriatim currently uses local filesystem paths only. There are no remote keys, cache paths, manifests, lock files, daemon paths, object-store keys, or resume state paths in the inspected implementation.
|
|
||||||
|
|
||||||
Path validation is reasonably centralized in `internal/config`:
|
|
||||||
|
|
||||||
- `normalizeInputFiles` handles merge's repeated input files, duplicate detection, sorting, and file existence.
|
|
||||||
- `normalizeOutputPath` validates output/report parent directories.
|
|
||||||
- `requireFile` validates input files and YAML config files.
|
|
||||||
|
|
||||||
Cleanup opportunities:
|
|
||||||
|
|
||||||
- Add private config helpers for required single input paths and optional report paths to reduce repeated trim/normalize validation.
|
|
||||||
- Centralize deterministic JSON output file writing as described above.
|
|
||||||
- Consider a small provenance reference helper for `source#index` formatting and fallback behavior where `internal/overlap` and `internal/coalesce` already share semantics.
|
|
||||||
|
|
||||||
No remote key or generated workspace path cleanup is applicable.
|
|
||||||
|
|
||||||
## Resolution And Catalog Review
|
|
||||||
|
|
||||||
Module resolution is cleanly centralized for merge:
|
|
||||||
|
|
||||||
- `internal/builtin.NewRegistry` registers built-in input, preprocessing, postprocessing, merger, and output modules.
|
|
||||||
- `internal/pipeline.Registry` resolves names and returns user-facing unknown module errors.
|
|
||||||
- `pipeline.Run` validates preprocessing state transitions before execution.
|
|
||||||
|
|
||||||
Output module resolution differs by command:
|
|
||||||
|
|
||||||
- Merge resolves output modules through the pipeline registry.
|
|
||||||
- Normalize accepts only `json` through config validation.
|
|
||||||
- Trim does not expose output modules and always writes JSON.
|
|
||||||
|
|
||||||
Those differences appear intentional for current behavior. Do not force trim and normalize into the merge registry unless artifact-level commands gain actual pluggable output modules.
|
|
||||||
|
|
||||||
Schema resolution is less centralized:
|
|
||||||
|
|
||||||
- Config validates public schema names for CLI/config inputs.
|
|
||||||
- Artifact, normalize, and trim packages switch over the same schema values for output construction, parsing, conversion, and validation.
|
|
||||||
|
|
||||||
Recommended centralization is limited to schema names and narrow validation/selection helpers. Avoid a plugin or catalog abstraction that would imply unimplemented dynamic schemas.
|
|
||||||
|
|
||||||
## Config And Command-Loading Review
|
|
||||||
|
|
||||||
Config loading is explicit and mostly consistent:
|
|
||||||
|
|
||||||
- CLI commands parse flags into option structs.
|
|
||||||
- `internal/config` validates and normalizes runtime configs.
|
|
||||||
- Merge and normalize resolve `SERIATIM_OUTPUT_SCHEMA`; trim accepts an explicit schema override and otherwise preserves the input artifact schema.
|
|
||||||
- Merge sorts and deduplicates repeated input files; trim and normalize each require exactly one input file.
|
|
||||||
|
|
||||||
Likely intentional differences:
|
|
||||||
|
|
||||||
- Trim has `--keep`, `--remove`, and `--allow-empty`; merge and normalize do not.
|
|
||||||
- Merge exposes module lists and stage config; trim and normalize do not run the merge pipeline.
|
|
||||||
- Normalize validates output modules as only `json`; trim has no output module flag.
|
|
||||||
- `--output-schema` has a default in merge and normalize help, but trim's empty default is meaningful.
|
|
||||||
|
|
||||||
Likely cleanup opportunities:
|
|
||||||
|
|
||||||
- Share path/report validation helpers for single-input commands.
|
|
||||||
- Share flag registration for truly common flags after confirming help text stays stable.
|
|
||||||
- Add small config test builders to reduce repeated boilerplate in `internal/config/config_test.go`.
|
|
||||||
|
|
||||||
No duplicated secret handling was found; no secrets are currently implemented.
|
|
||||||
|
|
||||||
## State, Manifest, Or Progress Handling Review
|
|
||||||
|
|
||||||
No durable state, manifests, checkpoints, progress files, resume logic, force mode, dry-run mode, remote storage, or daemon state are implemented.
|
|
||||||
|
|
||||||
Reports are deterministic JSON event artifacts:
|
|
||||||
|
|
||||||
- Merge reports are finalized in `internal/pipeline`.
|
|
||||||
- Normalize reports are built in `internal/normalize`.
|
|
||||||
- Trim reports are currently built in `internal/cli`.
|
|
||||||
|
|
||||||
The only state/progress cleanup recommended before 1.0 is to align trim report construction with an `internal/trim` application service and to avoid adding manifest/resume abstractions unless a concrete implemented workflow requires them.
|
|
||||||
|
|
||||||
## Refactors To Avoid Before 1.0
|
|
||||||
|
|
||||||
Avoid these tempting refactors before 1.0:
|
|
||||||
|
|
||||||
- A generic workflow engine for merge, trim, and normalize. Their command semantics are different enough that a shared engine would obscure behavior.
|
|
||||||
- A broad plugin architecture. Current modules are built-in and registry-driven; dynamic plugins are not implemented.
|
|
||||||
- A sweeping CLI redesign or command factory. The current explicit Cobra setup is easy to audit.
|
|
||||||
- A generalized schema catalog that implies runtime-extensible schemas. Only the implemented minimal, intermediate, and full output schemas should be represented.
|
|
||||||
- A manifest, checkpoint, resume, or dry-run framework. No such state model exists today.
|
|
||||||
- Premature generics-heavy helpers for schema conversions. Keep helpers small and tied to duplicated policy, not shape similarity alone.
|
|
||||||
- Merging backchannel and filler into one classifier package unless the common mechanics are extracted narrowly and domain-specific rules remain obvious.
|
|
||||||
- Replacing all file I/O with an adapter layer. The current app is filesystem-only; introduce adapter seams only where they reduce tested duplication or clarify command boundaries.
|
|
||||||
|
|
||||||
## Recommended Implementation Sequence
|
|
||||||
|
|
||||||
1. Centralize schema names and output schema validation helpers.
|
|
||||||
- Goal: one canonical public schema name source and stable validation behavior.
|
|
||||||
- Files: `internal/config`, `internal/trim`, `internal/artifact`, `internal/normalize`, `schema` as needed.
|
|
||||||
- Tests: `go test ./internal/config ./internal/artifact ./internal/trim ./internal/normalize ./schema ./internal/cli`.
|
|
||||||
- Prompt size: one small implementation prompt.
|
|
||||||
|
|
||||||
2. Extract trim projection policy.
|
|
||||||
- Goal: one implementation of keep/remove selection, sequential ID validation, renumbering, old-to-new mapping, removed IDs, and allow-empty policy.
|
|
||||||
- Files: `internal/trim/apply.go`, `internal/trim/apply_test.go`.
|
|
||||||
- Tests: `go test ./internal/trim ./internal/cli`.
|
|
||||||
- Prompt size: one prompt.
|
|
||||||
|
|
||||||
3. Move trim orchestration into `internal/trim`.
|
|
||||||
- Goal: make trim CLI match the merge/normalize adapter boundary.
|
|
||||||
- Files: `internal/cli/trim.go`, new or updated `internal/trim` service file, trim tests.
|
|
||||||
- Tests: `go test ./internal/trim ./internal/cli`.
|
|
||||||
- Prompt size: one prompt.
|
|
||||||
|
|
||||||
4. Centralize deterministic JSON output writing.
|
|
||||||
- Goal: remove repeated `os.Create` plus indented JSON encoder boilerplate.
|
|
||||||
- Files: a small internal helper plus `internal/builtin/output.go`, `internal/normalize/normalize.go`, `internal/trim` or `internal/cli/trim.go`, `internal/report/report.go`.
|
|
||||||
- Tests: `go test ./internal/builtin ./internal/normalize ./internal/trim ./internal/report ./internal/cli`.
|
|
||||||
- Prompt size: one prompt.
|
|
||||||
|
|
||||||
5. Clean up config and CLI repetition.
|
|
||||||
- Goal: private helpers for single input path validation, optional report paths, and common flags where semantics match.
|
|
||||||
- Files: `internal/config/config.go`, `internal/config/config_test.go`, `internal/cli/*.go`.
|
|
||||||
- Tests: `go test ./internal/config ./internal/cli`.
|
|
||||||
- Prompt size: one prompt; split CLI helper work if help text assertions are added.
|
|
||||||
|
|
||||||
6. Review provenance and category helper extraction.
|
|
||||||
- Goal: centralize only shared mechanics that are easy to get wrong.
|
|
||||||
- Files: `internal/overlap`, `internal/coalesce`, maybe `internal/model`; `internal/backchannel`, `internal/filler`.
|
|
||||||
- Tests: `go test ./internal/overlap ./internal/coalesce ./internal/backchannel ./internal/filler ./internal/cli`.
|
|
||||||
- Prompt size: one or two prompts depending on whether provenance and category work are both pursued.
|
|
||||||
|
|
||||||
7. Test helper cleanup.
|
|
||||||
- Goal: reduce repeated config builders and CLI test fixtures without hiding test intent.
|
|
||||||
- Files: `internal/config/*_test.go`, `internal/cli/*_test.go`, selected module tests.
|
|
||||||
- Tests: affected package tests plus `go test ./...`.
|
|
||||||
- Prompt size: one prompt if scoped to config/CLI; otherwise split by package.
|
|
||||||
|
|
||||||
8. Dead-code and legacy terminology sweep.
|
|
||||||
- Goal: remove stale helpers or wording left after cleanup.
|
|
||||||
- Files: code and docs touched by the above stages.
|
|
||||||
- Tests: `go test ./...`; grep for stale schema or architecture terms.
|
|
||||||
- Prompt size: one prompt.
|
|
||||||
|
|
||||||
## Test Strategy
|
|
||||||
|
|
||||||
Before refactoring:
|
|
||||||
|
|
||||||
- Use existing tests as a behavior lock:
|
|
||||||
- `go test ./internal/config`
|
|
||||||
- `go test ./internal/trim`
|
|
||||||
- `go test ./internal/normalize`
|
|
||||||
- `go test ./internal/artifact`
|
|
||||||
- `go test ./schema`
|
|
||||||
- `go test ./internal/cli`
|
|
||||||
|
|
||||||
During schema cleanup:
|
|
||||||
|
|
||||||
- Preserve tests covering schema defaults, env precedence, invalid schema values, trim schema preservation, trim schema conversion, normalize selected schema, and merge output schemas.
|
|
||||||
- Add a small cross-command test only if centralization changes the public error text source.
|
|
||||||
|
|
||||||
During trim projection cleanup:
|
|
||||||
|
|
||||||
- Add or preserve tests that assert the same selector behavior for full, intermediate, and minimal inputs.
|
|
||||||
- Keep full-schema overlap group recomputation tests separate.
|
|
||||||
|
|
||||||
During trim orchestration move:
|
|
||||||
|
|
||||||
- Keep CLI end-to-end tests for trim output and reports.
|
|
||||||
- Add direct `internal/trim` service tests if report construction moves out of CLI.
|
|
||||||
|
|
||||||
During JSON writer cleanup:
|
|
||||||
|
|
||||||
- Preserve CLI tests that read generated JSON.
|
|
||||||
- If the helper claims deterministic formatting, add a focused helper test for indentation and newline behavior.
|
|
||||||
|
|
||||||
During provenance/category cleanup:
|
|
||||||
|
|
||||||
- Preserve module tests and merge CLI tests that assert `source_ref`, `derived_from`, overlap group segment refs, backchannel categories, filler categories, and report events.
|
|
||||||
|
|
||||||
Final validation for any cleanup sequence:
|
|
||||||
|
|
||||||
- `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`
|
|
||||||
|
|
||||||
For this audit-only pass, the full test suite was intentionally not run because no code or example behavior changed.
|
|
||||||
|
|
||||||
## Appendix: Findings Not Worth Acting On
|
|
||||||
|
|
||||||
### Keep Merge Pipeline Registry Explicit
|
|
||||||
|
|
||||||
The registry resolution methods for input readers, preprocessors, postprocessors, output writers, and the merger look similar, but they produce stage-specific error messages and keep stage boundaries clear. A generic resolver would save little and could make errors less direct.
|
|
||||||
|
|
||||||
### Keep Merge, Trim, And Normalize As Separate Command Concepts
|
|
||||||
|
|
||||||
The commands share flags and file output behavior, but their domain semantics differ. Merge is a staged pipeline over raw inputs; trim and normalize operate on artifacts. A shared command runner would likely obscure those distinctions.
|
|
||||||
|
|
||||||
### Do Not Centralize All File Reads
|
|
||||||
|
|
||||||
`internal/builtin/input`, `internal/normalize/parse`, `internal/speaker`, `internal/autocorrect`, and trim orchestration read different file contracts and wrap errors differently. Centralizing all reads would not improve clarity today.
|
|
||||||
|
|
||||||
### Keep Schema-Specific Artifact Conversion Visible
|
|
||||||
|
|
||||||
Full, intermediate, and minimal outputs are intentionally different public contracts. Helpers can remove repeated accessors and schema names, but conversion code should remain easy to audit.
|
|
||||||
|
|
||||||
### Leave `samples/` Alone In Code Cleanup
|
|
||||||
|
|
||||||
`samples/` is a documentation/data hygiene question, not a code-quality refactor. The cleanup sequence should focus on maintained `examples/` and avoid destructive sample moves without a separate privacy and documentation pass.
|
|
||||||
|
|
||||||
### Avoid Test Fixture Over-Abstraction
|
|
||||||
|
|
||||||
There is repeated test setup in CLI and config tests, but much of it makes public behavior explicit at the call site. Add small builders where they reduce noise, but do not hide important command arguments behind opaque fixtures.
|
|
||||||
@@ -1,784 +0,0 @@
|
|||||||
# Pre-1.0 Cleanup Implementation Plan
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the staged cleanup work recommended by
|
|
||||||
`docs/roadmap/audit.md`. It is written for coding agents that will implement the
|
|
||||||
cleanup in order.
|
|
||||||
|
|
||||||
This is a roadmap document. It may describe planned refactors because it lives
|
|
||||||
under `docs/roadmap/`. Implementation agents must not document planned cleanup
|
|
||||||
as current behavior outside `docs/roadmap/`.
|
|
||||||
|
|
||||||
## Governing Policies
|
|
||||||
|
|
||||||
Follow these policy documents before implementing any stage:
|
|
||||||
|
|
||||||
- `docs/policy/architecture.md`
|
|
||||||
- `docs/policy/development.md`
|
|
||||||
- `docs/policy/documentation.md`
|
|
||||||
|
|
||||||
The cleanup must preserve these project rules:
|
|
||||||
|
|
||||||
- Keep CLI code thin: parse flags, build validated config, delegate.
|
|
||||||
- Keep config normalization and validation in `internal/config`.
|
|
||||||
- Keep public output contracts and validation in `schema`.
|
|
||||||
- Keep merge pipeline orchestration in `internal/pipeline`.
|
|
||||||
- Keep trim and normalize artifact-level; do not run merge modules from those
|
|
||||||
commands.
|
|
||||||
- Keep behavior deterministic and sequential.
|
|
||||||
- Prefer narrow helpers over broad frameworks.
|
|
||||||
- Do not add concurrency, dynamic plugins, durable state, manifests, remote
|
|
||||||
storage, or resume behavior.
|
|
||||||
|
|
||||||
## Global Implementation Rules
|
|
||||||
|
|
||||||
Each stage should be implemented as a small, behavior-preserving change.
|
|
||||||
|
|
||||||
For every stage:
|
|
||||||
|
|
||||||
- Start with `git status --short`.
|
|
||||||
- Read the files listed in that stage before editing.
|
|
||||||
- Do not modify unrelated files.
|
|
||||||
- Preserve public CLI flags, defaults, output schemas, JSON shapes, report event
|
|
||||||
ordering, report event text, and error messages unless the stage explicitly
|
|
||||||
says otherwise.
|
|
||||||
- Prefer private helpers unless a cross-package helper is genuinely needed.
|
|
||||||
- Do not introduce generic engines, plugin systems, broad adapter layers, or
|
|
||||||
generics-heavy abstractions.
|
|
||||||
- Run the stage-specific tests listed in the stage.
|
|
||||||
- Run `go test ./...` before considering the full cleanup sequence complete.
|
|
||||||
|
|
||||||
Documentation updates during cleanup should be minimal. If a refactor changes
|
|
||||||
implemented package boundaries that are described in `docs/internal/` or
|
|
||||||
`docs/policy/architecture.md`, update those docs in the same implementation
|
|
||||||
stage. Do not update user-facing docs if behavior did not change.
|
|
||||||
|
|
||||||
## Stage 1: Centralize Output Schema Names
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Make `schema` the canonical source for public output schema names and keep
|
|
||||||
command-specific default/precedence policy in `internal/config`.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Use the `schema` package as the canonical home for output schema name constants
|
|
||||||
because public output contracts already live there.
|
|
||||||
|
|
||||||
Add these exported string constants to `schema`:
|
|
||||||
|
|
||||||
- `OutputSchemaMinimal = "seriatim-minimal"`
|
|
||||||
- `OutputSchemaIntermediate = "seriatim-intermediate"`
|
|
||||||
- `OutputSchemaFull = "seriatim-full"`
|
|
||||||
|
|
||||||
Add narrow schema-name helpers in `schema`, not CLI-specific helpers:
|
|
||||||
|
|
||||||
- `ValidOutputSchemaName(value string) bool`
|
|
||||||
- `OutputSchemaNames() []string`
|
|
||||||
|
|
||||||
Do not put CLI flag names or default selection policy in `schema`.
|
|
||||||
|
|
||||||
Keep the `internal/config` constants as aliases for compatibility inside the
|
|
||||||
repository:
|
|
||||||
|
|
||||||
- `config.OutputSchemaMinimal = schema.OutputSchemaMinimal`
|
|
||||||
- `config.OutputSchemaIntermediate = schema.OutputSchemaIntermediate`
|
|
||||||
- `config.OutputSchemaFull = schema.OutputSchemaFull`
|
|
||||||
|
|
||||||
Remove the duplicate string constants from `internal/trim/artifact.go`; use the
|
|
||||||
canonical schema constants instead. A type alias or package-local aliases are
|
|
||||||
acceptable only if they point directly to the `schema` constants and do not
|
|
||||||
repeat string literals.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `schema/output.go`
|
|
||||||
- `schema/output_test.go`
|
|
||||||
- `internal/config/config.go`
|
|
||||||
- `internal/config/config_test.go`
|
|
||||||
- `internal/trim/artifact.go`
|
|
||||||
- `internal/trim/artifact_test.go`
|
|
||||||
- `internal/artifact/transcript.go`
|
|
||||||
- `internal/normalize/build.go`
|
|
||||||
- `internal/cli/*_test.go`
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Add canonical output schema constants and helpers to `schema/output.go`.
|
|
||||||
2. Update `internal/config` schema constants to alias `schema` constants.
|
|
||||||
3. Update config validation to use `schema.ValidOutputSchemaName` while keeping
|
|
||||||
the existing `--output-schema must be one of ...` error text.
|
|
||||||
4. Update `internal/trim` to stop repeating schema string constants.
|
|
||||||
5. Update affected tests only where they refer to moved constants.
|
|
||||||
6. Search for raw schema string literals outside tests and docs. Keep raw values
|
|
||||||
in docs and test fixtures where they are intentionally testing serialized
|
|
||||||
public JSON.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- There is one canonical set of output schema string constants in `schema`.
|
|
||||||
- `merge` and `normalize` still default to `seriatim-intermediate` and still
|
|
||||||
honor `SERIATIM_OUTPUT_SCHEMA`.
|
|
||||||
- `trim` still preserves the input artifact schema when `--output-schema` is
|
|
||||||
omitted.
|
|
||||||
- Invalid schema error text remains stable unless tests are intentionally
|
|
||||||
updated.
|
|
||||||
- No new schema registry or plugin-style abstraction exists.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./schema ./internal/config ./internal/artifact ./internal/trim ./internal/normalize ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 2: Extract Trim Projection Policy
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Use one implementation for trim selector policy, ID validation, retained-index
|
|
||||||
selection, ID renumbering metadata, removed IDs, and empty-output handling.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Add a private projection helper in `internal/trim/apply.go`. Do not introduce a
|
|
||||||
generic artifact framework.
|
|
||||||
|
|
||||||
Recommended shape:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type projection struct {
|
|
||||||
retainedIndexes []int
|
|
||||||
oldToNewID map[int]int
|
|
||||||
removedIDs []int
|
|
||||||
}
|
|
||||||
|
|
||||||
func projectSegmentIDs(ids []int, opts Options) (projection, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper should:
|
|
||||||
|
|
||||||
- validate `Mode`;
|
|
||||||
- reject an empty selector;
|
|
||||||
- validate input IDs are positive, unique, and sequential from `1..n`;
|
|
||||||
- validate selected IDs exist;
|
|
||||||
- apply keep/remove semantics;
|
|
||||||
- return retained input indexes in original order;
|
|
||||||
- return deterministic old-to-new ID mappings;
|
|
||||||
- return removed input IDs in original order;
|
|
||||||
- enforce `AllowEmpty`.
|
|
||||||
|
|
||||||
Keep schema-specific segment reconstruction in `Apply`, `ApplyIntermediate`,
|
|
||||||
and `ApplyMinimal`. Keep full-schema overlap group recomputation only in the
|
|
||||||
full-schema path.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/trim/apply.go`
|
|
||||||
- `internal/trim/apply_test.go`
|
|
||||||
- `internal/cli/trim_test.go`
|
|
||||||
- `schema/output.go`
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Add the private projection helper and small supporting types.
|
|
||||||
2. Refactor `Apply` to call the helper, reconstruct full segments from retained
|
|
||||||
indexes, renumber IDs, clear old overlap group IDs, and recompute overlap
|
|
||||||
groups.
|
|
||||||
3. Refactor `ApplyIntermediate` to call the helper and reconstruct intermediate
|
|
||||||
segments.
|
|
||||||
4. Refactor `ApplyMinimal` to call the helper and reconstruct minimal segments.
|
|
||||||
5. Preserve existing error messages from `validateMode`, empty selectors,
|
|
||||||
invalid input IDs, missing selected IDs, and empty output.
|
|
||||||
6. Add a focused table test proving the same selector policy is applied to all
|
|
||||||
three schema shapes.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Keep/remove behavior is unchanged for all supported artifact schemas.
|
|
||||||
- Segment IDs are still reassigned sequentially from `1`.
|
|
||||||
- Full-schema trim still recomputes overlap groups.
|
|
||||||
- Intermediate and minimal trim still do not create overlap groups.
|
|
||||||
- The common selector and ID policy exists in one helper.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/trim ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 3: Move Trim Orchestration Into `internal/trim`
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Make `trim` match the repository's adapter boundary: CLI parses flags and
|
|
||||||
delegates; artifact-level orchestration lives in `internal/trim`.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Add `Run(ctx context.Context, cfg config.TrimConfig) error` to `internal/trim`.
|
|
||||||
|
|
||||||
Move from `internal/cli/trim.go` into `internal/trim`:
|
|
||||||
|
|
||||||
- selector parsing from validated config;
|
|
||||||
- input file read;
|
|
||||||
- artifact parse;
|
|
||||||
- `ApplyArtifact`;
|
|
||||||
- output schema selection and conversion;
|
|
||||||
- output validation;
|
|
||||||
- transcript output JSON write;
|
|
||||||
- trim audit payload construction;
|
|
||||||
- report event construction;
|
|
||||||
- old-to-new ID mapping ordering.
|
|
||||||
|
|
||||||
Keep in `internal/cli/trim.go`:
|
|
||||||
|
|
||||||
- Cobra command definition;
|
|
||||||
- flag registration;
|
|
||||||
- `cmd.Flags().Changed("output-schema")` handling;
|
|
||||||
- `config.NewTrimConfig`;
|
|
||||||
- delegation to `trim.Run`.
|
|
||||||
|
|
||||||
Temporarily keep any moved JSON writer local to `internal/trim` if Stage 4 has
|
|
||||||
not yet been implemented. Stage 4 will centralize file writing.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/cli/trim.go`
|
|
||||||
- `internal/cli/trim_test.go`
|
|
||||||
- `internal/trim/*.go`
|
|
||||||
- `internal/trim/*_test.go`
|
|
||||||
- `internal/config/config.go`
|
|
||||||
- `internal/report/report.go`
|
|
||||||
- `internal/normalize/normalize.go`
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Create `internal/trim/run.go` or another appropriately named file.
|
|
||||||
2. Move trim audit structs to `internal/trim`. Keep JSON field names unchanged.
|
|
||||||
3. Add `Run(ctx, cfg)` and check `ctx.Err()` before doing work.
|
|
||||||
4. Move helper functions needed only by trim orchestration, including ordered
|
|
||||||
ID mapping.
|
|
||||||
5. Update `internal/cli/trim.go` to delegate to `trim.Run`.
|
|
||||||
6. Add direct `internal/trim` tests for service-level behavior if existing CLI
|
|
||||||
tests do not cover moved report/output behavior clearly.
|
|
||||||
7. Keep all existing trim CLI tests passing.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- `internal/cli/trim.go` contains flag wiring, config construction, and a call
|
|
||||||
to `trim.Run`; it no longer performs artifact I/O or report construction.
|
|
||||||
- Trim output files and report files are unchanged for existing tests.
|
|
||||||
- Trim error wrapping is not weakened for user-facing file, parse, validation,
|
|
||||||
or report-write failures.
|
|
||||||
- No merge pipeline modules are invoked by trim.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/trim ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 4: Centralize Deterministic JSON File Writing
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Remove repeated `os.Create` plus indented `json.Encoder` boilerplate while
|
|
||||||
preserving current JSON formatting.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Create a small package named `internal/jsonfile`.
|
|
||||||
|
|
||||||
Recommended API:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package jsonfile
|
|
||||||
|
|
||||||
func Write(path string, value any) error
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper should:
|
|
||||||
|
|
||||||
- create or truncate the target path with `os.Create`;
|
|
||||||
- encode JSON with `encoder.SetIndent("", " ")`;
|
|
||||||
- preserve the current trailing newline produced by `Encoder.Encode`;
|
|
||||||
- return underlying create/encode/close errors with enough context for callers
|
|
||||||
to wrap where they already wrap.
|
|
||||||
|
|
||||||
Do not implement atomic writes, temporary files, directory creation, lock files,
|
|
||||||
or fsync behavior in this stage.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/builtin/output.go`
|
|
||||||
- `internal/report/report.go`
|
|
||||||
- `internal/normalize/normalize.go`
|
|
||||||
- `internal/trim/run.go` or `internal/cli/trim.go`, depending on Stage 3 state
|
|
||||||
- Existing CLI tests that read output JSON
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Add `internal/jsonfile/jsonfile.go`.
|
|
||||||
2. Add a focused `internal/jsonfile/jsonfile_test.go` covering two-space
|
|
||||||
indentation and valid JSON.
|
|
||||||
3. Update merge JSON output writer to use `jsonfile.Write`.
|
|
||||||
4. Update normalize output writing to use `jsonfile.Write`, preserving
|
|
||||||
user-facing error context.
|
|
||||||
5. Update trim output writing to use `jsonfile.Write`.
|
|
||||||
6. Update `report.WriteJSON` to use `jsonfile.Write`.
|
|
||||||
7. Remove duplicated local `writeOutputJSON` helpers.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Merge, trim, normalize, and report JSON are still pretty-printed with two
|
|
||||||
spaces and a trailing newline.
|
|
||||||
- There is one implementation of deterministic JSON file writing.
|
|
||||||
- Report construction remains in `internal/report` or command packages; only
|
|
||||||
file-writing mechanics are shared.
|
|
||||||
- No new persistence semantics are introduced.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/jsonfile ./internal/builtin ./internal/report ./internal/normalize ./internal/trim ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 5: Clean Up Config Path Validation
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Reduce repeated path validation in single-input command config without changing
|
|
||||||
config precedence or errors.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Add private helpers in `internal/config/config.go` only. Do not create a config
|
|
||||||
builder framework.
|
|
||||||
|
|
||||||
Recommended helpers:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func normalizeSingleInputFile(path string, flag string) (string, error)
|
|
||||||
func normalizeOptionalOutputPath(path string, flag string) (string, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
`normalizeSingleInputFile` should preserve the current `--input-file is
|
|
||||||
required` and `requireFile` behavior.
|
|
||||||
|
|
||||||
`normalizeOptionalOutputPath` should return `""` for empty or whitespace-only
|
|
||||||
values and otherwise call the existing required output path validation.
|
|
||||||
|
|
||||||
Keep `normalizeInputFiles` separate for merge because merge accepts repeated
|
|
||||||
input files, rejects duplicates, and sorts paths.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/config/config.go`
|
|
||||||
- `internal/config/config_test.go`
|
|
||||||
- `internal/cli/trim_test.go`
|
|
||||||
- `internal/cli/normalize_test.go`
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Add private path helpers.
|
|
||||||
2. Refactor `NewTrimConfig` to use them.
|
|
||||||
3. Refactor `NewNormalizeConfig` to use them.
|
|
||||||
4. Keep `NewMergeConfig` behavior unchanged except where it can reuse existing
|
|
||||||
`normalizeOutputPath`.
|
|
||||||
5. Preserve all current error message substrings tested by config and CLI
|
|
||||||
tests.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Trim and normalize single-input path normalization is implemented once.
|
|
||||||
- Optional report path handling is implemented once.
|
|
||||||
- Merge multi-input normalization remains explicit and unchanged.
|
|
||||||
- No config struct fields, env vars, flags, defaults, or precedence rules
|
|
||||||
change.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/config ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 6: Add Narrow CLI Flag Helpers
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Reduce typo-prone repeated Cobra flag wiring while keeping command files
|
|
||||||
explicit and readable.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Add helper functions in `internal/cli/flags.go`. Helpers should take a
|
|
||||||
`*cobra.Command` and a target pointer. This avoids adding a direct import of
|
|
||||||
`pflag` unless it becomes clearly cleaner.
|
|
||||||
|
|
||||||
Recommended helpers:
|
|
||||||
|
|
||||||
- `addOutputFileFlag(cmd *cobra.Command, target *string)`
|
|
||||||
- `addReportFileFlag(cmd *cobra.Command, target *string)`
|
|
||||||
- `addOutputModulesFlag(cmd *cobra.Command, target *string)`
|
|
||||||
- `addMergeOutputSchemaFlag(cmd *cobra.Command, target *string)`
|
|
||||||
- `addNormalizeOutputSchemaFlag(cmd *cobra.Command, target *string)`
|
|
||||||
- `addTrimOutputSchemaFlag(cmd *cobra.Command, target *string)`
|
|
||||||
|
|
||||||
Do not create one generic output schema helper because trim has different
|
|
||||||
omitted-flag semantics and help text.
|
|
||||||
|
|
||||||
For `--input-file`, prefer keeping command-local definitions unless a helper
|
|
||||||
improves clarity:
|
|
||||||
|
|
||||||
- merge uses `StringArrayVar`;
|
|
||||||
- trim and normalize use `StringVar`;
|
|
||||||
- usage text differs.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/cli/merge.go`
|
|
||||||
- `internal/cli/trim.go`
|
|
||||||
- `internal/cli/normalize.go`
|
|
||||||
- `internal/cli/*_test.go`
|
|
||||||
- `docs/cli.md`, only to verify flag text if behavior or help text changes
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Add `internal/cli/flags.go`.
|
|
||||||
2. Move only identical or intentionally paired flag definitions into helpers.
|
|
||||||
3. Keep command-specific flags and semantic differences local.
|
|
||||||
4. Run command help manually and compare the important flag names/defaults.
|
|
||||||
5. Update tests only if they assert help text that intentionally changed.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Public flag names and defaults are unchanged.
|
|
||||||
- Help text remains at least as accurate as before.
|
|
||||||
- Command files remain easy to read.
|
|
||||||
- No command factory or shared command runner is introduced.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/cli
|
|
||||||
go run ./cmd/seriatim --help
|
|
||||||
go run ./cmd/seriatim merge --help
|
|
||||||
go run ./cmd/seriatim trim --help
|
|
||||||
go run ./cmd/seriatim normalize --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 7: Centralize Common Segment Reference Formatting
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Use one helper for the shared "best available segment reference" policy used by
|
|
||||||
overlap and coalesce logic.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Add a small helper in `internal/model`, because the helper operates on
|
|
||||||
`model.Segment` and expresses domain reference semantics.
|
|
||||||
|
|
||||||
Recommended API:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func SegmentReference(segment Segment) string
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper should:
|
|
||||||
|
|
||||||
- return `fmt.Sprintf("%s#%d", segment.Source, *segment.SourceSegmentIndex)`
|
|
||||||
when `Source` is non-empty and `SourceSegmentIndex` is non-nil;
|
|
||||||
- otherwise return `segment.SourceRef` when non-empty;
|
|
||||||
- otherwise return `""`.
|
|
||||||
|
|
||||||
Use this helper only where the existing code already has this exact fallback
|
|
||||||
policy. Keep generated references such as `word-run:%d:%d:%d`, `coalesce:%d`,
|
|
||||||
and `resolve-danglers:%d` local to the modules that create them.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/model/model.go`
|
|
||||||
- `internal/overlap/detect.go`
|
|
||||||
- `internal/coalesce/coalesce.go`
|
|
||||||
- `internal/overlap/*_test.go`
|
|
||||||
- `internal/coalesce/*_test.go`
|
|
||||||
- `internal/cli/merge_test.go`
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Add `SegmentReference` and tests in `internal/model`.
|
|
||||||
2. Replace duplicated fallback logic in overlap detection.
|
|
||||||
3. Replace duplicated fallback logic in coalesce.
|
|
||||||
4. Do not change generated reference prefixes.
|
|
||||||
5. Do not change sorting behavior or derived-from ordering.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Existing provenance strings in full output remain unchanged.
|
|
||||||
- `source#index` formatting exists in one shared helper for matching semantics.
|
|
||||||
- Module-specific generated references stay module-local.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/model ./internal/overlap ./internal/coalesce ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 8: Reduce Repetition In Schema Semantic Validation
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Use one private helper for cross-schema segment ID and timing invariants.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Keep this helper private to `schema`. Do not use generics unless the resulting
|
|
||||||
code is clearly simpler than a small projection type.
|
|
||||||
|
|
||||||
Recommended shape:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type segmentSemantics struct {
|
|
||||||
id int
|
|
||||||
start float64
|
|
||||||
end float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSegmentSemantics(segments []segmentSemantics) error
|
|
||||||
```
|
|
||||||
|
|
||||||
Preserve current error text:
|
|
||||||
|
|
||||||
- `segment %d has id %d; want %d`
|
|
||||||
- `segment %d has end %.3f before start %.3f`
|
|
||||||
|
|
||||||
Keep full-schema overlap group timing validation separate.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `schema/output.go`
|
|
||||||
- `schema/output_test.go`
|
|
||||||
- callers in `internal/artifact`, `internal/trim`, and `internal/normalize`
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Add the private segment semantic helper.
|
|
||||||
2. Adapt full, intermediate, and minimal validation functions to project their
|
|
||||||
segment shapes into the helper.
|
|
||||||
3. Keep full overlap group semantic validation in the full validation path.
|
|
||||||
4. Add or preserve tests that cover invalid IDs and invalid timing for each
|
|
||||||
public schema shape.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Sequential ID and timing rules are implemented once.
|
|
||||||
- Public schema validation behavior and error wording remain stable.
|
|
||||||
- Full overlap group validation remains full-schema only.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./schema ./internal/artifact ./internal/trim ./internal/normalize ./internal/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt.
|
|
||||||
|
|
||||||
## Stage 9: Test Helper Cleanup
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Reduce repeated test setup after behavior-preserving refactors are complete.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
Keep test helpers package-local. Do not add exported test utility packages.
|
|
||||||
|
|
||||||
Prioritize helpers that reduce noise without hiding public command behavior:
|
|
||||||
|
|
||||||
- config option builders in `internal/config/config_test.go`;
|
|
||||||
- command execution helpers in `internal/cli` if duplication remains after
|
|
||||||
trim orchestration and flag helper cleanup;
|
|
||||||
- fixture builders for trim schema shapes if they stay repetitive.
|
|
||||||
|
|
||||||
Do not abstract command arguments so far that tests no longer show the public
|
|
||||||
CLI contract being exercised.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- `internal/config/config_test.go`
|
|
||||||
- `internal/cli/merge_test.go`
|
|
||||||
- `internal/cli/trim_test.go`
|
|
||||||
- `internal/cli/normalize_test.go`
|
|
||||||
- `internal/trim/*_test.go`
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Identify repeated setup that survived prior stages.
|
|
||||||
2. Add package-local helper builders for config options with valid defaults.
|
|
||||||
3. Consolidate duplicate read/write JSON helpers only inside the test package
|
|
||||||
where they are used.
|
|
||||||
4. Keep high-signal command arguments inline in CLI tests.
|
|
||||||
5. Avoid golden-file rewrites or fixture churn unrelated to cleanup.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- Tests are shorter where setup was noisy.
|
|
||||||
- Public behavior being tested remains obvious.
|
|
||||||
- No production code changes are made in this stage unless a test-only cleanup
|
|
||||||
reveals dead production code from earlier stages.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/config ./internal/cli ./internal/trim
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
One implementation prompt if limited to config/CLI/trim tests. Split by package
|
|
||||||
if the diff becomes large.
|
|
||||||
|
|
||||||
## Stage 10: Final Dead-Code And Documentation Sweep
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Remove stale helpers and update internal documentation only where implemented
|
|
||||||
cleanup changed current boundaries.
|
|
||||||
|
|
||||||
### Decision
|
|
||||||
|
|
||||||
This is a cleanup verification stage, not a feature stage.
|
|
||||||
|
|
||||||
Do not delete `samples/`, move examples, redesign docs, or alter user-facing
|
|
||||||
references unless directly required by implemented cleanup.
|
|
||||||
|
|
||||||
### Files To Inspect
|
|
||||||
|
|
||||||
- All files touched by Stages 1-9
|
|
||||||
- `docs/policy/architecture.md`
|
|
||||||
- `docs/policy/development.md`
|
|
||||||
- `docs/internal/artifacts.md`
|
|
||||||
- `docs/internal/pipeline.md`
|
|
||||||
- `docs/internal/modules.md`
|
|
||||||
- `docs/cli.md`
|
|
||||||
- `docs/config.md`
|
|
||||||
|
|
||||||
### Implementation Steps
|
|
||||||
|
|
||||||
1. Search for obsolete helpers, duplicate schema constants, duplicate JSON
|
|
||||||
writers, old trim CLI orchestration helpers, and unused imports.
|
|
||||||
2. Run `go test ./...`.
|
|
||||||
3. Run command help checks.
|
|
||||||
4. Update internal docs only if package responsibility changed in a way current
|
|
||||||
docs now describe inaccurately.
|
|
||||||
5. Do not add planned cleanup notes outside `docs/roadmap/`.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
- No duplicate output schema string constants remain outside canonical aliases
|
|
||||||
and intentional docs/test fixtures.
|
|
||||||
- No duplicate production `writeOutputJSON` helper remains.
|
|
||||||
- `internal/cli/trim.go` is a thin adapter.
|
|
||||||
- Docs outside `docs/roadmap/` describe only implemented behavior.
|
|
||||||
- Full repository tests pass.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
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
|
|
||||||
rg -n "SchemaMinimal|SchemaIntermediate|SchemaFull|writeOutputJSON|seriatim-minimal|seriatim-intermediate|seriatim-full" internal schema
|
|
||||||
```
|
|
||||||
|
|
||||||
The `rg` command is a review aid. Raw schema strings may still be appropriate in
|
|
||||||
canonical constants, docs, JSON Schema files, and tests that assert serialized
|
|
||||||
public contracts.
|
|
||||||
|
|
||||||
### Prompt Size
|
|
||||||
|
|
||||||
Small enough for one implementation prompt after prior stages are complete.
|
|
||||||
|
|
||||||
## Deferred Or Explicitly Avoided Work
|
|
||||||
|
|
||||||
Do not implement these as part of the pre-1.0 cleanup sequence unless a later
|
|
||||||
audit identifies a concrete bug or larger duplication pattern:
|
|
||||||
|
|
||||||
- A generic workflow engine for merge, trim, and normalize.
|
|
||||||
- Dynamic plugins or runtime-extensible schemas.
|
|
||||||
- A manifest, checkpoint, resume, dry-run, force, or progress framework.
|
|
||||||
- Atomic output writes or fsync semantics.
|
|
||||||
- A broad filesystem adapter layer.
|
|
||||||
- A combined backchannel/filler classifier framework.
|
|
||||||
- A generic registry resolver that erases stage-specific error messages.
|
|
||||||
- A broad public API for internals.
|
|
||||||
- Destructive changes to `samples/`.
|
|
||||||
|
|
||||||
Backchannel and filler share mechanics, but the cleanup decision is to defer
|
|
||||||
shared category-tagging extraction before 1.0. The current duplication is small,
|
|
||||||
domain-specific, and well isolated.
|
|
||||||
|
|
||||||
## Full Cleanup Validation
|
|
||||||
|
|
||||||
After all implemented stages:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Manual review checklist:
|
|
||||||
|
|
||||||
- Public CLI behavior is unchanged.
|
|
||||||
- Public output schemas and JSON shapes are unchanged.
|
|
||||||
- Report event ordering and text remain stable unless tests were deliberately
|
|
||||||
updated.
|
|
||||||
- Trim, normalize, and merge boundaries match `docs/policy/architecture.md`.
|
|
||||||
- No planned or aspirational behavior was added to non-roadmap docs.
|
|
||||||
- Cleanup reduced duplicated policy without introducing broad abstractions.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
No blocking questions are required to implement this plan.
|
|
||||||
|
|
||||||
If a future implementation stage discovers that preserving current behavior
|
|
||||||
conflicts with one of the structural decisions above, stop that stage and ask
|
|
||||||
for direction before changing public CLI behavior, public JSON contracts, or
|
|
||||||
report semantics.
|
|
||||||
446
docs/roadmap/render.md
Normal file
446
docs/roadmap/render.md
Normal 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.
|
||||||
@@ -4,30 +4,31 @@ Each entry includes symptom, likely cause, inspection step, and safe fix.
|
|||||||
|
|
||||||
## Missing required flags
|
## 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`.
|
- 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: required command flags were omitted.
|
- Likely cause: one or more required flags were omitted.
|
||||||
- Inspection: run command help for the failing command:
|
- Inspection: run help for the failing command:
|
||||||
- `go run ./cmd/seriatim merge --help`
|
- `go run ./cmd/seriatim merge --help`
|
||||||
- `go run ./cmd/seriatim trim --help`
|
- `go run ./cmd/seriatim trim --help`
|
||||||
- `go run ./cmd/seriatim normalize --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`).
|
- Safe fix: provide all required flags; for `trim`, provide exactly one selector mode (`--keep` or `--remove`).
|
||||||
|
|
||||||
## Invalid output or report path
|
## Invalid output or report path
|
||||||
|
|
||||||
- Symptom: errors like `--output-file parent directory ...` or `--report-file parent directory ...`.
|
- 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.
|
- Likely cause: parent directory does not exist, is not a directory, or the target path is unusable.
|
||||||
- Inspection: verify paths:
|
- Inspection: verify parent path and permissions:
|
||||||
- `dirname <path>`
|
- `dirname <path>`
|
||||||
- `ls -ld <parent-dir>`
|
- `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
|
## 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`.
|
- 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.
|
- 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>`
|
- `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
|
## 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)
|
- `jq 'keys' <input-file>` (for object input)
|
||||||
- Safe fix: reshape input into one supported form and rerun `normalize`.
|
- 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
|
## 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.
|
- 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:
|
- Inspection: check YAML validity and required top-level keys:
|
||||||
- `speakers.yml` requires top-level `match` rules.
|
- `speakers.yml` requires top-level `match` rules
|
||||||
- `autocorrect.yml` requires top-level `autocorrect` rules.
|
- `autocorrect.yml` requires top-level `autocorrect` rules
|
||||||
- Safe fix: correct YAML structure and rule content, then rerun `merge`.
|
- Safe fix: correct YAML structure and rule content, then rerun `merge`.
|
||||||
|
|
||||||
## Unknown module names
|
## 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.
|
- 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.
|
- 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 ...`.
|
- Symptom:
|
||||||
- Likely cause: unsupported schema value from flag or `SERIATIM_OUTPUT_SCHEMA`.
|
- render: `--format must be "markdown"`
|
||||||
- Inspection: check effective value:
|
- merge/normalize/trim: `--output-schema must be one of ...`
|
||||||
|
- Likely cause: unsupported `--format` or `--output-schema` value.
|
||||||
|
- Inspection:
|
||||||
- command flags
|
- command flags
|
||||||
- `echo "$SERIATIM_OUTPUT_SCHEMA"`
|
- `echo "$SERIATIM_OUTPUT_SCHEMA"` (for merge/normalize defaults)
|
||||||
- Safe fix: use one of `seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`.
|
- Safe fix:
|
||||||
|
- render: use `--format markdown`
|
||||||
|
- output schema: use `seriatim-minimal`, `seriatim-intermediate`, or `seriatim-full`
|
||||||
|
|
||||||
## Invalid trim selector
|
## Invalid trim selector
|
||||||
|
|
||||||
@@ -73,23 +87,23 @@ Each entry includes symptom, likely cause, inspection step, and safe fix.
|
|||||||
- list: `1-10,15,20-25`
|
- list: `1-10,15,20-25`
|
||||||
- Safe fix: correct selector syntax and rerun `trim`.
|
- 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:
|
- Likely cause:
|
||||||
- merge module order/config produced invalid final artifact (for example, validating before IDs are assigned), or
|
- merge module order/config produced an invalid output artifact, or
|
||||||
- trim input is not a valid seriatim artifact.
|
- trim/render input is not a valid seriatim output artifact.
|
||||||
- Inspection:
|
- Inspection:
|
||||||
- for merge: inspect customized module ordering flags.
|
- for merge: inspect customized module ordering flags
|
||||||
- for trim: verify input artifact against known seriatim schema files in `schema/`.
|
- for trim/render: validate input against schema files in `schema/`
|
||||||
- Safe fix:
|
- Safe fix:
|
||||||
- restore valid merge postprocessing order ending with assigned IDs before validation, or
|
- merge: restore a valid postprocessing order ending with assigned IDs before output validation
|
||||||
- provide a valid seriatim artifact as trim input.
|
- trim/render: provide a valid seriatim artifact as input
|
||||||
|
|
||||||
## Report write failure
|
## Report write failure
|
||||||
|
|
||||||
- Symptom: errors like `write --report-file ...` or file-create failures when report writing is requested.
|
- 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:
|
- Inspection:
|
||||||
- `ls -ld <report-parent-dir>`
|
- `ls -ld <report-parent-dir>`
|
||||||
- verify `--report-file` is a file path, not a directory
|
- verify `--report-file` is a file path, not a directory
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Examples
|
# Examples
|
||||||
|
|
||||||
These are small synthetic, copyable example assets for the implemented CLI
|
These are small synthetic, copyable example assets for the implemented CLI
|
||||||
commands.
|
commands. This directory is the canonical examples home for documentation.
|
||||||
This directory is the canonical examples home for documentation.
|
|
||||||
|
|
||||||
## Merge example
|
## Merge example
|
||||||
|
|
||||||
@@ -55,6 +54,25 @@ go run ./cmd/seriatim trim \
|
|||||||
--keep "1-2"
|
--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
|
## YAML rule examples
|
||||||
|
|
||||||
- `speakers.yml`
|
- `speakers.yml`
|
||||||
|
|||||||
33
examples/render/input-intermediate.json
Normal file
33
examples/render/input-intermediate.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
examples/render/output-markdown.md
Normal file
7
examples/render/output-markdown.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Transcript
|
||||||
|
|
||||||
|
[00:00:01–00:00:04] **Eric Rakestraw:** Hello there.
|
||||||
|
|
||||||
|
[00:00:05–00:00:08] **Mike Brown:** Welcome back, everyone.
|
||||||
|
|
||||||
|
[00:00:09–00:00:10] **Eric Rakestraw:** *Yeah.*
|
||||||
178
internal/artifact/output_artifact.go
Normal file
178
internal/artifact/output_artifact.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package artifact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutputSchemaMinimal = schema.OutputSchemaMinimal
|
||||||
|
OutputSchemaIntermediate = schema.OutputSchemaIntermediate
|
||||||
|
OutputSchemaFull = schema.OutputSchemaFull
|
||||||
|
)
|
||||||
|
|
||||||
|
// OutputArtifact stores a parsed seriatim output artifact of one supported schema.
|
||||||
|
type OutputArtifact struct {
|
||||||
|
Schema string
|
||||||
|
Full *schema.Transcript
|
||||||
|
Intermediate *schema.IntermediateTranscript
|
||||||
|
Minimal *schema.MinimalTranscript
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseOutputArtifactJSON parses and validates serialized seriatim output JSON.
|
||||||
|
func ParseOutputArtifactJSON(data []byte) (OutputArtifact, error) {
|
||||||
|
var decoded any
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
return OutputArtifact{}, fmt.Errorf("input JSON is malformed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var full schema.Transcript
|
||||||
|
if err := json.Unmarshal(data, &full); err == nil {
|
||||||
|
if err := schema.ValidateTranscript(full); err == nil {
|
||||||
|
return OutputArtifact{
|
||||||
|
Schema: OutputSchemaFull,
|
||||||
|
Full: &full,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var intermediate schema.IntermediateTranscript
|
||||||
|
if err := json.Unmarshal(data, &intermediate); err == nil {
|
||||||
|
if err := schema.ValidateIntermediateTranscript(intermediate); err == nil {
|
||||||
|
return OutputArtifact{
|
||||||
|
Schema: OutputSchemaIntermediate,
|
||||||
|
Intermediate: &intermediate,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var minimal schema.MinimalTranscript
|
||||||
|
if err := json.Unmarshal(data, &minimal); err == nil {
|
||||||
|
if err := schema.ValidateMinimalTranscript(minimal); err == nil {
|
||||||
|
return OutputArtifact{
|
||||||
|
Schema: OutputSchemaMinimal,
|
||||||
|
Minimal: &minimal,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OutputArtifact{}, fmt.Errorf("input JSON is not a valid seriatim output artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value returns the output payload value for serialization.
|
||||||
|
func (artifact OutputArtifact) Value() any {
|
||||||
|
switch artifact.Schema {
|
||||||
|
case OutputSchemaFull:
|
||||||
|
if artifact.Full == nil {
|
||||||
|
return schema.Transcript{}
|
||||||
|
}
|
||||||
|
return *artifact.Full
|
||||||
|
case OutputSchemaIntermediate:
|
||||||
|
if artifact.Intermediate == nil {
|
||||||
|
return schema.IntermediateTranscript{}
|
||||||
|
}
|
||||||
|
return *artifact.Intermediate
|
||||||
|
case OutputSchemaMinimal:
|
||||||
|
if artifact.Minimal == nil {
|
||||||
|
return schema.MinimalTranscript{}
|
||||||
|
}
|
||||||
|
return *artifact.Minimal
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SegmentCount returns the number of segments in the output artifact.
|
||||||
|
func (artifact OutputArtifact) SegmentCount() int {
|
||||||
|
switch artifact.Schema {
|
||||||
|
case OutputSchemaFull:
|
||||||
|
if artifact.Full == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(artifact.Full.Segments)
|
||||||
|
case OutputSchemaIntermediate:
|
||||||
|
if artifact.Intermediate == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(artifact.Intermediate.Segments)
|
||||||
|
case OutputSchemaMinimal:
|
||||||
|
if artifact.Minimal == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(artifact.Minimal.Segments)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application returns output artifact metadata application name.
|
||||||
|
func (artifact OutputArtifact) Application() string {
|
||||||
|
switch artifact.Schema {
|
||||||
|
case OutputSchemaFull:
|
||||||
|
if artifact.Full == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return artifact.Full.Metadata.Application
|
||||||
|
case OutputSchemaIntermediate:
|
||||||
|
if artifact.Intermediate == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return artifact.Intermediate.Metadata.Application
|
||||||
|
case OutputSchemaMinimal:
|
||||||
|
if artifact.Minimal == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return artifact.Minimal.Metadata.Application
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version returns output artifact metadata version.
|
||||||
|
func (artifact OutputArtifact) Version() string {
|
||||||
|
switch artifact.Schema {
|
||||||
|
case OutputSchemaFull:
|
||||||
|
if artifact.Full == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return artifact.Full.Metadata.Version
|
||||||
|
case OutputSchemaIntermediate:
|
||||||
|
if artifact.Intermediate == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return artifact.Intermediate.Metadata.Version
|
||||||
|
case OutputSchemaMinimal:
|
||||||
|
if artifact.Minimal == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return artifact.Minimal.Metadata.Version
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullPayload returns the full-schema payload when present.
|
||||||
|
func (artifact OutputArtifact) FullPayload() (*schema.Transcript, error) {
|
||||||
|
if artifact.Full == nil {
|
||||||
|
return nil, fmt.Errorf("full artifact payload is missing")
|
||||||
|
}
|
||||||
|
return artifact.Full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntermediatePayload returns the intermediate-schema payload when present.
|
||||||
|
func (artifact OutputArtifact) IntermediatePayload() (*schema.IntermediateTranscript, error) {
|
||||||
|
if artifact.Intermediate == nil {
|
||||||
|
return nil, fmt.Errorf("intermediate artifact payload is missing")
|
||||||
|
}
|
||||||
|
return artifact.Intermediate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinimalPayload returns the minimal-schema payload when present.
|
||||||
|
func (artifact OutputArtifact) MinimalPayload() (*schema.MinimalTranscript, error) {
|
||||||
|
if artifact.Minimal == nil {
|
||||||
|
return nil, fmt.Errorf("minimal artifact payload is missing")
|
||||||
|
}
|
||||||
|
return artifact.Minimal, nil
|
||||||
|
}
|
||||||
134
internal/artifact/output_artifact_test.go
Normal file
134
internal/artifact/output_artifact_test.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package artifact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseOutputArtifactJSONParsesFullIntermediateAndMinimal(t *testing.T) {
|
||||||
|
t.Run("full", func(t *testing.T) {
|
||||||
|
first := 0
|
||||||
|
value := schema.Transcript{
|
||||||
|
Metadata: schema.Metadata{
|
||||||
|
Application: "seriatim",
|
||||||
|
Version: "v-test",
|
||||||
|
InputReader: "json-files",
|
||||||
|
InputFiles: []string{"input.json"},
|
||||||
|
PreprocessingModules: []string{"validate-raw"},
|
||||||
|
PostprocessingModules: []string{"assign-ids", "validate-output"},
|
||||||
|
OutputModules: []string{"json"},
|
||||||
|
},
|
||||||
|
Segments: []schema.Segment{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Source: "input.json",
|
||||||
|
SourceSegmentIndex: &first,
|
||||||
|
Speaker: "Alice",
|
||||||
|
Start: 1,
|
||||||
|
End: 2,
|
||||||
|
Text: "hello",
|
||||||
|
Categories: []string{"backchannel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OverlapGroups: []schema.OverlapGroup{},
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := mustParseOutputArtifact(t, value)
|
||||||
|
if parsed.Schema != OutputSchemaFull {
|
||||||
|
t.Fatalf("schema = %q, want %q", parsed.Schema, OutputSchemaFull)
|
||||||
|
}
|
||||||
|
if parsed.Full == nil {
|
||||||
|
t.Fatal("expected full payload")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("intermediate", func(t *testing.T) {
|
||||||
|
value := schema.IntermediateTranscript{
|
||||||
|
Metadata: schema.IntermediateMetadata{
|
||||||
|
Application: "seriatim",
|
||||||
|
Version: "v-test",
|
||||||
|
OutputSchema: OutputSchemaIntermediate,
|
||||||
|
},
|
||||||
|
Segments: []schema.IntermediateSegment{
|
||||||
|
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "hello", Categories: []string{"filler"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := mustParseOutputArtifact(t, value)
|
||||||
|
if parsed.Schema != OutputSchemaIntermediate {
|
||||||
|
t.Fatalf("schema = %q, want %q", parsed.Schema, OutputSchemaIntermediate)
|
||||||
|
}
|
||||||
|
if parsed.Intermediate == nil {
|
||||||
|
t.Fatal("expected intermediate payload")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("minimal", func(t *testing.T) {
|
||||||
|
value := schema.MinimalTranscript{
|
||||||
|
Metadata: schema.MinimalMetadata{
|
||||||
|
Application: "seriatim",
|
||||||
|
Version: "v-test",
|
||||||
|
OutputSchema: OutputSchemaMinimal,
|
||||||
|
},
|
||||||
|
Segments: []schema.MinimalSegment{
|
||||||
|
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := mustParseOutputArtifact(t, value)
|
||||||
|
if parsed.Schema != OutputSchemaMinimal {
|
||||||
|
t.Fatalf("schema = %q, want %q", parsed.Schema, OutputSchemaMinimal)
|
||||||
|
}
|
||||||
|
if parsed.Minimal == nil {
|
||||||
|
t.Fatal("expected minimal payload")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOutputArtifactJSONRejectsMalformedJSON(t *testing.T) {
|
||||||
|
_, err := ParseOutputArtifactJSON([]byte(`{"metadata":`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected malformed JSON error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "input JSON is malformed") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOutputArtifactJSONRejectsRawWhisperXLikeInput(t *testing.T) {
|
||||||
|
data := []byte(`{
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"start": 0.1,
|
||||||
|
"end": 1.2,
|
||||||
|
"text": "hello",
|
||||||
|
"words": [{"word":"hello","start":0.1,"end":0.8}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
_, err := ParseOutputArtifactJSON(data)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected artifact validation error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a valid seriatim output artifact") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseOutputArtifact(t *testing.T, value any) OutputArtifact {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := ParseOutputArtifactJSON(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@ package builtin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/internal/jsonfile"
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/report"
|
"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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Create(cfg.OutputFile)
|
if err := jsonfile.Write(cfg.OutputFile, out); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
enc := json.NewEncoder(file)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
if err := enc.Encode(out); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
internal/cli/flags.go
Normal file
31
internal/cli/flags.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -31,13 +31,13 @@ func newMergeCommand() *cobra.Command {
|
|||||||
|
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
flags.StringArrayVar(&opts.InputFiles, "input-file", nil, "input transcript file; may be repeated")
|
flags.StringArrayVar(&opts.InputFiles, "input-file", nil, "input transcript file; may be repeated")
|
||||||
flags.StringVar(&opts.OutputFile, "output-file", "", "output transcript JSON file")
|
addOutputFileFlag(cmd, &opts.OutputFile)
|
||||||
flags.StringVar(&opts.ReportFile, "report-file", "", "optional report JSON file")
|
addReportFileFlag(cmd, &opts.ReportFile)
|
||||||
flags.StringVar(&opts.SpeakersFile, "speakers", "", "speaker map file")
|
flags.StringVar(&opts.SpeakersFile, "speakers", "", "speaker map file")
|
||||||
flags.StringVar(&opts.AutocorrectFile, "autocorrect", "", "autocorrect rules file")
|
flags.StringVar(&opts.AutocorrectFile, "autocorrect", "", "autocorrect rules file")
|
||||||
flags.StringVar(&opts.InputReader, "input-reader", config.DefaultInputReader, "input reader module")
|
flags.StringVar(&opts.InputReader, "input-reader", config.DefaultInputReader, "input reader module")
|
||||||
flags.StringVar(&opts.OutputModules, "output-modules", config.DefaultOutputModules, "comma-separated output modules")
|
addOutputModulesFlag(cmd, &opts.OutputModules)
|
||||||
flags.StringVar(&opts.OutputSchema, "output-schema", config.DefaultOutputSchema, "output JSON schema: seriatim-minimal, seriatim-intermediate (default), or seriatim-full")
|
addMergeOutputSchemaFlag(cmd, &opts.OutputSchema)
|
||||||
flags.StringVar(&opts.PreprocessingModules, "preprocessing-modules", config.DefaultPreprocessingModules, "comma-separated preprocessing modules")
|
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.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")
|
flags.StringVar(&opts.CoalesceGap, "coalesce-gap", config.DefaultCoalesceGapValue, "maximum same-speaker gap in seconds for coalesce")
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ func newNormalizeCommand() *cobra.Command {
|
|||||||
|
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
flags.StringVar(&opts.InputFile, "input-file", "", "input transcript JSON file")
|
flags.StringVar(&opts.InputFile, "input-file", "", "input transcript JSON file")
|
||||||
flags.StringVar(&opts.OutputFile, "output-file", "", "output transcript JSON file")
|
addOutputFileFlag(cmd, &opts.OutputFile)
|
||||||
flags.StringVar(&opts.ReportFile, "report-file", "", "optional report JSON file")
|
addReportFileFlag(cmd, &opts.ReportFile)
|
||||||
flags.StringVar(&opts.OutputSchema, "output-schema", config.DefaultOutputSchema, "output JSON schema: seriatim-minimal, seriatim-intermediate, or seriatim-full")
|
addNormalizeOutputSchemaFlag(cmd, &opts.OutputSchema)
|
||||||
flags.StringVar(&opts.OutputModules, "output-modules", config.DefaultOutputModules, "comma-separated output modules")
|
addOutputModulesFlag(cmd, &opts.OutputModules)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
39
internal/cli/render.go
Normal file
39
internal/cli/render.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/internal/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRenderCommand() *cobra.Command {
|
||||||
|
opts := config.RenderOptions{
|
||||||
|
Title: config.DefaultRenderTitle,
|
||||||
|
IncludeTimestamps: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "render",
|
||||||
|
Short: "Render a seriatim transcript artifact into human-readable output",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := config.NewRenderConfig(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return render.Run(cmd.Context(), cfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := cmd.Flags()
|
||||||
|
flags.StringVar(&opts.InputFile, "input-file", "", "input seriatim transcript artifact JSON file")
|
||||||
|
flags.StringVar(&opts.OutputFile, "output-file", "", "rendered output file path")
|
||||||
|
flags.StringVar(&opts.Format, "format", "", "output format (markdown)")
|
||||||
|
flags.StringVar(&opts.Title, "title", config.DefaultRenderTitle, "document title")
|
||||||
|
flags.BoolVar(&opts.IncludeTimestamps, "include-timestamps", true, "include segment timestamps")
|
||||||
|
flags.BoolVar(&opts.IncludeSegmentIDs, "include-segment-ids", false, "include segment IDs")
|
||||||
|
flags.BoolVar(&opts.IncludeMetadata, "include-metadata", false, "include artifact metadata")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
274
internal/cli/render_test.go
Normal file
274
internal/cli/render_test.go
Normal 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:01–00:00:04] **Eric:** Hello there.") {
|
||||||
|
t.Fatalf("missing first segment:\n%s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(data, "[00:00:05–00:00:08] **Mike:** *Yeah.*") {
|
||||||
|
t.Fatalf("missing italicized backchannel segment:\n%s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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:01–00: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)
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func NewRootCommand() *cobra.Command {
|
func NewRootCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "seriatim",
|
Use: "seriatim",
|
||||||
Short: "Merge, trim, and normalize transcript artifacts",
|
Short: "Merge, trim, normalize, and render transcript artifacts",
|
||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
@@ -18,6 +18,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
|
|
||||||
cmd.AddCommand(newMergeCommand())
|
cmd.AddCommand(newMergeCommand())
|
||||||
cmd.AddCommand(newNormalizeCommand())
|
cmd.AddCommand(newNormalizeCommand())
|
||||||
|
cmd.AddCommand(newRenderCommand())
|
||||||
cmd.AddCommand(newTrimCommand())
|
cmd.AddCommand(newTrimCommand())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,12 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/report"
|
"gitea.maximumdirect.net/eric/seriatim/internal/trim"
|
||||||
triminternal "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 {
|
func newTrimCommand() *cobra.Command {
|
||||||
var opts config.TrimOptions
|
var opts config.TrimOptions
|
||||||
|
|
||||||
@@ -53,139 +24,18 @@ func newTrimCommand() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
selector, err := triminternal.ParseSelector(cfg.Selector)
|
return trim.Run(cmd.Context(), cfg)
|
||||||
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
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
flags := cmd.Flags()
|
flags := cmd.Flags()
|
||||||
flags.StringVar(&opts.InputFile, "input-file", "", "input seriatim transcript artifact JSON file")
|
flags.StringVar(&opts.InputFile, "input-file", "", "input seriatim transcript artifact JSON file")
|
||||||
flags.StringVar(&opts.OutputFile, "output-file", "", "output transcript JSON file")
|
addOutputFileFlag(cmd, &opts.OutputFile)
|
||||||
flags.StringVar(&opts.ReportFile, "report-file", "", "optional report JSON file")
|
addReportFileFlag(cmd, &opts.ReportFile)
|
||||||
flags.StringVar(&opts.Keep, "keep", "", "segment ID selector to keep (for example: 1-10,15)")
|
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.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")
|
flags.BoolVar(&opts.AllowEmpty, "allow-empty", false, "allow trimming to an empty transcript")
|
||||||
|
|
||||||
return cmd
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/seriatim/schema"
|
"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) {
|
func TestTrimKeepModeEndToEnd(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
input := writeTrimFullFixture(t, dir, "input.json")
|
input := writeTrimFullFixture(t, dir, "input.json")
|
||||||
|
|||||||
@@ -160,13 +160,7 @@ func (r run) coalescedSegment(id int) model.Segment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func segmentRef(segment model.Segment) string {
|
func segmentRef(segment model.Segment) string {
|
||||||
if segment.SourceSegmentIndex != nil {
|
return model.SegmentReference(segment)
|
||||||
return fmt.Sprintf("%s#%d", segment.Source, *segment.SourceSegmentIndex)
|
|
||||||
}
|
|
||||||
if segment.SourceRef != "" {
|
|
||||||
return segment.SourceRef
|
|
||||||
}
|
|
||||||
return segment.Source
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSkippableInterjection(segment model.Segment) bool {
|
func isSkippableInterjection(segment model.Segment) bool {
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultInputReader = "json-files"
|
DefaultInputReader = "json-files"
|
||||||
DefaultOutputModules = "json"
|
DefaultOutputModules = "json"
|
||||||
DefaultOutputSchema = OutputSchemaIntermediate
|
DefaultOutputSchema = OutputSchemaIntermediate
|
||||||
|
DefaultRenderTitle = "Transcript"
|
||||||
|
RenderFormatMarkdown = "markdown"
|
||||||
DefaultPreprocessingModules = "validate-raw,normalize-speakers,trim-text"
|
DefaultPreprocessingModules = "validate-raw,normalize-speakers,trim-text"
|
||||||
DefaultPostprocessingModules = "detect-overlaps,resolve-overlaps,backchannel,filler,resolve-danglers,coalesce,detect-overlaps,autocorrect,assign-ids,validate-output"
|
DefaultPostprocessingModules = "detect-overlaps,resolve-overlaps,backchannel,filler,resolve-danglers,coalesce,detect-overlaps,autocorrect,assign-ids,validate-output"
|
||||||
DefaultOverlapWordRunGap = 1.0
|
DefaultOverlapWordRunGap = 1.0
|
||||||
@@ -27,9 +31,9 @@ const (
|
|||||||
WordRunReorderWindowEnv = "SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"
|
WordRunReorderWindowEnv = "SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"
|
||||||
BackchannelMaxDurationEnv = "SERIATIM_BACKCHANNEL_MAX_DURATION"
|
BackchannelMaxDurationEnv = "SERIATIM_BACKCHANNEL_MAX_DURATION"
|
||||||
FillerMaxDurationEnv = "SERIATIM_FILLER_MAX_DURATION"
|
FillerMaxDurationEnv = "SERIATIM_FILLER_MAX_DURATION"
|
||||||
OutputSchemaMinimal = "seriatim-minimal"
|
OutputSchemaMinimal = schema.OutputSchemaMinimal
|
||||||
OutputSchemaIntermediate = "seriatim-intermediate"
|
OutputSchemaIntermediate = schema.OutputSchemaIntermediate
|
||||||
OutputSchemaFull = "seriatim-full"
|
OutputSchemaFull = schema.OutputSchemaFull
|
||||||
)
|
)
|
||||||
|
|
||||||
// MergeOptions captures raw CLI option values before validation.
|
// MergeOptions captures raw CLI option values before validation.
|
||||||
@@ -67,6 +71,17 @@ type NormalizeOptions struct {
|
|||||||
OutputModules string
|
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.
|
// Config is the validated runtime configuration for a merge invocation.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
InputFiles []string
|
InputFiles []string
|
||||||
@@ -106,6 +121,17 @@ type NormalizeConfig struct {
|
|||||||
OutputModules []string
|
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.
|
// NewMergeConfig validates raw merge options and returns normalized config.
|
||||||
func NewMergeConfig(opts MergeOptions) (Config, error) {
|
func NewMergeConfig(opts MergeOptions) (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
@@ -210,11 +236,8 @@ func NewMergeConfig(opts MergeOptions) (Config, error) {
|
|||||||
|
|
||||||
// NewTrimConfig validates raw trim options and returns normalized config.
|
// NewTrimConfig validates raw trim options and returns normalized config.
|
||||||
func NewTrimConfig(opts TrimOptions) (TrimConfig, error) {
|
func NewTrimConfig(opts TrimOptions) (TrimConfig, error) {
|
||||||
inputFile := filepath.Clean(strings.TrimSpace(opts.InputFile))
|
inputFile, err := normalizeSingleInputFile(opts.InputFile, "--input-file")
|
||||||
if strings.TrimSpace(opts.InputFile) == "" {
|
if err != nil {
|
||||||
return TrimConfig{}, errors.New("--input-file is required")
|
|
||||||
}
|
|
||||||
if err := requireFile(inputFile, "--input-file"); err != nil {
|
|
||||||
return TrimConfig{}, err
|
return TrimConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,13 +246,10 @@ func NewTrimConfig(opts TrimOptions) (TrimConfig, error) {
|
|||||||
return TrimConfig{}, err
|
return TrimConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
reportFile := ""
|
reportFile, err := normalizeOptionalOutputPath(opts.ReportFile, "--report-file")
|
||||||
if strings.TrimSpace(opts.ReportFile) != "" {
|
|
||||||
reportFile, err = normalizeOutputPath(opts.ReportFile, "--report-file")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TrimConfig{}, err
|
return TrimConfig{}, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
keep := strings.TrimSpace(opts.Keep)
|
keep := strings.TrimSpace(opts.Keep)
|
||||||
remove := strings.TrimSpace(opts.Remove)
|
remove := strings.TrimSpace(opts.Remove)
|
||||||
@@ -267,11 +287,8 @@ func NewTrimConfig(opts TrimOptions) (TrimConfig, error) {
|
|||||||
|
|
||||||
// NewNormalizeConfig validates raw normalize options and returns normalized config.
|
// NewNormalizeConfig validates raw normalize options and returns normalized config.
|
||||||
func NewNormalizeConfig(opts NormalizeOptions) (NormalizeConfig, error) {
|
func NewNormalizeConfig(opts NormalizeOptions) (NormalizeConfig, error) {
|
||||||
inputFile := filepath.Clean(strings.TrimSpace(opts.InputFile))
|
inputFile, err := normalizeSingleInputFile(opts.InputFile, "--input-file")
|
||||||
if strings.TrimSpace(opts.InputFile) == "" {
|
if err != nil {
|
||||||
return NormalizeConfig{}, errors.New("--input-file is required")
|
|
||||||
}
|
|
||||||
if err := requireFile(inputFile, "--input-file"); err != nil {
|
|
||||||
return NormalizeConfig{}, err
|
return NormalizeConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,13 +297,10 @@ func NewNormalizeConfig(opts NormalizeOptions) (NormalizeConfig, error) {
|
|||||||
return NormalizeConfig{}, err
|
return NormalizeConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
reportFile := ""
|
reportFile, err := normalizeOptionalOutputPath(opts.ReportFile, "--report-file")
|
||||||
if strings.TrimSpace(opts.ReportFile) != "" {
|
|
||||||
reportFile, err = normalizeOutputPath(opts.ReportFile, "--report-file")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NormalizeConfig{}, err
|
return NormalizeConfig{}, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
outputSchema, err := resolveOutputSchema(opts.OutputSchema)
|
outputSchema, err := resolveOutputSchema(opts.OutputSchema)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -313,6 +327,42 @@ func NewNormalizeConfig(opts NormalizeOptions) (NormalizeConfig, error) {
|
|||||||
}, nil
|
}, 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) {
|
func parseModuleList(value string) ([]string, error) {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -332,12 +382,12 @@ func parseModuleList(value string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateOutputSchema(value string) error {
|
func validateOutputSchema(value string) error {
|
||||||
switch value {
|
if schema.ValidOutputSchemaName(value) {
|
||||||
case OutputSchemaMinimal, OutputSchemaIntermediate, OutputSchemaFull:
|
|
||||||
return nil
|
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) {
|
func resolveOutputSchema(value string) (string, error) {
|
||||||
@@ -381,6 +431,26 @@ func normalizeInputFiles(paths []string) ([]string, error) {
|
|||||||
return normalized, nil
|
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) {
|
func normalizeOutputPath(path string, flag string) (string, error) {
|
||||||
path = strings.TrimSpace(path)
|
path = strings.TrimSpace(path)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
@@ -475,3 +545,12 @@ func validateNormalizeOutputModules(modules []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRenderFormat(format string) error {
|
||||||
|
switch format {
|
||||||
|
case RenderFormatMarkdown:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("--format must be %q", RenderFormatMarkdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -538,15 +538,9 @@ func TestCoalesceGapUsesValidOverride(t *testing.T) {
|
|||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
output := filepath.Join(dir, "merged.json")
|
output := filepath.Join(dir, "merged.json")
|
||||||
|
|
||||||
cfg, err := NewMergeConfig(MergeOptions{
|
opts := validMergeOptions(input, output)
|
||||||
InputFiles: []string{input},
|
opts.CoalesceGap = "1.5"
|
||||||
OutputFile: output,
|
cfg, err := NewMergeConfig(opts)
|
||||||
InputReader: DefaultInputReader,
|
|
||||||
OutputModules: DefaultOutputModules,
|
|
||||||
PreprocessingModules: DefaultPreprocessingModules,
|
|
||||||
PostprocessingModules: DefaultPostprocessingModules,
|
|
||||||
CoalesceGap: "1.5",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config failed: %v", err)
|
t.Fatalf("config failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -560,15 +554,9 @@ func TestCoalesceGapAllowsZero(t *testing.T) {
|
|||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
output := filepath.Join(dir, "merged.json")
|
output := filepath.Join(dir, "merged.json")
|
||||||
|
|
||||||
cfg, err := NewMergeConfig(MergeOptions{
|
opts := validMergeOptions(input, output)
|
||||||
InputFiles: []string{input},
|
opts.CoalesceGap = "0"
|
||||||
OutputFile: output,
|
cfg, err := NewMergeConfig(opts)
|
||||||
InputReader: DefaultInputReader,
|
|
||||||
OutputModules: DefaultOutputModules,
|
|
||||||
PreprocessingModules: DefaultPreprocessingModules,
|
|
||||||
PostprocessingModules: DefaultPostprocessingModules,
|
|
||||||
CoalesceGap: "0",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config failed: %v", err)
|
t.Fatalf("config failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -593,15 +581,9 @@ func TestCoalesceGapRejectsInvalidOverride(t *testing.T) {
|
|||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
output := filepath.Join(dir, "merged.json")
|
output := filepath.Join(dir, "merged.json")
|
||||||
|
|
||||||
_, err := NewMergeConfig(MergeOptions{
|
opts := validMergeOptions(input, output)
|
||||||
InputFiles: []string{input},
|
opts.CoalesceGap = test.value
|
||||||
OutputFile: output,
|
_, err := NewMergeConfig(opts)
|
||||||
InputReader: DefaultInputReader,
|
|
||||||
OutputModules: DefaultOutputModules,
|
|
||||||
PreprocessingModules: DefaultPreprocessingModules,
|
|
||||||
PostprocessingModules: DefaultPostprocessingModules,
|
|
||||||
CoalesceGap: test.value,
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
@@ -639,20 +621,16 @@ func TestNewTrimConfigRequiresExactlyOneSelectorFlag(t *testing.T) {
|
|||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
output := filepath.Join(dir, "trimmed.json")
|
output := filepath.Join(dir, "trimmed.json")
|
||||||
|
|
||||||
_, err := NewTrimConfig(TrimOptions{
|
opts := validTrimOptions(input, output)
|
||||||
InputFile: input,
|
opts.Keep = ""
|
||||||
OutputFile: output,
|
_, err := NewTrimConfig(opts)
|
||||||
})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "exactly one of --keep or --remove is required") {
|
if err == nil || !strings.Contains(err.Error(), "exactly one of --keep or --remove is required") {
|
||||||
t.Fatalf("expected missing selector error, got %v", err)
|
t.Fatalf("expected missing selector error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = NewTrimConfig(TrimOptions{
|
opts = validTrimOptions(input, output)
|
||||||
InputFile: input,
|
opts.Remove = "2"
|
||||||
OutputFile: output,
|
_, err = NewTrimConfig(opts)
|
||||||
Keep: "1",
|
|
||||||
Remove: "2",
|
|
||||||
})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||||
t.Fatalf("expected mutually exclusive selector error, got %v", err)
|
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")
|
output := filepath.Join(dir, "trimmed.json")
|
||||||
reportPath := filepath.Join(dir, "report.json")
|
reportPath := filepath.Join(dir, "report.json")
|
||||||
|
|
||||||
cfg, err := NewTrimConfig(TrimOptions{
|
opts := validTrimOptions(input, output)
|
||||||
InputFile: input,
|
opts.Keep = ""
|
||||||
OutputFile: output,
|
opts.Remove = "3-5"
|
||||||
ReportFile: reportPath,
|
opts.ReportFile = reportPath
|
||||||
Remove: "3-5",
|
opts.OutputSchema = OutputSchemaMinimal
|
||||||
OutputSchema: OutputSchemaMinimal,
|
opts.AllowEmpty = true
|
||||||
AllowEmpty: true,
|
cfg, err := NewTrimConfig(opts)
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config failed: %v", err)
|
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) {
|
func TestNewTrimConfigRejectsInvalidOutputSchemaOverride(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
output := filepath.Join(dir, "trimmed.json")
|
output := filepath.Join(dir, "trimmed.json")
|
||||||
|
|
||||||
_, err := NewTrimConfig(TrimOptions{
|
opts := validTrimOptions(input, output)
|
||||||
InputFile: input,
|
opts.OutputSchema = "compact"
|
||||||
OutputFile: output,
|
_, err := NewTrimConfig(opts)
|
||||||
Keep: "1",
|
|
||||||
OutputSchema: "compact",
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected output schema validation error")
|
t.Fatal("expected output schema validation error")
|
||||||
}
|
}
|
||||||
@@ -731,10 +721,8 @@ func TestNewNormalizeConfigRequiresOutputFile(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
|
|
||||||
_, err := NewNormalizeConfig(NormalizeOptions{
|
opts := validNormalizeOptions(input, "")
|
||||||
InputFile: input,
|
_, err := NewNormalizeConfig(opts)
|
||||||
OutputModules: DefaultOutputModules,
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected output-file required error")
|
t.Fatal("expected output-file required error")
|
||||||
}
|
}
|
||||||
@@ -749,11 +737,8 @@ func TestNewNormalizeConfigResolvesOutputSchemaDefaultAndEnv(t *testing.T) {
|
|||||||
output := filepath.Join(dir, "normalized.json")
|
output := filepath.Join(dir, "normalized.json")
|
||||||
|
|
||||||
t.Setenv(OutputSchemaEnv, "")
|
t.Setenv(OutputSchemaEnv, "")
|
||||||
cfg, err := NewNormalizeConfig(NormalizeOptions{
|
opts := validNormalizeOptions(input, output)
|
||||||
InputFile: input,
|
cfg, err := NewNormalizeConfig(opts)
|
||||||
OutputFile: output,
|
|
||||||
OutputModules: DefaultOutputModules,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config failed: %v", err)
|
t.Fatalf("config failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -762,11 +747,7 @@ func TestNewNormalizeConfigResolvesOutputSchemaDefaultAndEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Setenv(OutputSchemaEnv, OutputSchemaMinimal)
|
t.Setenv(OutputSchemaEnv, OutputSchemaMinimal)
|
||||||
cfg, err = NewNormalizeConfig(NormalizeOptions{
|
cfg, err = NewNormalizeConfig(opts)
|
||||||
InputFile: input,
|
|
||||||
OutputFile: output,
|
|
||||||
OutputModules: DefaultOutputModules,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("config failed: %v", err)
|
t.Fatalf("config failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -780,12 +761,9 @@ func TestNewNormalizeConfigRejectsInvalidOutputSchema(t *testing.T) {
|
|||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
output := filepath.Join(dir, "normalized.json")
|
output := filepath.Join(dir, "normalized.json")
|
||||||
|
|
||||||
_, err := NewNormalizeConfig(NormalizeOptions{
|
opts := validNormalizeOptions(input, output)
|
||||||
InputFile: input,
|
opts.OutputSchema = "compact"
|
||||||
OutputFile: output,
|
_, err := NewNormalizeConfig(opts)
|
||||||
OutputSchema: "compact",
|
|
||||||
OutputModules: DefaultOutputModules,
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected output schema error")
|
t.Fatal("expected output schema error")
|
||||||
}
|
}
|
||||||
@@ -799,11 +777,9 @@ func TestNewNormalizeConfigRejectsUnknownOutputModule(t *testing.T) {
|
|||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
output := filepath.Join(dir, "normalized.json")
|
output := filepath.Join(dir, "normalized.json")
|
||||||
|
|
||||||
_, err := NewNormalizeConfig(NormalizeOptions{
|
opts := validNormalizeOptions(input, output)
|
||||||
InputFile: input,
|
opts.OutputModules = "json,yaml"
|
||||||
OutputFile: output,
|
_, err := NewNormalizeConfig(opts)
|
||||||
OutputModules: "json,yaml",
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected output module error")
|
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) {
|
func assertPositiveFloatEnvValidation(t *testing.T, envName string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -832,14 +955,7 @@ func assertPositiveFloatEnvValidation(t *testing.T, envName string) {
|
|||||||
input := writeTempFile(t, dir, "input.json")
|
input := writeTempFile(t, dir, "input.json")
|
||||||
output := filepath.Join(dir, "merged.json")
|
output := filepath.Join(dir, "merged.json")
|
||||||
|
|
||||||
_, err := NewMergeConfig(MergeOptions{
|
_, err := NewMergeConfig(validMergeOptions(input, output))
|
||||||
InputFiles: []string{input},
|
|
||||||
OutputFile: output,
|
|
||||||
InputReader: DefaultInputReader,
|
|
||||||
OutputModules: DefaultOutputModules,
|
|
||||||
PreprocessingModules: DefaultPreprocessingModules,
|
|
||||||
PostprocessingModules: DefaultPostprocessingModules,
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
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 {
|
func writeTempFile(t *testing.T, dir string, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
28
internal/jsonfile/jsonfile.go
Normal file
28
internal/jsonfile/jsonfile.go
Normal 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
|
||||||
|
}
|
||||||
69
internal/jsonfile/jsonfile_test.go
Normal file
69
internal/jsonfile/jsonfile_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
// RawTranscript is a loaded input document before canonical normalization.
|
// RawTranscript is a loaded input document before canonical normalization.
|
||||||
type RawTranscript struct {
|
type RawTranscript struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
@@ -61,6 +63,17 @@ type Segment struct {
|
|||||||
OverlapGroupID int `json:"overlap_group_id,omitempty"`
|
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.
|
// Word preserves optional word-level timing data.
|
||||||
type Word struct {
|
type Word struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
|||||||
41
internal/model/model_test.go
Normal file
41
internal/model/model_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/buildinfo"
|
"gitea.maximumdirect.net/eric/seriatim/internal/buildinfo"
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/internal/jsonfile"
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/report"
|
"gitea.maximumdirect.net/eric/seriatim/internal/report"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ func Run(ctx context.Context, cfg config.NormalizeConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writeOutputJSON(cfg.OutputFile, built.Output); err != nil {
|
if err := jsonfile.Write(cfg.OutputFile, built.Output); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,18 +118,3 @@ func Run(ctx context.Context, cfg config.NormalizeConfig) error {
|
|||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package overlap
|
package overlap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/seriatim/internal/model"
|
"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.
|
// SegmentRef returns the stable overlap reference for a segment.
|
||||||
func SegmentRef(segment model.Segment) string {
|
func SegmentRef(segment model.Segment) string {
|
||||||
if segment.SourceSegmentIndex != nil {
|
return model.SegmentReference(segment)
|
||||||
return fmt.Sprintf("%s#%d", segment.Source, *segment.SourceSegmentIndex)
|
|
||||||
}
|
|
||||||
if segment.SourceRef != "" {
|
|
||||||
return segment.SourceRef
|
|
||||||
}
|
|
||||||
return segment.Source
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearExisting(in *model.MergedTranscript) {
|
func clearExisting(in *model.MergedTranscript) {
|
||||||
|
|||||||
97
internal/render/markdown.go
Normal file
97
internal/render/markdown.go
Normal 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)
|
||||||
|
}
|
||||||
227
internal/render/markdown_test.go
Normal file
227
internal/render/markdown_test.go
Normal 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:01–00:00:04] **Eric:** Hello there.") {
|
||||||
|
t.Fatalf("expected first segment in output:\n%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "[00:00:05–00:00:08] **Mike:** Welcome back, everyone.") {
|
||||||
|
t.Fatalf("expected second segment in output:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownRendererWithoutTimestamps(t *testing.T) {
|
||||||
|
transcript := Transcript{
|
||||||
|
Segments: []Segment{
|
||||||
|
{ID: 1, Start: 1, End: 4, Speaker: "Eric", Text: "Hello."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := MarkdownRenderer{}.Render(transcript, Options{
|
||||||
|
Title: "Transcript",
|
||||||
|
IncludeTimestamps: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render markdown: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(output, "[00:00:01") {
|
||||||
|
t.Fatalf("timestamps should be omitted:\n%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "**Eric:** Hello.") {
|
||||||
|
t.Fatalf("expected speaker/text line:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownRendererWithSegmentIDs(t *testing.T) {
|
||||||
|
transcript := Transcript{
|
||||||
|
Segments: []Segment{
|
||||||
|
{ID: 17, Start: 1, End: 4, Speaker: "Eric", Text: "Hello."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := MarkdownRenderer{}.Render(transcript, Options{
|
||||||
|
Title: "Transcript",
|
||||||
|
IncludeTimestamps: true,
|
||||||
|
IncludeSegmentIDs: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render markdown: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "[#17]") {
|
||||||
|
t.Fatalf("expected segment ID in output:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownRendererMetadataOnlyWhenRequested(t *testing.T) {
|
||||||
|
transcript := Transcript{
|
||||||
|
Schema: "seriatim-full",
|
||||||
|
Metadata: Metadata{
|
||||||
|
Application: "seriatim",
|
||||||
|
Version: "v-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
withMetadata, err := MarkdownRenderer{}.Render(transcript, Options{
|
||||||
|
Title: "Transcript",
|
||||||
|
IncludeMetadata: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render with metadata: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(withMetadata, "- Application: seriatim") {
|
||||||
|
t.Fatalf("expected metadata block:\n%s", withMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutMetadata, err := MarkdownRenderer{}.Render(transcript, Options{Title: "Transcript"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render without metadata: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(withoutMetadata, "- Application: seriatim") {
|
||||||
|
t.Fatalf("metadata should be omitted:\n%s", withoutMetadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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:01–00:00:04] [#1] **Eric:** Hello there.") {
|
||||||
|
t.Fatalf("expected HH:MM:SS timestamp formatting:\n%s", first)
|
||||||
|
}
|
||||||
|
if !strings.Contains(first, "[00:01:05–00: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
24
internal/render/model.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
// Transcript is the render-normalized transcript model used by renderers.
|
||||||
|
type Transcript struct {
|
||||||
|
Schema string
|
||||||
|
Metadata Metadata
|
||||||
|
Segments []Segment
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata is the render-relevant artifact metadata.
|
||||||
|
type Metadata struct {
|
||||||
|
Application string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment is a normalized render segment.
|
||||||
|
type Segment struct {
|
||||||
|
ID int
|
||||||
|
Start float64
|
||||||
|
End float64
|
||||||
|
Speaker string
|
||||||
|
Text string
|
||||||
|
Categories []string
|
||||||
|
}
|
||||||
96
internal/render/normalize.go
Normal file
96
internal/render/normalize.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FromOutputArtifact converts a parsed output artifact into the internal render model.
|
||||||
|
func FromOutputArtifact(input artifact.OutputArtifact) (Transcript, error) {
|
||||||
|
switch input.Schema {
|
||||||
|
case artifact.OutputSchemaFull:
|
||||||
|
payload, err := input.FullPayload()
|
||||||
|
if err != nil {
|
||||||
|
return Transcript{}, err
|
||||||
|
}
|
||||||
|
segments := make([]Segment, len(payload.Segments))
|
||||||
|
for index, segment := range payload.Segments {
|
||||||
|
segments[index] = Segment{
|
||||||
|
ID: segment.ID,
|
||||||
|
Start: segment.Start,
|
||||||
|
End: segment.End,
|
||||||
|
Speaker: segment.Speaker,
|
||||||
|
Text: segment.Text,
|
||||||
|
Categories: normalizeCategories(segment.Categories),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Transcript{
|
||||||
|
Schema: input.Schema,
|
||||||
|
Metadata: Metadata{
|
||||||
|
Application: payload.Metadata.Application,
|
||||||
|
Version: payload.Metadata.Version,
|
||||||
|
},
|
||||||
|
Segments: segments,
|
||||||
|
}, nil
|
||||||
|
case artifact.OutputSchemaIntermediate:
|
||||||
|
payload, err := input.IntermediatePayload()
|
||||||
|
if err != nil {
|
||||||
|
return Transcript{}, err
|
||||||
|
}
|
||||||
|
segments := make([]Segment, len(payload.Segments))
|
||||||
|
for index, segment := range payload.Segments {
|
||||||
|
segments[index] = Segment{
|
||||||
|
ID: segment.ID,
|
||||||
|
Start: segment.Start,
|
||||||
|
End: segment.End,
|
||||||
|
Speaker: segment.Speaker,
|
||||||
|
Text: segment.Text,
|
||||||
|
Categories: normalizeCategories(segment.Categories),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Transcript{
|
||||||
|
Schema: input.Schema,
|
||||||
|
Metadata: Metadata{
|
||||||
|
Application: payload.Metadata.Application,
|
||||||
|
Version: payload.Metadata.Version,
|
||||||
|
},
|
||||||
|
Segments: segments,
|
||||||
|
}, nil
|
||||||
|
case artifact.OutputSchemaMinimal:
|
||||||
|
payload, err := input.MinimalPayload()
|
||||||
|
if err != nil {
|
||||||
|
return Transcript{}, err
|
||||||
|
}
|
||||||
|
segments := make([]Segment, len(payload.Segments))
|
||||||
|
for index, segment := range payload.Segments {
|
||||||
|
segments[index] = Segment{
|
||||||
|
ID: segment.ID,
|
||||||
|
Start: segment.Start,
|
||||||
|
End: segment.End,
|
||||||
|
Speaker: segment.Speaker,
|
||||||
|
Text: segment.Text,
|
||||||
|
Categories: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Transcript{
|
||||||
|
Schema: input.Schema,
|
||||||
|
Metadata: Metadata{
|
||||||
|
Application: payload.Metadata.Application,
|
||||||
|
Version: payload.Metadata.Version,
|
||||||
|
},
|
||||||
|
Segments: segments,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return Transcript{}, fmt.Errorf("unsupported artifact schema %q", input.Schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCategories(categories []string) []string {
|
||||||
|
if categories == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
out := make([]string, len(categories))
|
||||||
|
copy(out, categories)
|
||||||
|
return out
|
||||||
|
}
|
||||||
139
internal/render/normalize_test.go
Normal file
139
internal/render/normalize_test.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFromOutputArtifactNormalizesSupportedSchemas(t *testing.T) {
|
||||||
|
t.Run("full", func(t *testing.T) {
|
||||||
|
sourceIndex := 0
|
||||||
|
input := schema.Transcript{
|
||||||
|
Metadata: schema.Metadata{
|
||||||
|
Application: "seriatim",
|
||||||
|
Version: "v-test",
|
||||||
|
InputReader: "json-files",
|
||||||
|
InputFiles: []string{"a.json"},
|
||||||
|
PreprocessingModules: []string{"validate-raw"},
|
||||||
|
PostprocessingModules: []string{"assign-ids", "validate-output"},
|
||||||
|
OutputModules: []string{"json"},
|
||||||
|
},
|
||||||
|
Segments: []schema.Segment{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Source: "a.json",
|
||||||
|
SourceSegmentIndex: &sourceIndex,
|
||||||
|
Speaker: "Alice",
|
||||||
|
Start: 1,
|
||||||
|
End: 2,
|
||||||
|
Text: "hello",
|
||||||
|
Categories: []string{"background"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OverlapGroups: []schema.OverlapGroup{},
|
||||||
|
}
|
||||||
|
model := mustNormalizeOutputArtifact(t, input)
|
||||||
|
if model.Schema != artifact.OutputSchemaFull {
|
||||||
|
t.Fatalf("schema = %q, want %q", model.Schema, artifact.OutputSchemaFull)
|
||||||
|
}
|
||||||
|
if len(model.Segments) != 1 {
|
||||||
|
t.Fatalf("segment count = %d, want 1", len(model.Segments))
|
||||||
|
}
|
||||||
|
if model.Segments[0].ID != 1 || model.Segments[0].Speaker != "Alice" || model.Segments[0].Text != "hello" {
|
||||||
|
t.Fatalf("unexpected segment: %#v", model.Segments[0])
|
||||||
|
}
|
||||||
|
if len(model.Segments[0].Categories) != 1 || model.Segments[0].Categories[0] != "background" {
|
||||||
|
t.Fatalf("categories = %#v, want [background]", model.Segments[0].Categories)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("intermediate", func(t *testing.T) {
|
||||||
|
input := schema.IntermediateTranscript{
|
||||||
|
Metadata: schema.IntermediateMetadata{
|
||||||
|
Application: "seriatim",
|
||||||
|
Version: "v-test",
|
||||||
|
OutputSchema: artifact.OutputSchemaIntermediate,
|
||||||
|
},
|
||||||
|
Segments: []schema.IntermediateSegment{
|
||||||
|
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "one", Categories: []string{}},
|
||||||
|
{ID: 2, Start: 2, End: 3, Speaker: "Bob", Text: "two"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
model := mustNormalizeOutputArtifact(t, input)
|
||||||
|
if model.Schema != artifact.OutputSchemaIntermediate {
|
||||||
|
t.Fatalf("schema = %q, want %q", model.Schema, artifact.OutputSchemaIntermediate)
|
||||||
|
}
|
||||||
|
if len(model.Segments[0].Categories) != 0 {
|
||||||
|
t.Fatalf("segment[0] categories = %#v, want empty slice", model.Segments[0].Categories)
|
||||||
|
}
|
||||||
|
if len(model.Segments[1].Categories) != 0 {
|
||||||
|
t.Fatalf("segment[1] categories = %#v, want empty slice", model.Segments[1].Categories)
|
||||||
|
}
|
||||||
|
if model.Segments[0].Categories == nil || model.Segments[1].Categories == nil {
|
||||||
|
t.Fatal("expected non-nil empty categories slices")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("minimal", func(t *testing.T) {
|
||||||
|
input := schema.MinimalTranscript{
|
||||||
|
Metadata: schema.MinimalMetadata{
|
||||||
|
Application: "seriatim",
|
||||||
|
Version: "v-test",
|
||||||
|
OutputSchema: artifact.OutputSchemaMinimal,
|
||||||
|
},
|
||||||
|
Segments: []schema.MinimalSegment{
|
||||||
|
{ID: 1, Start: 1, End: 2, Speaker: "Alice", Text: "one"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
model := mustNormalizeOutputArtifact(t, input)
|
||||||
|
if model.Schema != artifact.OutputSchemaMinimal {
|
||||||
|
t.Fatalf("schema = %q, want %q", model.Schema, artifact.OutputSchemaMinimal)
|
||||||
|
}
|
||||||
|
if len(model.Segments[0].Categories) != 0 {
|
||||||
|
t.Fatalf("categories = %#v, want empty slice", model.Segments[0].Categories)
|
||||||
|
}
|
||||||
|
if model.Segments[0].Categories == nil {
|
||||||
|
t.Fatal("expected non-nil empty categories slice")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromOutputArtifactRejectsMalformedAndRawInput(t *testing.T) {
|
||||||
|
_, err := artifact.ParseOutputArtifactJSON([]byte(`{"metadata":`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected malformed JSON error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "input JSON is malformed") {
|
||||||
|
t.Fatalf("unexpected malformed error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawWhisper := []byte(`{"segments":[{"id":0,"start":0.1,"end":1.2,"text":"hello","words":[{"word":"hello"}]}]}`)
|
||||||
|
_, err = artifact.ParseOutputArtifactJSON(rawWhisper)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected raw input artifact error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a valid seriatim output artifact") {
|
||||||
|
t.Fatalf("unexpected raw input error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustNormalizeOutputArtifact(t *testing.T, value any) Transcript {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
parsed, err := artifact.ParseOutputArtifactJSON(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
model, err := FromOutputArtifact(parsed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize: %v", err)
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
41
internal/render/registry.go
Normal file
41
internal/render/registry.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
const FormatMarkdown = "markdown"
|
||||||
|
|
||||||
|
// Options configures rendering behavior across formats.
|
||||||
|
type Options struct {
|
||||||
|
Title string
|
||||||
|
IncludeTimestamps bool
|
||||||
|
IncludeSegmentIDs bool
|
||||||
|
IncludeMetadata bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderer turns a normalized render model into text output.
|
||||||
|
type Renderer interface {
|
||||||
|
Render(transcript Transcript, opts Options) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry resolves renderers by public format name.
|
||||||
|
type Registry struct {
|
||||||
|
renderers map[string]Renderer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry returns a renderer registry with built-in renderers.
|
||||||
|
func NewRegistry() Registry {
|
||||||
|
return Registry{
|
||||||
|
renderers: map[string]Renderer{
|
||||||
|
FormatMarkdown: MarkdownRenderer{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve resolves a renderer by format name.
|
||||||
|
func (registry Registry) Resolve(format string) (Renderer, error) {
|
||||||
|
renderer, ok := registry.renderers[format]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported --format %q", format)
|
||||||
|
}
|
||||||
|
return renderer, nil
|
||||||
|
}
|
||||||
22
internal/render/registry_test.go
Normal file
22
internal/render/registry_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRegistryResolvesMarkdownRenderer(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
renderer, err := registry.Resolve(FormatMarkdown)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve markdown renderer: %v", err)
|
||||||
|
}
|
||||||
|
if renderer == nil {
|
||||||
|
t.Fatal("expected renderer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryRejectsUnknownRenderer(t *testing.T) {
|
||||||
|
registry := NewRegistry()
|
||||||
|
_, err := registry.Resolve("txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unsupported format error")
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/render/run.go
Normal file
72
internal/render/run.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/seriatim/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes artifact-level render orchestration.
|
||||||
|
func Run(ctx context.Context, cfg config.RenderConfig) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(cfg.InputFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read --input-file %q: %w", cfg.InputFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputArtifact, err := artifact.ParseOutputArtifactJSON(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--input-file %q: %w", cfg.InputFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := FromOutputArtifact(inputArtifact)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("normalize artifact for render: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := NewRegistry()
|
||||||
|
renderer, err := registry.Resolve(cfg.Format)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := renderer.Render(model, Options{
|
||||||
|
Title: cfg.Title,
|
||||||
|
IncludeTimestamps: cfg.IncludeTimestamps,
|
||||||
|
IncludeSegmentIDs: cfg.IncludeSegmentIDs,
|
||||||
|
IncludeMetadata: cfg.IncludeMetadata,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("render %q output: %w", cfg.Format, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeFile(cfg.OutputFile, rendered); err != nil {
|
||||||
|
return fmt.Errorf("write --output-file %q: %w", cfg.OutputFile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(path string, content string) (err error) {
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
closeErr := file.Close()
|
||||||
|
if err == nil && closeErr != nil {
|
||||||
|
err = fmt.Errorf("close %q: %w", path, closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := file.WriteString(content); err != nil {
|
||||||
|
return fmt.Errorf("write %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package report
|
package report
|
||||||
|
|
||||||
import (
|
import "gitea.maximumdirect.net/eric/seriatim/internal/jsonfile"
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Severity classifies report events.
|
// Severity classifies report events.
|
||||||
type Severity string
|
type Severity string
|
||||||
@@ -62,13 +59,5 @@ func Warning(stage string, module string, message string) Event {
|
|||||||
|
|
||||||
// WriteJSON writes a deterministic JSON report.
|
// WriteJSON writes a deterministic JSON report.
|
||||||
func WriteJSON(path string, rpt Report) error {
|
func WriteJSON(path string, rpt Report) error {
|
||||||
file, err := os.Create(path)
|
return jsonfile.Write(path, rpt)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
enc := json.NewEncoder(file)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
return enc.Encode(rpt)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,54 +44,29 @@ type MinimalResult struct {
|
|||||||
RemovedIDs []int
|
RemovedIDs []int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type projection struct {
|
||||||
|
retainedIndexes []int
|
||||||
|
oldToNewID map[int]int
|
||||||
|
removedIDs []int
|
||||||
|
}
|
||||||
|
|
||||||
// Apply trims a full seriatim output transcript by segment ID.
|
// Apply trims a full seriatim output transcript by segment ID.
|
||||||
func Apply(input schema.Transcript, opts Options) (Result, error) {
|
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))
|
inputIDs := make([]int, len(input.Segments))
|
||||||
for index, segment := range input.Segments {
|
for index, segment := range input.Segments {
|
||||||
inputIDs[index] = segment.ID
|
inputIDs[index] = segment.ID
|
||||||
}
|
}
|
||||||
|
proj, err := projectSegmentIDs(inputIDs, opts)
|
||||||
idIndex, err := validateInputIDs(inputIDs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSelectedIDsExist(selected, idIndex); err != nil {
|
kept := make([]schema.Segment, len(proj.retainedIndexes))
|
||||||
return Result{}, err
|
for outputIndex, inputIndex := range proj.retainedIndexes {
|
||||||
}
|
rewritten := copySegment(input.Segments[inputIndex])
|
||||||
|
rewritten.ID = outputIndex + 1
|
||||||
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
|
|
||||||
rewritten.OverlapGroupID = 0
|
rewritten.OverlapGroupID = 0
|
||||||
kept = append(kept, rewritten)
|
kept[outputIndex] = 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, groups := recomputeOverlapGroups(kept)
|
kept, groups := recomputeOverlapGroups(kept)
|
||||||
@@ -104,62 +79,35 @@ func Apply(input schema.Transcript, opts Options) (Result, error) {
|
|||||||
out.OverlapGroups = groups
|
out.OverlapGroups = groups
|
||||||
return Result{
|
return Result{
|
||||||
Transcript: out,
|
Transcript: out,
|
||||||
OldToNewID: oldToNew,
|
OldToNewID: proj.oldToNewID,
|
||||||
RemovedIDs: removed,
|
RemovedIDs: proj.removedIDs,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyIntermediate trims an intermediate seriatim output transcript by
|
// ApplyIntermediate trims an intermediate seriatim output transcript by
|
||||||
// segment ID.
|
// segment ID.
|
||||||
func ApplyIntermediate(input schema.IntermediateTranscript, opts Options) (IntermediateResult, error) {
|
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))
|
inputIDs := make([]int, len(input.Segments))
|
||||||
for index, segment := range input.Segments {
|
for index, segment := range input.Segments {
|
||||||
inputIDs[index] = segment.ID
|
inputIDs[index] = segment.ID
|
||||||
}
|
}
|
||||||
idIndex, err := validateInputIDs(inputIDs)
|
proj, err := projectSegmentIDs(inputIDs, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return IntermediateResult{}, err
|
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{
|
rewritten := schema.IntermediateSegment{
|
||||||
ID: len(kept) + 1,
|
ID: outputIndex + 1,
|
||||||
Start: segment.Start,
|
Start: segment.Start,
|
||||||
End: segment.End,
|
End: segment.End,
|
||||||
Speaker: segment.Speaker,
|
Speaker: segment.Speaker,
|
||||||
Text: segment.Text,
|
Text: segment.Text,
|
||||||
Categories: append([]string(nil), segment.Categories...),
|
Categories: append([]string(nil), segment.Categories...),
|
||||||
}
|
}
|
||||||
kept = append(kept, rewritten)
|
kept[outputIndex] = 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return IntermediateResult{
|
return IntermediateResult{
|
||||||
@@ -171,60 +119,33 @@ func ApplyIntermediate(input schema.IntermediateTranscript, opts Options) (Inter
|
|||||||
},
|
},
|
||||||
Segments: kept,
|
Segments: kept,
|
||||||
},
|
},
|
||||||
OldToNewID: oldToNew,
|
OldToNewID: proj.oldToNewID,
|
||||||
RemovedIDs: removed,
|
RemovedIDs: proj.removedIDs,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyMinimal trims a minimal seriatim output transcript by segment ID.
|
// ApplyMinimal trims a minimal seriatim output transcript by segment ID.
|
||||||
func ApplyMinimal(input schema.MinimalTranscript, opts Options) (MinimalResult, error) {
|
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))
|
inputIDs := make([]int, len(input.Segments))
|
||||||
for index, segment := range input.Segments {
|
for index, segment := range input.Segments {
|
||||||
inputIDs[index] = segment.ID
|
inputIDs[index] = segment.ID
|
||||||
}
|
}
|
||||||
idIndex, err := validateInputIDs(inputIDs)
|
proj, err := projectSegmentIDs(inputIDs, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return MinimalResult{}, err
|
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{
|
rewritten := schema.MinimalSegment{
|
||||||
ID: len(kept) + 1,
|
ID: outputIndex + 1,
|
||||||
Start: segment.Start,
|
Start: segment.Start,
|
||||||
End: segment.End,
|
End: segment.End,
|
||||||
Speaker: segment.Speaker,
|
Speaker: segment.Speaker,
|
||||||
Text: segment.Text,
|
Text: segment.Text,
|
||||||
}
|
}
|
||||||
kept = append(kept, rewritten)
|
kept[outputIndex] = 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return MinimalResult{
|
return MinimalResult{
|
||||||
@@ -236,11 +157,53 @@ func ApplyMinimal(input schema.MinimalTranscript, opts Options) (MinimalResult,
|
|||||||
},
|
},
|
||||||
Segments: kept,
|
Segments: kept,
|
||||||
},
|
},
|
||||||
OldToNewID: oldToNew,
|
OldToNewID: proj.oldToNewID,
|
||||||
RemovedIDs: removed,
|
RemovedIDs: proj.removedIDs,
|
||||||
}, nil
|
}, 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 {
|
func validateMode(mode Mode) error {
|
||||||
switch mode {
|
switch mode {
|
||||||
case ModeKeep, ModeRemove:
|
case ModeKeep, ModeRemove:
|
||||||
|
|||||||
@@ -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) {
|
func TestApplyOutputInvariantsValidAfterRenumberAndOverlapRecompute(t *testing.T) {
|
||||||
input := overlapTranscriptFixture()
|
input := overlapTranscriptFixture()
|
||||||
selector := mustParseSelector(t, "2,1")
|
selector := mustParseSelector(t, "2,1")
|
||||||
@@ -666,3 +766,108 @@ func equalStringSlices(got []string, want []string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
package trim
|
package trim
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
artifactpkg "gitea.maximumdirect.net/eric/seriatim/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/seriatim/schema"
|
"gitea.maximumdirect.net/eric/seriatim/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SchemaMinimal = "seriatim-minimal"
|
SchemaMinimal = artifactpkg.OutputSchemaMinimal
|
||||||
SchemaIntermediate = "seriatim-intermediate"
|
SchemaIntermediate = artifactpkg.OutputSchemaIntermediate
|
||||||
SchemaFull = "seriatim-full"
|
SchemaFull = artifactpkg.OutputSchemaFull
|
||||||
)
|
)
|
||||||
|
|
||||||
// Artifact stores a parsed seriatim output artifact of one supported schema.
|
// 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.
|
// ParseArtifactJSON parses and validates a serialized seriatim output artifact.
|
||||||
func ParseArtifactJSON(data []byte) (Artifact, error) {
|
func ParseArtifactJSON(data []byte) (Artifact, error) {
|
||||||
var decoded any
|
parsed, err := artifactpkg.ParseOutputArtifactJSON(data)
|
||||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
if err != nil {
|
||||||
return Artifact{}, fmt.Errorf("input JSON is malformed: %w", err)
|
return Artifact{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var full schema.Transcript
|
|
||||||
if err := json.Unmarshal(data, &full); err == nil {
|
|
||||||
if err := schema.ValidateTranscript(full); err == nil {
|
|
||||||
return Artifact{
|
return Artifact{
|
||||||
Schema: SchemaFull,
|
Schema: parsed.Schema,
|
||||||
Full: &full,
|
Full: parsed.Full,
|
||||||
|
Intermediate: parsed.Intermediate,
|
||||||
|
Minimal: parsed.Minimal,
|
||||||
}, nil
|
}, 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateArtifact validates an artifact against its declared schema.
|
// ValidateArtifact validates an artifact against its declared schema.
|
||||||
func ValidateArtifact(artifact Artifact) error {
|
func ValidateArtifact(artifact Artifact) error {
|
||||||
switch artifact.Schema {
|
switch artifact.Schema {
|
||||||
case SchemaFull:
|
case SchemaFull:
|
||||||
if artifact.Full == nil {
|
payload, err := artifact.fullPayload()
|
||||||
return fmt.Errorf("full artifact payload is missing")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return schema.ValidateTranscript(*artifact.Full)
|
return schema.ValidateTranscript(*payload)
|
||||||
case SchemaIntermediate:
|
case SchemaIntermediate:
|
||||||
if artifact.Intermediate == nil {
|
payload, err := artifact.intermediatePayload()
|
||||||
return fmt.Errorf("intermediate artifact payload is missing")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return schema.ValidateIntermediateTranscript(*artifact.Intermediate)
|
return schema.ValidateIntermediateTranscript(*payload)
|
||||||
case SchemaMinimal:
|
case SchemaMinimal:
|
||||||
if artifact.Minimal == nil {
|
payload, err := artifact.minimalPayload()
|
||||||
return fmt.Errorf("minimal artifact payload is missing")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return schema.ValidateMinimalTranscript(*artifact.Minimal)
|
return schema.ValidateMinimalTranscript(*payload)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported artifact schema %q", artifact.Schema)
|
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) {
|
func ApplyArtifact(input Artifact, opts Options) (ApplyArtifactResult, error) {
|
||||||
switch input.Schema {
|
switch input.Schema {
|
||||||
case SchemaFull:
|
case SchemaFull:
|
||||||
if input.Full == nil {
|
payload, err := input.fullPayload()
|
||||||
return ApplyArtifactResult{}, fmt.Errorf("full artifact payload is missing")
|
if err != nil {
|
||||||
|
return ApplyArtifactResult{}, err
|
||||||
}
|
}
|
||||||
result, err := Apply(*input.Full, opts)
|
result, err := Apply(*payload, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ApplyArtifactResult{}, err
|
return ApplyArtifactResult{}, err
|
||||||
}
|
}
|
||||||
@@ -206,10 +184,11 @@ func ApplyArtifact(input Artifact, opts Options) (ApplyArtifactResult, error) {
|
|||||||
OverlapGroupsRecomputed: true,
|
OverlapGroupsRecomputed: true,
|
||||||
}, nil
|
}, nil
|
||||||
case SchemaIntermediate:
|
case SchemaIntermediate:
|
||||||
if input.Intermediate == nil {
|
payload, err := input.intermediatePayload()
|
||||||
return ApplyArtifactResult{}, fmt.Errorf("intermediate artifact payload is missing")
|
if err != nil {
|
||||||
|
return ApplyArtifactResult{}, err
|
||||||
}
|
}
|
||||||
result, err := ApplyIntermediate(*input.Intermediate, opts)
|
result, err := ApplyIntermediate(*payload, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ApplyArtifactResult{}, err
|
return ApplyArtifactResult{}, err
|
||||||
}
|
}
|
||||||
@@ -224,10 +203,11 @@ func ApplyArtifact(input Artifact, opts Options) (ApplyArtifactResult, error) {
|
|||||||
OverlapGroupsRecomputed: false,
|
OverlapGroupsRecomputed: false,
|
||||||
}, nil
|
}, nil
|
||||||
case SchemaMinimal:
|
case SchemaMinimal:
|
||||||
if input.Minimal == nil {
|
payload, err := input.minimalPayload()
|
||||||
return ApplyArtifactResult{}, fmt.Errorf("minimal artifact payload is missing")
|
if err != nil {
|
||||||
|
return ApplyArtifactResult{}, err
|
||||||
}
|
}
|
||||||
result, err := ApplyMinimal(*input.Minimal, opts)
|
result, err := ApplyMinimal(*payload, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ApplyArtifactResult{}, err
|
return ApplyArtifactResult{}, err
|
||||||
}
|
}
|
||||||
@@ -254,18 +234,19 @@ func ConvertArtifact(input Artifact, outputSchema string) (Artifact, error) {
|
|||||||
|
|
||||||
switch input.Schema {
|
switch input.Schema {
|
||||||
case SchemaFull:
|
case SchemaFull:
|
||||||
if input.Full == nil {
|
payload, err := input.fullPayload()
|
||||||
return Artifact{}, fmt.Errorf("full artifact payload is missing")
|
if err != nil {
|
||||||
|
return Artifact{}, err
|
||||||
}
|
}
|
||||||
switch outputSchema {
|
switch outputSchema {
|
||||||
case SchemaIntermediate:
|
case SchemaIntermediate:
|
||||||
out := intermediateFromFull(*input.Full)
|
out := intermediateFromFull(*payload)
|
||||||
return Artifact{
|
return Artifact{
|
||||||
Schema: SchemaIntermediate,
|
Schema: SchemaIntermediate,
|
||||||
Intermediate: &out,
|
Intermediate: &out,
|
||||||
}, nil
|
}, nil
|
||||||
case SchemaMinimal:
|
case SchemaMinimal:
|
||||||
out := minimalFromFull(*input.Full)
|
out := minimalFromFull(*payload)
|
||||||
return Artifact{
|
return Artifact{
|
||||||
Schema: SchemaMinimal,
|
Schema: SchemaMinimal,
|
||||||
Minimal: &out,
|
Minimal: &out,
|
||||||
@@ -274,12 +255,13 @@ func ConvertArtifact(input Artifact, outputSchema string) (Artifact, error) {
|
|||||||
return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema)
|
return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema)
|
||||||
}
|
}
|
||||||
case SchemaIntermediate:
|
case SchemaIntermediate:
|
||||||
if input.Intermediate == nil {
|
payload, err := input.intermediatePayload()
|
||||||
return Artifact{}, fmt.Errorf("intermediate artifact payload is missing")
|
if err != nil {
|
||||||
|
return Artifact{}, err
|
||||||
}
|
}
|
||||||
switch outputSchema {
|
switch outputSchema {
|
||||||
case SchemaMinimal:
|
case SchemaMinimal:
|
||||||
out := minimalFromIntermediate(*input.Intermediate)
|
out := minimalFromIntermediate(*payload)
|
||||||
return Artifact{
|
return Artifact{
|
||||||
Schema: SchemaMinimal,
|
Schema: SchemaMinimal,
|
||||||
Minimal: &out,
|
Minimal: &out,
|
||||||
@@ -290,12 +272,13 @@ func ConvertArtifact(input Artifact, outputSchema string) (Artifact, error) {
|
|||||||
return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema)
|
return Artifact{}, fmt.Errorf("unsupported output schema %q", outputSchema)
|
||||||
}
|
}
|
||||||
case SchemaMinimal:
|
case SchemaMinimal:
|
||||||
if input.Minimal == nil {
|
payload, err := input.minimalPayload()
|
||||||
return Artifact{}, fmt.Errorf("minimal artifact payload is missing")
|
if err != nil {
|
||||||
|
return Artifact{}, err
|
||||||
}
|
}
|
||||||
switch outputSchema {
|
switch outputSchema {
|
||||||
case SchemaIntermediate:
|
case SchemaIntermediate:
|
||||||
out := intermediateFromMinimal(*input.Minimal)
|
out := intermediateFromMinimal(*payload)
|
||||||
return Artifact{
|
return Artifact{
|
||||||
Schema: SchemaIntermediate,
|
Schema: SchemaIntermediate,
|
||||||
Intermediate: &out,
|
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 {
|
func intermediateFromFull(input schema.Transcript) schema.IntermediateTranscript {
|
||||||
segments := make([]schema.IntermediateSegment, len(input.Segments))
|
segments := make([]schema.IntermediateSegment, len(input.Segments))
|
||||||
for index, segment := range input.Segments {
|
for index, segment := range input.Segments {
|
||||||
|
|||||||
@@ -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 {
|
func mustMarshalJSON(t *testing.T, value any) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := json.Marshal(value)
|
data, err := json.Marshal(value)
|
||||||
|
|||||||
156
internal/trim/run.go
Normal file
156
internal/trim/run.go
Normal 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
28
internal/trim/run_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import (
|
|||||||
var schemaFS embed.FS
|
var schemaFS embed.FS
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
OutputSchemaMinimal = "seriatim-minimal"
|
||||||
|
OutputSchemaIntermediate = "seriatim-intermediate"
|
||||||
|
OutputSchemaFull = "seriatim-full"
|
||||||
|
|
||||||
fullOutputSchemaPath = "full-output.schema.json"
|
fullOutputSchemaPath = "full-output.schema.json"
|
||||||
intermediateOutputSchemaPath = "intermediate-output.schema.json"
|
intermediateOutputSchemaPath = "intermediate-output.schema.json"
|
||||||
minimalOutputSchemaPath = "minimal-output.schema.json"
|
minimalOutputSchemaPath = "minimal-output.schema.json"
|
||||||
@@ -115,6 +119,25 @@ type OverlapGroup struct {
|
|||||||
Resolution string `json:"resolution"`
|
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
|
// ValidateTranscript validates a full transcript against the public JSON
|
||||||
// schema and seriatim-specific semantic rules.
|
// schema and seriatim-specific semantic rules.
|
||||||
func ValidateTranscript(transcript Transcript) error {
|
func ValidateTranscript(transcript Transcript) error {
|
||||||
@@ -228,14 +251,16 @@ func outputSchema(schemaPath string) (*jsonschema.Schema, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateSemantics(transcript Transcript) error {
|
func validateSemantics(transcript Transcript) error {
|
||||||
|
segments := make([]segmentSemantics, len(transcript.Segments))
|
||||||
for index, segment := range transcript.Segments {
|
for index, segment := range transcript.Segments {
|
||||||
wantID := index + 1
|
segments[index] = segmentSemantics{
|
||||||
if segment.ID != wantID {
|
id: segment.ID,
|
||||||
return fmt.Errorf("segment %d has id %d; want %d", index, segment.ID, wantID)
|
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)
|
|
||||||
}
|
}
|
||||||
|
if err := validateSegmentSemantics(segments); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
for index, group := range transcript.OverlapGroups {
|
for index, group := range transcript.OverlapGroups {
|
||||||
if group.End < group.Start {
|
if group.End < group.Start {
|
||||||
@@ -246,26 +271,43 @@ func validateSemantics(transcript Transcript) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateIntermediateSemantics(transcript IntermediateTranscript) error {
|
func validateIntermediateSemantics(transcript IntermediateTranscript) error {
|
||||||
|
segments := make([]segmentSemantics, len(transcript.Segments))
|
||||||
for index, segment := range transcript.Segments {
|
for index, segment := range transcript.Segments {
|
||||||
wantID := index + 1
|
segments[index] = segmentSemantics{
|
||||||
if segment.ID != wantID {
|
id: segment.ID,
|
||||||
return fmt.Errorf("segment %d has id %d; want %d", index, segment.ID, wantID)
|
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 nil
|
return validateSegmentSemantics(segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateMinimalSemantics(transcript MinimalTranscript) error {
|
func validateMinimalSemantics(transcript MinimalTranscript) error {
|
||||||
|
segments := make([]segmentSemantics, len(transcript.Segments))
|
||||||
for index, segment := range transcript.Segments {
|
for index, segment := range transcript.Segments {
|
||||||
wantID := index + 1
|
segments[index] = segmentSemantics{
|
||||||
if segment.ID != wantID {
|
id: segment.ID,
|
||||||
return fmt.Errorf("segment %d has id %d; want %d", index, segment.ID, wantID)
|
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
|
return nil
|
||||||
|
|||||||
@@ -5,6 +5,43 @@ import (
|
|||||||
"testing"
|
"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) {
|
func TestValidateTranscriptAcceptsValidTranscript(t *testing.T) {
|
||||||
transcript := validTranscript()
|
transcript := validTranscript()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user