Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 717451512a | |||
| 3ddb3a947b | |||
| c6632d5576 | |||
| ffc07922c7 | |||
| f3310d4d16 | |||
| 88cee96d8d | |||
| 2fece10215 | |||
| 0658f2f642 | |||
| a51228c803 |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Narratio is a stage-driven Go orchestrator for turning D&D session audio into polished transcripts and generated artifacts.
|
Narratio is a stage-driven Go orchestrator for turning D&D session audio into polished transcripts and generated artifacts.
|
||||||
|
|
||||||
It runs a deterministic workflow across `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`, and `publish`, with manifest-driven continuation and restore support.
|
It runs a deterministic workflow across `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `render`, `analyze`, and `publish`, with manifest-driven continuation and restore support.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run 2026-04-04
|
narratio run 2026-04-04
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ Valid stage names:
|
|||||||
- `polish`
|
- `polish`
|
||||||
- `normalize`
|
- `normalize`
|
||||||
- `trim`
|
- `trim`
|
||||||
|
- `render`
|
||||||
- `analyze`
|
- `analyze`
|
||||||
- `publish`
|
- `publish`
|
||||||
- `notify`
|
- `notify`
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ using configured object storage.
|
|||||||
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
||||||
- Pipeline defaults are applied before validation.
|
- Pipeline defaults are applied before validation.
|
||||||
- Campaign and session identities must agree.
|
- Campaign and session identities must agree.
|
||||||
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`) resolve from session overrides when provided, otherwise from campaign defaults.
|
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, `players_file`, `party_file`) resolve from session overrides when provided, otherwise from campaign defaults.
|
||||||
- Exactly one audio mode must be configured in session input:
|
- Exactly one audio mode must be configured in session input:
|
||||||
- local (`audio_dir` or `audio_files`), or
|
- local (`audio_dir` or `audio_files`), or
|
||||||
- S3 (`audio_s3.prefix`).
|
- S3 (`audio_s3.prefix`).
|
||||||
@@ -69,6 +69,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
`session.yml` (local audio)
|
`session.yml` (local audio)
|
||||||
@@ -98,6 +100,12 @@ publish:
|
|||||||
- source: narratio.transcript.final_trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/final.trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
|
- source: narratio.transcript.final_markdown
|
||||||
|
dest: transcripts/final.md
|
||||||
|
required: true
|
||||||
|
- source: narratio.transcript.final_trimmed_markdown
|
||||||
|
dest: transcripts/final.trimmed.md
|
||||||
|
required: true
|
||||||
- source: narratio.artifact.session_recap
|
- source: narratio.artifact.session_recap
|
||||||
dest: artifacts/session_recap.md
|
dest: artifacts/session_recap.md
|
||||||
required: true
|
required: true
|
||||||
@@ -138,7 +146,7 @@ Rules:
|
|||||||
| `pipeline.cache.s3_audio` | bool | No | `true` |
|
| `pipeline.cache.s3_audio` | bool | No | `true` |
|
||||||
| `pipeline.publish.enabled` | bool | No | `true` |
|
| `pipeline.publish.enabled` | bool | No | `true` |
|
||||||
| `pipeline.publish.upload_run` | bool | No | `true` |
|
| `pipeline.publish.upload_run` | bool | No | `true` |
|
||||||
| `pipeline.publish.outputs[]` | list | No | defaults to final trimmed transcript output |
|
| `pipeline.publish.outputs[]` | list | No | defaults to final trimmed JSON plus final and final-trimmed Markdown outputs |
|
||||||
| `pipeline.publish.outputs[].source` | string | Yes (per rule) | must reference built-in or configured artifact source |
|
| `pipeline.publish.outputs[].source` | string | Yes (per rule) | must reference built-in or configured artifact source |
|
||||||
| `pipeline.publish.outputs[].dest` | string | Conditional | derived if omitted and source supports derivation |
|
| `pipeline.publish.outputs[].dest` | string | Conditional | derived if omitted and source supports derivation |
|
||||||
| `pipeline.publish.outputs[].required` | bool | No | `true` |
|
| `pipeline.publish.outputs[].required` | bool | No | `true` |
|
||||||
@@ -178,16 +186,22 @@ Rules:
|
|||||||
| `pipeline.normalize.output_path` | string | No | `transcripts/final.json` |
|
| `pipeline.normalize.output_path` | string | No | `transcripts/final.json` |
|
||||||
| `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` |
|
| `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` |
|
||||||
| `pipeline.normalize.report` | bool | No | `true` |
|
| `pipeline.normalize.report` | bool | No | `true` |
|
||||||
| `pipeline.trim.enabled` | bool | No | `false` |
|
| `pipeline.trim.enabled` | bool | No | `true` |
|
||||||
| `pipeline.trim.output_path` | string | Conditional | required when trim enabled |
|
| `pipeline.trim.output_path` | string | No | `transcripts/final.trimmed.json` |
|
||||||
| `pipeline.trim.bounds.prompt_id` | string | Conditional | required when trim enabled |
|
| `pipeline.trim.bounds.prompt_id` | string | No | `dnd.session_bounds` |
|
||||||
| `pipeline.trim.bounds.profile_id` | string | No | empty |
|
| `pipeline.trim.bounds.profile_id` | string | No | empty |
|
||||||
| `pipeline.trim.bounds.transcript_input_name` | string | Conditional | required when trim enabled |
|
| `pipeline.trim.bounds.transcript_input_name` | string | No | `transcript` |
|
||||||
| `pipeline.trim.bounds.output_path` | string | Conditional | required when trim enabled |
|
| `pipeline.trim.bounds.output_path` | string | No | `artifacts/session_bounds.json` |
|
||||||
| `pipeline.trim.bounds.timeout` | duration | No | `10m` |
|
| `pipeline.trim.bounds.timeout` | duration | No | `10m` |
|
||||||
| `pipeline.trim.bounds.render_debug` | bool | No | `false` |
|
| `pipeline.trim.bounds.render_debug` | bool | No | `false` |
|
||||||
| `pipeline.trim.bounds.render_output_path` | string | Conditional | required when `render_debug` is true |
|
| `pipeline.trim.bounds.render_output_path` | string | Conditional | required when `render_debug` is true |
|
||||||
| `pipeline.trim.seriatim.report` | bool | No | `false` |
|
| `pipeline.trim.seriatim.report` | bool | No | `false` |
|
||||||
|
| `pipeline.render.enabled` | bool | No | `true` |
|
||||||
|
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
|
||||||
|
| `pipeline.render.title` | string | No | empty (falls back to `session.title` when set) |
|
||||||
|
| `pipeline.render.include_timestamps` | bool | No | `true` |
|
||||||
|
| `pipeline.render.include_segment_ids` | bool | No | `true` |
|
||||||
|
| `pipeline.render.include_metadata` | bool | No | `false` |
|
||||||
| `pipeline.scriptorium.binary` | string | No | `scriptorium` |
|
| `pipeline.scriptorium.binary` | string | No | `scriptorium` |
|
||||||
| `pipeline.scriptorium.config_path` | string | No | empty |
|
| `pipeline.scriptorium.config_path` | string | No | empty |
|
||||||
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
||||||
@@ -217,7 +231,7 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
|
|||||||
|
|
||||||
| Field | Type | Required | Rule |
|
| Field | Type | Required | Rule |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `source` | string | Yes | built-in runtime source, `narratio.artifact.<name>`, or `narratio.previous_session.artifact.<name>` |
|
| `source` | string | Yes | built-in runtime source, prepared input source, `narratio.artifact.<name>`, or `narratio.previous_session.artifact.<name>` |
|
||||||
| `artifact` | string | No | optional passthrough adapter field |
|
| `artifact` | string | No | optional passthrough adapter field |
|
||||||
| `path` | string | No | optional passthrough adapter field |
|
| `path` | string | No | optional passthrough adapter field |
|
||||||
| `required` | bool | No | optional input requirement |
|
| `required` | bool | No | optional input requirement |
|
||||||
@@ -231,6 +245,8 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
|
|||||||
| `inputs.speakers_file` | string | Yes | stable input default |
|
| `inputs.speakers_file` | string | Yes | stable input default |
|
||||||
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
||||||
| `inputs.glossary_file` | string | Yes | stable input default |
|
| `inputs.glossary_file` | string | Yes | stable input default |
|
||||||
|
| `inputs.players_file` | string | Yes | stable input default |
|
||||||
|
| `inputs.party_file` | string | Yes | stable input default |
|
||||||
|
|
||||||
### Session
|
### Session
|
||||||
|
|
||||||
@@ -244,6 +260,8 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
|
|||||||
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
|
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
||||||
|
| `inputs.players_file` | string | No | overrides campaign stable input |
|
||||||
|
| `inputs.party_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||||
| `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode |
|
| `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ These docs cover what Narratio expects from external tools and what each adapter
|
|||||||
|
|
||||||
## Integration Contracts
|
## Integration Contracts
|
||||||
- `audita.md`: transcript polishing adapter (`audita process`).
|
- `audita.md`: transcript polishing adapter (`audita process`).
|
||||||
- `seriatim.md`: merge/normalize/trim adapter (`seriatim`).
|
- `seriatim.md`: merge/normalize/trim/render adapter (`seriatim`).
|
||||||
- `scriptorium.md`: artifact run/render adapter (`scriptorium run|render`).
|
- `scriptorium.md`: artifact run/render adapter (`scriptorium run|render`).
|
||||||
|
|
||||||
## Related Canonical Docs
|
## Related Canonical Docs
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Integration: Seriatim
|
# Integration: Seriatim
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Define the Seriatim adapter contract used by `merge`, `normalize`, and `trim`.
|
Define the Seriatim adapter contract used by `merge`, `normalize`, `trim`, and `render`.
|
||||||
|
|
||||||
## Adapter Boundary
|
## Adapter Boundary
|
||||||
Interface:
|
Interface:
|
||||||
@@ -10,6 +10,7 @@ Interface:
|
|||||||
- `Run(ctx, MergeRequest)`
|
- `Run(ctx, MergeRequest)`
|
||||||
- `Normalize(ctx, NormalizeRequest)`
|
- `Normalize(ctx, NormalizeRequest)`
|
||||||
- `Trim(ctx, TrimRequest)`
|
- `Trim(ctx, TrimRequest)`
|
||||||
|
- `Render(ctx, RenderRequest)`
|
||||||
|
|
||||||
Primary implementation:
|
Primary implementation:
|
||||||
- `internal/adapters/seriatim/SubprocessRunner`
|
- `internal/adapters/seriatim/SubprocessRunner`
|
||||||
@@ -18,11 +19,13 @@ Execution modes:
|
|||||||
- `seriatim merge`
|
- `seriatim merge`
|
||||||
- `seriatim normalize`
|
- `seriatim normalize`
|
||||||
- `seriatim trim`
|
- `seriatim trim`
|
||||||
|
- `seriatim render`
|
||||||
|
|
||||||
## Request/Result Contracts
|
## Request/Result Contracts
|
||||||
- `MergeRequest`/`MergeResult`: multi-input merge to base transcript, optional report.
|
- `MergeRequest`/`MergeResult`: multi-input merge to base transcript, optional report.
|
||||||
- `NormalizeRequest`/`NormalizeResult`: transcript normalization with explicit schema.
|
- `NormalizeRequest`/`NormalizeResult`: transcript normalization with explicit schema.
|
||||||
- `TrimRequest`/`TrimResult`: transcript trimming with required keep selector.
|
- `TrimRequest`/`TrimResult`: transcript trimming with required keep selector.
|
||||||
|
- `RenderRequest`/`RenderResult`: transcript-to-markdown rendering with explicit format and render booleans.
|
||||||
|
|
||||||
Results include output/log/config paths, timing, exit code, and metadata.
|
Results include output/log/config paths, timing, exit code, and metadata.
|
||||||
|
|
||||||
@@ -36,9 +39,11 @@ Runner construction validates:
|
|||||||
Invocation fails on:
|
Invocation fails on:
|
||||||
- missing required request paths/inputs;
|
- missing required request paths/inputs;
|
||||||
- invalid normalize schema override;
|
- invalid normalize schema override;
|
||||||
|
- unsupported render format;
|
||||||
- subprocess failure;
|
- subprocess failure;
|
||||||
- invalid JSON outputs;
|
- invalid JSON outputs for merge/normalize/trim;
|
||||||
- missing `segments` array for normalize/trim transcript outputs.
|
- missing `segments` array for normalize/trim transcript outputs;
|
||||||
|
- empty render output files.
|
||||||
|
|
||||||
When report paths are provided/enabled, report files must parse as JSON.
|
When report paths are provided/enabled, report files must parse as JSON.
|
||||||
|
|
||||||
@@ -49,7 +54,7 @@ When report paths are provided/enabled, report files must parse as JSON.
|
|||||||
- adapter does not write manifests or choose stage inputs.
|
- adapter does not write manifests or choose stage inputs.
|
||||||
|
|
||||||
## Config Mapping
|
## Config Mapping
|
||||||
Config fields consumed through runner/stage wiring are under `pipeline.seriatim.*`.
|
Config fields consumed through runner/stage wiring are under `pipeline.seriatim.*` and `pipeline.render.*`.
|
||||||
|
|
||||||
Maintained examples with Seriatim config:
|
Maintained examples with Seriatim config:
|
||||||
- `examples/pipeline.full.annotated.yml`
|
- `examples/pipeline.full.annotated.yml`
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ Canonical stage order from `internal/stage.All()`:
|
|||||||
4. `polish`
|
4. `polish`
|
||||||
5. `normalize`
|
5. `normalize`
|
||||||
6. `trim`
|
6. `trim`
|
||||||
7. `analyze`
|
7. `render`
|
||||||
8. `publish`
|
8. `analyze`
|
||||||
9. `notify` (placeholder)
|
9. `publish`
|
||||||
|
10. `notify` (placeholder)
|
||||||
|
|
||||||
`notify` is currently a placeholder stage with optional notifier call behavior; it has no persisted pipeline outputs.
|
`notify` is currently a placeholder stage with optional notifier call behavior; it has no persisted pipeline outputs.
|
||||||
|
|
||||||
@@ -39,5 +40,6 @@ Canonical stage order from `internal/stage.All()`:
|
|||||||
- `stage-polish.md`
|
- `stage-polish.md`
|
||||||
- `stage-normalize.md`
|
- `stage-normalize.md`
|
||||||
- `stage-trim.md`
|
- `stage-trim.md`
|
||||||
|
- `stage-render.md`
|
||||||
- `stage-analyze.md`
|
- `stage-analyze.md`
|
||||||
- `stage-publish.md`
|
- `stage-publish.md`
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ Define canonical artifact IDs, runtime catalog behavior, source resolution rules
|
|||||||
- `narratio.transcript.polished` -> `transcripts/polished.json` (`polish`)
|
- `narratio.transcript.polished` -> `transcripts/polished.json` (`polish`)
|
||||||
- `narratio.transcript.final` -> `transcripts/final.json` (`normalize`)
|
- `narratio.transcript.final` -> `transcripts/final.json` (`normalize`)
|
||||||
- `narratio.transcript.final_trimmed` -> `transcripts/final.trimmed.json` (`trim`)
|
- `narratio.transcript.final_trimmed` -> `transcripts/final.trimmed.json` (`trim`)
|
||||||
|
- `narratio.transcript.final_markdown` -> `transcripts/final.md` (`render`)
|
||||||
|
- `narratio.transcript.final_trimmed_markdown` -> `transcripts/final.trimmed.md` (`render`)
|
||||||
- `narratio.bounds.session` -> `artifacts/session_bounds.json` (`trim`)
|
- `narratio.bounds.session` -> `artifacts/session_bounds.json` (`trim`)
|
||||||
|
|
||||||
## Configured and Previous-Session Sources
|
## Configured and Previous-Session Sources
|
||||||
@@ -53,7 +55,8 @@ Previous-session sources (`narratio.previous_session.artifact.*`):
|
|||||||
|
|
||||||
Validation by content type:
|
Validation by content type:
|
||||||
|
|
||||||
- transcript built-ins: JSON with top-level `segments` array;
|
- transcript JSON built-ins: JSON with top-level `segments` array;
|
||||||
|
- transcript Markdown built-ins: non-empty text file;
|
||||||
- bounds built-in: valid JSON;
|
- bounds built-in: valid JSON;
|
||||||
- configured/previous-session artifact files: non-empty text file.
|
- configured/previous-session artifact files: non-empty text file.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Execute selected configured Scriptorium artifacts in dependency order and materi
|
|||||||
|
|
||||||
Supported source families:
|
Supported source families:
|
||||||
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
|
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
|
||||||
|
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`, `narratio.input.glossary`
|
||||||
- configured artifacts: `narratio.artifact.<key>`
|
- configured artifacts: `narratio.artifact.<key>`
|
||||||
- previous-session cache: `narratio.previous_session.artifact.<key>`
|
- previous-session cache: `narratio.previous_session.artifact.<key>`
|
||||||
|
|
||||||
@@ -23,13 +24,16 @@ Supported source families:
|
|||||||
- marks non-executable configured artifacts as reusable when output files already exist.
|
- marks non-executable configured artifacts as reusable when output files already exist.
|
||||||
- validates selected artifact dependency order (cycle-safe topo ordering).
|
- validates selected artifact dependency order (cycle-safe topo ordering).
|
||||||
- resolves required/optional inputs per artifact source definition.
|
- resolves required/optional inputs per artifact source definition.
|
||||||
|
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
|
||||||
- resolves previous-session sources from local `previous/` cache only.
|
- resolves previous-session sources from local `previous/` cache only.
|
||||||
- runs optional render-debug, then artifact execution.
|
- runs optional render-debug, then artifact execution.
|
||||||
- validates non-empty output files and materializes canonical outputs.
|
- validates non-empty output files and materializes canonical outputs.
|
||||||
|
|
||||||
## Failure Semantics
|
## Failure Semantics
|
||||||
- required missing configured/previous-session inputs fail.
|
- required missing configured/previous-session inputs fail.
|
||||||
|
- missing required prepared stable input source includes prepare rerun guidance.
|
||||||
- missing required previous-session source includes prepare rerun guidance.
|
- missing required previous-session source includes prepare rerun guidance.
|
||||||
|
- missing required `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` inputs includes render rerun guidance.
|
||||||
- dependency cycles or unavailable required dependencies fail.
|
- dependency cycles or unavailable required dependencies fail.
|
||||||
- adapter validation failures fail stage.
|
- adapter validation failures fail stage.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
- resolved `campaign.yml`, `session.yml`, and pipeline config
|
- resolved `campaign.yml`, `session.yml`, and pipeline config
|
||||||
- stable input files (`speakers`, `autocorrect`, `glossary`)
|
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
|
||||||
- audio source:
|
- audio source:
|
||||||
- local `audio_dir`/`audio_files`, or
|
- local `audio_dir`/`audio_files`, or
|
||||||
- S3 `audio_s3.prefix`
|
- S3 `audio_s3.prefix`
|
||||||
@@ -18,6 +18,8 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
- `inputs/speakers.yml`
|
- `inputs/speakers.yml`
|
||||||
- `inputs/autocorrect.yml`
|
- `inputs/autocorrect.yml`
|
||||||
- `inputs/glossary.yml`
|
- `inputs/glossary.yml`
|
||||||
|
- `inputs/players.yml`
|
||||||
|
- `inputs/party.yml`
|
||||||
- `audio/*.flac`
|
- `audio/*.flac`
|
||||||
- optional `previous/manifest.json`
|
- optional `previous/manifest.json`
|
||||||
- optional `previous/artifacts/**`
|
- optional `previous/artifacts/**`
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
Upload run/session outputs to object storage and atomically advance remote current state.
|
Upload run/session outputs to object storage and atomically advance remote current state.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
- successful prerequisite stages: `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`
|
- successful prerequisite stages: `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `render`, `analyze`
|
||||||
- run root `runs/{run_id}/**`
|
- run root `runs/{run_id}/**`
|
||||||
- publish output rules (`pipeline.publish.outputs`)
|
- publish output rules (`pipeline.publish.outputs`)
|
||||||
- effective publish locks (static + remote merged lock set)
|
- effective publish locks (static + remote merged lock set)
|
||||||
|
|||||||
29
docs/internal/stage-render.md
Normal file
29
docs/internal/stage-render.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Stage: render
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
- `narratio.transcript.final` (`transcripts/final.json`)
|
||||||
|
- `narratio.transcript.final_trimmed` (`transcripts/final.trimmed.json`)
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
- `narratio.transcript.final_markdown` -> `transcripts/final.md`
|
||||||
|
- `narratio.transcript.final_trimmed_markdown` -> `transcripts/final.trimmed.md`
|
||||||
|
|
||||||
|
## Key Behavior
|
||||||
|
- uses `pipeline.render` settings (enabled/format/title/booleans).
|
||||||
|
- resolves inputs manifest-first, then canonical fallback.
|
||||||
|
- writes run-local outputs first, then materializes canonical session outputs.
|
||||||
|
- records input provenance, output paths, adapter metadata, logs, and generated config refs.
|
||||||
|
- skips with stage metadata when `pipeline.render.enabled=false`.
|
||||||
|
|
||||||
|
## Failure Semantics
|
||||||
|
- missing normalized input fails with normalize rerun guidance.
|
||||||
|
- missing trimmed input fails with trim rerun guidance.
|
||||||
|
- adapter/subprocess failure fails stage.
|
||||||
|
- empty render output files fail validation.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
- only `format: markdown` is supported.
|
||||||
|
- render stage owns production of built-in Markdown transcript sources.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Stage: trim
|
# Stage: trim
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Produce a final-trimmed transcript; optionally generate bounds-driven trim.
|
Produce a final-trimmed transcript. By default, the stage generates bounds and applies a bounds-driven trim.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
- `transcripts/final.json`
|
- `transcripts/final.json`
|
||||||
@@ -11,9 +11,6 @@ Produce a final-trimmed transcript; optionally generate bounds-driven trim.
|
|||||||
- when trim enabled: `artifacts/session_bounds.json`
|
- when trim enabled: `artifacts/session_bounds.json`
|
||||||
|
|
||||||
## Key Behavior
|
## Key Behavior
|
||||||
When `trim.enabled=false`:
|
|
||||||
- copies normalized transcript to trimmed output.
|
|
||||||
|
|
||||||
When `trim.enabled=true`:
|
When `trim.enabled=true`:
|
||||||
- runs Scriptorium bounds artifact generation;
|
- runs Scriptorium bounds artifact generation;
|
||||||
- optionally runs render-debug output generation;
|
- optionally runs render-debug output generation;
|
||||||
@@ -22,6 +19,9 @@ When `trim.enabled=true`:
|
|||||||
- either copies unchanged transcript or runs Seriatim trim;
|
- either copies unchanged transcript or runs Seriatim trim;
|
||||||
- validates trimmed transcript and materializes bounds output.
|
- validates trimmed transcript and materializes bounds output.
|
||||||
|
|
||||||
|
When `trim.enabled=false`:
|
||||||
|
- copies normalized transcript to trimmed output.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
- normalized transcript is required input.
|
- normalized transcript is required input.
|
||||||
- bounds output exists only in enabled trim path.
|
- bounds output exists only in enabled trim path.
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ narratio session init 2026-04-04 --remote --force
|
|||||||
|
|
||||||
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
|
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
|
||||||
|
|
||||||
|
Campaigns must provide stable input files for speakers, autocorrect, glossary, players, and party. Session files may override those paths for one session. The `prepare` stage materializes them under `inputs/`; configured Scriptorium artifacts can reference prepared `players`, `party`, and `glossary` files with `narratio.input.players`, `narratio.input.party`, and `narratio.input.glossary`.
|
||||||
|
|
||||||
## Standard Session Workflow
|
## Standard Session Workflow
|
||||||
|
|
||||||
1. Select pipeline/campaign/session config.
|
1. Select pipeline/campaign/session config.
|
||||||
@@ -73,9 +75,10 @@ Canonical stage order:
|
|||||||
4. `polish`
|
4. `polish`
|
||||||
5. `normalize`
|
5. `normalize`
|
||||||
6. `trim`
|
6. `trim`
|
||||||
7. `analyze`
|
7. `render`
|
||||||
8. `publish`
|
8. `analyze`
|
||||||
9. `notify`
|
9. `publish`
|
||||||
|
10. `notify`
|
||||||
|
|
||||||
Execution rules:
|
Execution rules:
|
||||||
|
|
||||||
@@ -244,6 +247,7 @@ Rules:
|
|||||||
## Operational Caveats
|
## Operational Caveats
|
||||||
|
|
||||||
- Local and S3 audio modes are mutually exclusive.
|
- Local and S3 audio modes are mutually exclusive.
|
||||||
- Publish requires prerequisite stages through analyze to be succeeded.
|
- Publish requires prerequisite stages through `render` and `analyze` to be succeeded.
|
||||||
|
- Markdown publish defaults require render outputs (`transcripts/final.md` and `transcripts/final.trimmed.md`).
|
||||||
- Restore requires configured object storage and committed remote current state.
|
- Restore requires configured object storage and committed remote current state.
|
||||||
- Storage-backed commands load filesystem secrets before object-store initialization.
|
- Storage-backed commands load filesystem secrets before object-store initialization.
|
||||||
|
|||||||
@@ -217,6 +217,33 @@ Safe fix:
|
|||||||
- correct publish source/destination rules;
|
- correct publish source/destination rules;
|
||||||
- retry after storage failure is resolved.
|
- retry after storage failure is resolved.
|
||||||
|
|
||||||
|
## Render markdown source missing
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- analyze or publish fails because `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` is unavailable.
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- render stage was not executed after transcript changes;
|
||||||
|
- render stage failed before producing canonical markdown outputs.
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session status 2026-04-04
|
||||||
|
narratio run-stage render 2026-04-04 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- rerun render and then retry downstream stage(s):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio run-stage render 2026-04-04 --force
|
||||||
|
narratio run-stage analyze 2026-04-04 --force
|
||||||
|
```
|
||||||
|
|
||||||
## Secrets or storage credential failure
|
## Secrets or storage credential failure
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
|
|||||||
2
examples/campaigns/sample-campaign/party.yml
Normal file
2
examples/campaigns/sample-campaign/party.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- name: Example Hero
|
||||||
|
type: pc
|
||||||
2
examples/campaigns/sample-campaign/players.yml
Normal file
2
examples/campaigns/sample-campaign/players.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- name: Example Player
|
||||||
|
role: player
|
||||||
@@ -48,6 +48,12 @@ publish:
|
|||||||
- source: narratio.transcript.final_trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/final.trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
|
- source: narratio.transcript.final_markdown
|
||||||
|
dest: transcripts/final.md
|
||||||
|
required: true
|
||||||
|
- source: narratio.transcript.final_trimmed_markdown
|
||||||
|
dest: transcripts/final.trimmed.md
|
||||||
|
required: true
|
||||||
- source: narratio.artifact.session_recap
|
- source: narratio.artifact.session_recap
|
||||||
dest: artifacts/session_recap.md
|
dest: artifacts/session_recap.md
|
||||||
required: true
|
required: true
|
||||||
@@ -104,17 +110,16 @@ normalize:
|
|||||||
report: true
|
report: true
|
||||||
|
|
||||||
trim:
|
trim:
|
||||||
# Keep disabled unless bounds prompt integration is configured.
|
# Optional; defaults shown explicitly.
|
||||||
enabled: false
|
enabled: true
|
||||||
output_path: transcripts/final.trimmed.json
|
output_path: transcripts/final.trimmed.json
|
||||||
bounds:
|
bounds:
|
||||||
prompt_id: dnd.session_bounds
|
prompt_id: dnd.session_bounds
|
||||||
profile_id: local-fast
|
profile_id: ""
|
||||||
transcript_input_name: transcript
|
transcript_input_name: transcript
|
||||||
output_path: reports/session_bounds.json
|
output_path: artifacts/session_bounds.json
|
||||||
timeout: 10m
|
timeout: 10m
|
||||||
render_debug: false
|
render_debug: false
|
||||||
render_output_path: reports/session_bounds.render.json
|
|
||||||
seriatim:
|
seriatim:
|
||||||
report: false
|
report: false
|
||||||
|
|
||||||
@@ -138,6 +143,15 @@ scriptorium:
|
|||||||
previous_recap:
|
previous_recap:
|
||||||
source: narratio.previous_session.artifact.session_recap
|
source: narratio.previous_session.artifact.session_recap
|
||||||
required: false
|
required: false
|
||||||
|
players:
|
||||||
|
source: narratio.input.players
|
||||||
|
required: true
|
||||||
|
party:
|
||||||
|
source: narratio.input.party
|
||||||
|
required: true
|
||||||
|
glossary:
|
||||||
|
source: narratio.input.glossary
|
||||||
|
required: false
|
||||||
vars:
|
vars:
|
||||||
session_id: true
|
session_id: true
|
||||||
session_date: true
|
session_date: true
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ publish:
|
|||||||
- source: narratio.transcript.final_trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/final.trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
|
- source: narratio.transcript.final_markdown
|
||||||
|
dest: transcripts/final.md
|
||||||
|
required: true
|
||||||
|
- source: narratio.transcript.final_trimmed_markdown
|
||||||
|
dest: transcripts/final.trimmed.md
|
||||||
|
required: true
|
||||||
- source: narratio.artifact.session_recap
|
- source: narratio.artifact.session_recap
|
||||||
dest: artifacts/session_recap.md
|
dest: artifacts/session_recap.md
|
||||||
required: true
|
required: true
|
||||||
@@ -65,9 +71,6 @@ normalize:
|
|||||||
output_schema: seriatim-intermediate
|
output_schema: seriatim-intermediate
|
||||||
report: true
|
report: true
|
||||||
|
|
||||||
trim:
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
scriptorium:
|
scriptorium:
|
||||||
binary: scriptorium
|
binary: scriptorium
|
||||||
config_path: /usr/local/etc/scriptorium/config.yml
|
config_path: /usr/local/etc/scriptorium/config.yml
|
||||||
@@ -87,6 +90,15 @@ scriptorium:
|
|||||||
previous_recap:
|
previous_recap:
|
||||||
source: narratio.previous_session.artifact.session_recap
|
source: narratio.previous_session.artifact.session_recap
|
||||||
required: false
|
required: false
|
||||||
|
players:
|
||||||
|
source: narratio.input.players
|
||||||
|
required: true
|
||||||
|
party:
|
||||||
|
source: narratio.input.party
|
||||||
|
required: true
|
||||||
|
glossary:
|
||||||
|
source: narratio.input.glossary
|
||||||
|
required: false
|
||||||
vars:
|
vars:
|
||||||
session_id: true
|
session_id: true
|
||||||
session_date: true
|
session_date: true
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ func (n *NoopRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render returns the requested output path with placeholder metadata.
|
||||||
|
func (n *NoopRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return RenderResult{}, err
|
||||||
|
}
|
||||||
|
if err := materializeRenderPlaceholders(req); err != nil {
|
||||||
|
return RenderResult{}, err
|
||||||
|
}
|
||||||
|
return RenderResult{
|
||||||
|
OutputRenderedPath: req.OutputRenderedPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
InvokedBinary: "noop",
|
||||||
|
Format: req.Format,
|
||||||
|
Title: req.Title,
|
||||||
|
Metadata: map[string]any{"placeholder": true},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FakeRunner captures merge requests and returns deterministic responses.
|
// FakeRunner captures merge requests and returns deterministic responses.
|
||||||
type FakeRunner struct {
|
type FakeRunner struct {
|
||||||
Requests []MergeRequest
|
Requests []MergeRequest
|
||||||
@@ -79,6 +99,9 @@ type FakeRunner struct {
|
|||||||
TrimRequests []TrimRequest
|
TrimRequests []TrimRequest
|
||||||
TrimErr error
|
TrimErr error
|
||||||
TrimResult TrimResult
|
TrimResult TrimResult
|
||||||
|
RenderRequests []RenderRequest
|
||||||
|
RenderErr error
|
||||||
|
RenderResult RenderResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run records request and returns configured response.
|
// Run records request and returns configured response.
|
||||||
@@ -195,6 +218,46 @@ func (f *FakeRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render records request and returns configured response.
|
||||||
|
func (f *FakeRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return RenderResult{}, err
|
||||||
|
}
|
||||||
|
f.RenderRequests = append(f.RenderRequests, req)
|
||||||
|
if f.RenderErr != nil {
|
||||||
|
return RenderResult{}, f.RenderErr
|
||||||
|
}
|
||||||
|
if err := materializeRenderPlaceholders(req); err != nil {
|
||||||
|
return RenderResult{}, err
|
||||||
|
}
|
||||||
|
res := f.RenderResult
|
||||||
|
if res.OutputRenderedPath == "" {
|
||||||
|
res.OutputRenderedPath = req.OutputRenderedPath
|
||||||
|
}
|
||||||
|
if res.StdoutLogPath == "" {
|
||||||
|
res.StdoutLogPath = req.StdoutLogPath
|
||||||
|
}
|
||||||
|
if res.StderrLogPath == "" {
|
||||||
|
res.StderrLogPath = req.StderrLogPath
|
||||||
|
}
|
||||||
|
if res.GeneratedConfigPath == "" {
|
||||||
|
res.GeneratedConfigPath = req.GeneratedConfigPath
|
||||||
|
}
|
||||||
|
if res.InvokedBinary == "" {
|
||||||
|
res.InvokedBinary = "fake"
|
||||||
|
}
|
||||||
|
if res.Format == "" {
|
||||||
|
res.Format = req.Format
|
||||||
|
}
|
||||||
|
if res.Title == "" {
|
||||||
|
res.Title = req.Title
|
||||||
|
}
|
||||||
|
if res.Metadata == nil {
|
||||||
|
res.Metadata = map[string]any{"fake": true}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
func materializePlaceholders(req MergeRequest) error {
|
func materializePlaceholders(req MergeRequest) error {
|
||||||
if req.OutputMergedTranscriptPath != "" {
|
if req.OutputMergedTranscriptPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil {
|
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil {
|
||||||
@@ -301,3 +364,39 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func materializeRenderPlaceholders(req RenderRequest) error {
|
||||||
|
if req.OutputRenderedPath != "" {
|
||||||
|
if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.GeneratedConfigPath != "" {
|
||||||
|
payload := map[string]any{
|
||||||
|
"schema": "seriatim.generated.v1",
|
||||||
|
"placeholder": true,
|
||||||
|
"command": "render",
|
||||||
|
"input_path": req.InputTranscriptPath,
|
||||||
|
"output_path": req.OutputRenderedPath,
|
||||||
|
"format": req.Format,
|
||||||
|
"title": req.Title,
|
||||||
|
"include_timestamps": req.IncludeTimestamps,
|
||||||
|
"include_segment_ids": req.IncludeSegmentIDs,
|
||||||
|
"include_metadata": req.IncludeMetadata,
|
||||||
|
}
|
||||||
|
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.StdoutLogPath != "" {
|
||||||
|
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.StderrLogPath != "" {
|
||||||
|
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,3 +148,58 @@ func TestFakeRunnerNormalizeError(t *testing.T) {
|
|||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFakeRunnerRenderCapturesRequestAndReturnsPath(t *testing.T) {
|
||||||
|
fake := &FakeRunner{}
|
||||||
|
dir := t.TempDir()
|
||||||
|
req := RenderRequest{
|
||||||
|
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.render.yml"),
|
||||||
|
InputTranscriptPath: filepath.Join(dir, "transcripts", "final.trimmed.json"),
|
||||||
|
OutputRenderedPath: filepath.Join(dir, "transcripts", "final.trimmed.md"),
|
||||||
|
Format: "markdown",
|
||||||
|
Title: "Session render",
|
||||||
|
IncludeTimestamps: true,
|
||||||
|
IncludeSegmentIDs: false,
|
||||||
|
IncludeMetadata: true,
|
||||||
|
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.render.stdout.log"),
|
||||||
|
StderrLogPath: filepath.Join(dir, "logs", "seriatim.render.stderr.log"),
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := fake.Render(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RenderRequests) != 1 || fake.RenderRequests[0].GeneratedConfigPath == "" {
|
||||||
|
t.Fatalf("render requests = %#v, want captured request", fake.RenderRequests)
|
||||||
|
}
|
||||||
|
if res.OutputRenderedPath != req.OutputRenderedPath {
|
||||||
|
t.Fatalf("rendered path = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath)
|
||||||
|
}
|
||||||
|
if res.Format != req.Format {
|
||||||
|
t.Fatalf("format = %q, want %q", res.Format, req.Format)
|
||||||
|
}
|
||||||
|
if res.Title != req.Title {
|
||||||
|
t.Fatalf("title = %q, want %q", res.Title, req.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgData, err := os.ReadFile(req.GeneratedConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read generated config: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(cfgData), "command: render") {
|
||||||
|
t.Fatalf("generated config = %q, want render command marker", string(cfgData))
|
||||||
|
}
|
||||||
|
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath, req.OutputRenderedPath} {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("expected file %q to exist: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeRunnerRenderError(t *testing.T) {
|
||||||
|
fake := &FakeRunner{RenderErr: errors.New("boom")}
|
||||||
|
_, err := fake.Render(context.Background(), RenderRequest{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package seriatim declares the adapter contract for transcript merge/normalize/trim execution.
|
// Package seriatim declares the adapter contract for transcript merge/normalize/trim/render execution.
|
||||||
package seriatim
|
package seriatim
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,11 +6,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Runner is the adapter boundary for seriatim merge/normalize/trim invocations.
|
// Runner is the adapter boundary for seriatim merge/normalize/trim/render invocations.
|
||||||
type Runner interface {
|
type Runner interface {
|
||||||
Run(ctx context.Context, req MergeRequest) (MergeResult, error)
|
Run(ctx context.Context, req MergeRequest) (MergeResult, error)
|
||||||
Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error)
|
Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error)
|
||||||
Trim(ctx context.Context, req TrimRequest) (TrimResult, error)
|
Trim(ctx context.Context, req TrimRequest) (TrimResult, error)
|
||||||
|
Render(ctx context.Context, req RenderRequest) (RenderResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergeRequest describes a seriatim merge invocation.
|
// MergeRequest describes a seriatim merge invocation.
|
||||||
@@ -90,3 +91,33 @@ type TrimResult struct {
|
|||||||
KeepSelector string
|
KeepSelector string
|
||||||
Metadata map[string]any
|
Metadata map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderRequest describes a seriatim render invocation.
|
||||||
|
type RenderRequest struct {
|
||||||
|
Binary string
|
||||||
|
InputTranscriptPath string
|
||||||
|
OutputRenderedPath string
|
||||||
|
Format string
|
||||||
|
Title string
|
||||||
|
IncludeTimestamps bool
|
||||||
|
IncludeSegmentIDs bool
|
||||||
|
IncludeMetadata bool
|
||||||
|
StdoutLogPath string
|
||||||
|
StderrLogPath string
|
||||||
|
GeneratedConfigPath string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderResult describes a render output.
|
||||||
|
type RenderResult struct {
|
||||||
|
OutputRenderedPath string
|
||||||
|
StdoutLogPath string
|
||||||
|
StderrLogPath string
|
||||||
|
GeneratedConfigPath string
|
||||||
|
ExitCode int
|
||||||
|
Duration time.Duration
|
||||||
|
InvokedBinary string
|
||||||
|
Format string
|
||||||
|
Title string
|
||||||
|
Metadata map[string]any
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||||
)
|
)
|
||||||
@@ -384,6 +385,96 @@ func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render executes Seriatim render with deterministic flags and validates non-empty text output.
|
||||||
|
func (r *SubprocessRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
|
||||||
|
if r == nil {
|
||||||
|
return RenderResult{}, fmt.Errorf("seriatim subprocess runner is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.InputTranscriptPath) == "" {
|
||||||
|
return RenderResult{}, fmt.Errorf("seriatim render input path is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.OutputRenderedPath) == "" {
|
||||||
|
return RenderResult{}, fmt.Errorf("seriatim render output path is required")
|
||||||
|
}
|
||||||
|
format := strings.TrimSpace(req.Format)
|
||||||
|
if format == "" {
|
||||||
|
format = "markdown"
|
||||||
|
}
|
||||||
|
if format != "markdown" {
|
||||||
|
return RenderResult{}, fmt.Errorf("seriatim render format %q is unsupported", req.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
binary := r.binary
|
||||||
|
if strings.TrimSpace(req.Binary) != "" {
|
||||||
|
binary = strings.TrimSpace(req.Binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := r.timeout
|
||||||
|
if req.Timeout < 0 {
|
||||||
|
return RenderResult{}, fmt.Errorf("seriatim render timeout must be >= 0")
|
||||||
|
}
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
timeout = req.Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
args := buildRenderArgs(req, format)
|
||||||
|
if req.GeneratedConfigPath != "" {
|
||||||
|
if err := writeRenderInvocationConfig(req, args, binary, timeout, format); err != nil {
|
||||||
|
return RenderResult{}, fmt.Errorf("write seriatim render invocation config %q: %w", req.GeneratedConfigPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runRes, err := subprocess.Run(ctx, subprocess.RunRequest{
|
||||||
|
Executable: binary,
|
||||||
|
Args: args,
|
||||||
|
Timeout: timeout,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RenderResult{
|
||||||
|
OutputRenderedPath: req.OutputRenderedPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
ExitCode: runRes.ExitCode,
|
||||||
|
Duration: runRes.Duration,
|
||||||
|
InvokedBinary: binary,
|
||||||
|
Format: format,
|
||||||
|
Title: req.Title,
|
||||||
|
}, fmt.Errorf("run seriatim render (binary=%q): %w", binary, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateNonEmptyTextFile(req.OutputRenderedPath); err != nil {
|
||||||
|
return RenderResult{
|
||||||
|
OutputRenderedPath: req.OutputRenderedPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
ExitCode: runRes.ExitCode,
|
||||||
|
Duration: runRes.Duration,
|
||||||
|
InvokedBinary: binary,
|
||||||
|
Format: format,
|
||||||
|
Title: req.Title,
|
||||||
|
}, fmt.Errorf("validate seriatim rendered output %q: %w", req.OutputRenderedPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RenderResult{
|
||||||
|
OutputRenderedPath: req.OutputRenderedPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
ExitCode: runRes.ExitCode,
|
||||||
|
Duration: runRes.Duration,
|
||||||
|
InvokedBinary: binary,
|
||||||
|
Format: format,
|
||||||
|
Title: req.Title,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"adapter": "seriatim_subprocess",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string {
|
func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string {
|
||||||
args := []string{"merge"}
|
args := []string{"merge"}
|
||||||
|
|
||||||
@@ -480,6 +571,22 @@ func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildRenderArgs(req RenderRequest, format string) []string {
|
||||||
|
args := []string{
|
||||||
|
"render",
|
||||||
|
"--input-file", req.InputTranscriptPath,
|
||||||
|
"--output-file", req.OutputRenderedPath,
|
||||||
|
"--format", format,
|
||||||
|
"--include-timestamps=" + strconv.FormatBool(req.IncludeTimestamps),
|
||||||
|
"--include-segment-ids=" + strconv.FormatBool(req.IncludeSegmentIDs),
|
||||||
|
"--include-metadata=" + strconv.FormatBool(req.IncludeMetadata),
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Title) != "" {
|
||||||
|
args = append(args, "--title", req.Title)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error {
|
func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"schema": "seriatim.generated.v1",
|
"schema": "seriatim.generated.v1",
|
||||||
@@ -509,6 +616,24 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
|
|||||||
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
|
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error {
|
||||||
|
payload := map[string]any{
|
||||||
|
"schema": "seriatim.generated.v1",
|
||||||
|
"command": "render",
|
||||||
|
"binary": binary,
|
||||||
|
"args": args,
|
||||||
|
"timeout": timeout.String(),
|
||||||
|
"input_path": req.InputTranscriptPath,
|
||||||
|
"output_path": req.OutputRenderedPath,
|
||||||
|
"format": format,
|
||||||
|
"title": req.Title,
|
||||||
|
"include_timestamps": req.IncludeTimestamps,
|
||||||
|
"include_segment_ids": req.IncludeSegmentIDs,
|
||||||
|
"include_metadata": req.IncludeMetadata,
|
||||||
|
}
|
||||||
|
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
func validateJSONFile(path string) error {
|
func validateJSONFile(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -541,3 +666,20 @@ func validateJSONFileWithSegments(path string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateNonEmptyTextFile(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read file: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return fmt.Errorf("file is empty")
|
||||||
|
}
|
||||||
|
if !utf8.Valid(data) {
|
||||||
|
return fmt.Errorf("file is not valid utf-8 text")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(data)) == "" {
|
||||||
|
return fmt.Errorf("file has no non-whitespace content")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -569,6 +569,156 @@ func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
|
||||||
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath)
|
||||||
|
|
||||||
|
wrapper := writeHelperWrapper(t)
|
||||||
|
runner := mustRunner(t, wrapper, false)
|
||||||
|
req := renderReqForTest(t)
|
||||||
|
|
||||||
|
res, err := runner.Render(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
if res.OutputRenderedPath != req.OutputRenderedPath {
|
||||||
|
t.Fatalf("OutputRenderedPath = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath)
|
||||||
|
}
|
||||||
|
if res.Format != req.Format {
|
||||||
|
t.Fatalf("Format = %q, want %q", res.Format, req.Format)
|
||||||
|
}
|
||||||
|
if res.Title != req.Title {
|
||||||
|
t.Fatalf("Title = %q, want %q", res.Title, req.Title)
|
||||||
|
}
|
||||||
|
if res.InvokedBinary != wrapper {
|
||||||
|
t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper)
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
||||||
|
}
|
||||||
|
if res.Duration <= 0 {
|
||||||
|
t.Fatalf("Duration = %s, want >0", res.Duration)
|
||||||
|
}
|
||||||
|
if res.Metadata == nil || res.Metadata["adapter"] != "seriatim_subprocess" {
|
||||||
|
t.Fatalf("Metadata = %#v, want adapter marker", res.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(req.OutputRenderedPath); err != nil {
|
||||||
|
t.Fatalf("rendered output missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(req.StdoutLogPath); err != nil {
|
||||||
|
t.Fatalf("stdout log missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(req.StderrLogPath); err != nil {
|
||||||
|
t.Fatalf("stderr log missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(req.GeneratedConfigPath); err != nil {
|
||||||
|
t.Fatalf("generated config missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := readHelperRecord(t, recordPath)
|
||||||
|
wantArgs := []string{
|
||||||
|
"render",
|
||||||
|
"--input-file", req.InputTranscriptPath,
|
||||||
|
"--output-file", req.OutputRenderedPath,
|
||||||
|
"--format", req.Format,
|
||||||
|
"--include-timestamps=true",
|
||||||
|
"--include-segment-ids=true",
|
||||||
|
"--include-metadata=false",
|
||||||
|
"--title", req.Title,
|
||||||
|
}
|
||||||
|
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
|
||||||
|
t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
|
||||||
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath)
|
||||||
|
|
||||||
|
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||||
|
req := renderReqForTest(t)
|
||||||
|
req.Title = ""
|
||||||
|
if _, err := runner.Render(context.Background(), req); err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := readHelperRecord(t, recordPath)
|
||||||
|
for i := 0; i < len(rec.Args); i++ {
|
||||||
|
if rec.Args[i] == "--title" {
|
||||||
|
t.Fatalf("args = %#v, did not expect --title", rec.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
|
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||||
|
req := renderReqForTest(t)
|
||||||
|
_, err := runner.Render(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Render() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run seriatim render") {
|
||||||
|
t.Fatalf("error = %q, want subprocess context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
|
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||||
|
req := renderReqForTest(t)
|
||||||
|
_, err := runner.Render(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Render() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "validate seriatim rendered output") {
|
||||||
|
t.Fatalf("error = %q, want output validation context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRenderEmptyOutputFails(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "render_empty_output")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
|
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||||
|
req := renderReqForTest(t)
|
||||||
|
_, err := runner.Render(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Render() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "file is empty") {
|
||||||
|
t.Fatalf("error = %q, want empty-file validation", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
|
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
|
||||||
_, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{})
|
_, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -702,6 +852,14 @@ func TestSeriatimSubprocessHelper(t *testing.T) {
|
|||||||
case "normalize_report_missing":
|
case "normalize_report_missing":
|
||||||
writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
|
writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
case "render_success":
|
||||||
|
writeSeriatimHelperFile(outputPath, "# Rendered transcript\n\nHello.\n")
|
||||||
|
_, _ = os.Stdout.WriteString("seriatim helper render stdout\n")
|
||||||
|
_, _ = os.Stderr.WriteString("seriatim helper render stderr\n")
|
||||||
|
os.Exit(0)
|
||||||
|
case "render_empty_output":
|
||||||
|
writeSeriatimHelperFile(outputPath, "")
|
||||||
|
os.Exit(0)
|
||||||
default:
|
default:
|
||||||
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@@ -777,6 +935,25 @@ func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
|
|||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderReqForTest(t *testing.T) RenderRequest {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
input := filepath.Join(dir, "final.trimmed.json")
|
||||||
|
writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
|
||||||
|
return RenderRequest{
|
||||||
|
InputTranscriptPath: input,
|
||||||
|
OutputRenderedPath: filepath.Join(dir, "final.trimmed.md"),
|
||||||
|
Format: "markdown",
|
||||||
|
Title: "Session 42",
|
||||||
|
IncludeTimestamps: true,
|
||||||
|
IncludeSegmentIDs: true,
|
||||||
|
IncludeMetadata: false,
|
||||||
|
GeneratedConfigPath: filepath.Join(dir, "seriatim.render.generated.yml"),
|
||||||
|
StdoutLogPath: filepath.Join(dir, "seriatim.render.stdout.log"),
|
||||||
|
StderrLogPath: filepath.Join(dir, "seriatim.render.stderr.log"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner {
|
func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
coalesce := 3.0
|
coalesce := 3.0
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
@@ -143,7 +143,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=0 skipped=9") {
|
if !strings.Contains(out.String(), "executed=0 skipped=10") {
|
||||||
t.Fatalf("output = %q, want all stages skipped", out.String())
|
t.Fatalf("output = %q, want all stages skipped", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func TestExecuteValidCommands(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
wantOut string
|
wantOut string
|
||||||
}{
|
}{
|
||||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
|
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=10 skipped=0; manifest="},
|
||||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
||||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||||
}
|
}
|
||||||
@@ -227,6 +227,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline.yml: %v", err)
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
@@ -290,6 +292,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline.yml: %v", err)
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
@@ -331,7 +335,7 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
|
|||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=9 skipped=0; manifest=") {
|
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=10 skipped=0; manifest=") {
|
||||||
t.Fatalf("stdout = %q, want successful run output", stdout.String())
|
t.Fatalf("stdout = %q, want successful run output", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,10 +389,14 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`)
|
`)
|
||||||
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(otherDir, "players.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(otherDir, "party.yml"), "[]\n")
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -466,10 +474,13 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
|
|||||||
url = transcribeURL[0]
|
url = transcribeURL[0]
|
||||||
}
|
}
|
||||||
seriatimBinary := writeSeriatimAppTestWrapper(t)
|
seriatimBinary := writeSeriatimAppTestWrapper(t)
|
||||||
|
scriptoriumBinary := writeScriptoriumAppTestWrapper(t)
|
||||||
auditaBinary := writeAuditaAppTestWrapper(t)
|
auditaBinary := writeAuditaAppTestWrapper(t)
|
||||||
t.Setenv("GO_WANT_APP_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_APP_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("GO_WANT_APP_SCRIPTORIUM_HELPER", "1")
|
||||||
t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_LLM_API_KEY", "test-audita-key")
|
t.Setenv("AUDITA_LLM_API_KEY", "test-audita-key")
|
||||||
|
t.Setenv("PATH", filepath.Dir(scriptoriumBinary)+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
pipelineYAML := `workspace:
|
pipelineYAML := `workspace:
|
||||||
root: ` + workspaceRoot + `
|
root: ` + workspaceRoot + `
|
||||||
@@ -514,6 +525,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
|
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
@@ -532,6 +545,8 @@ inputs:
|
|||||||
mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(campaignDir, "players.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
||||||
|
|
||||||
return pipelinePath, campaignPath, sessionPath
|
return pipelinePath, campaignPath, sessionPath
|
||||||
@@ -545,6 +560,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write campaign.yml: %v", err)
|
t.Fatalf("write campaign.yml: %v", err)
|
||||||
@@ -591,6 +608,60 @@ func writeSeriatimAppTestWrapper(t *testing.T) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeScriptoriumAppTestWrapper(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Executable() error = %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "scriptorium")
|
||||||
|
content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestScriptoriumAppHelper -- \"$@\"\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScriptoriumAppHelper(t *testing.T) {
|
||||||
|
if os.Getenv("GO_WANT_APP_SCRIPTORIUM_HELPER") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := os.Args
|
||||||
|
start := -1
|
||||||
|
for i := range args {
|
||||||
|
if args[i] == "--" {
|
||||||
|
start = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < 0 || start >= len(args) {
|
||||||
|
_, _ = os.Stderr.WriteString("missing -- args separator\n")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
runArgs := args[start:]
|
||||||
|
|
||||||
|
outputPath := appSeriatimFlagValue(runArgs, "--out")
|
||||||
|
if strings.TrimSpace(outputPath) == "" {
|
||||||
|
outputPath = appSeriatimFlagValue(runArgs, "--output")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(outputPath) == "" {
|
||||||
|
_, _ = os.Stderr.WriteString("missing output flag\n")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir output dir: %v\n", err))
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outputPath, []byte(`{"trim_action":"copy","warnings":[]}`), 0o644); err != nil {
|
||||||
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err))
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
_, _ = os.Stdout.WriteString("scriptorium helper stdout\n")
|
||||||
|
_, _ = os.Stderr.WriteString("scriptorium helper stderr\n")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSeriatimAppHelper(t *testing.T) {
|
func TestSeriatimAppHelper(t *testing.T) {
|
||||||
if os.Getenv("GO_WANT_APP_SERIATIM_HELPER") != "1" {
|
if os.Getenv("GO_WANT_APP_SERIATIM_HELPER") != "1" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -32,15 +32,10 @@ func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog,
|
|||||||
func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) {
|
func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) {
|
||||||
lockSet := lockSourceSet(locks.All)
|
lockSet := lockSourceSet(locks.All)
|
||||||
fmt.Fprintln(out, "Built-in:")
|
fmt.Fprintln(out, "Built-in:")
|
||||||
for _, id := range []string{
|
for _, transcript := range artifacts.RuntimeTranscriptArtifacts() {
|
||||||
artifacts.ArtifactTranscriptBase,
|
writeArtifactLine(out, transcript.SourceID, lockSet)
|
||||||
artifacts.ArtifactTranscriptPolished,
|
|
||||||
artifacts.ArtifactTranscriptFinal,
|
|
||||||
artifacts.ArtifactTranscriptFinalTrimmed,
|
|
||||||
artifacts.ArtifactBoundsSession,
|
|
||||||
} {
|
|
||||||
writeArtifactLine(out, id, lockSet)
|
|
||||||
}
|
}
|
||||||
|
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||||
fmt.Fprintln(out, "Configured:")
|
fmt.Fprintln(out, "Configured:")
|
||||||
for _, entry := range catalog.ListConfigured() {
|
for _, entry := range catalog.ListConfigured() {
|
||||||
writeArtifactLine(out, entry.SourceID, lockSet)
|
writeArtifactLine(out, entry.SourceID, lockSet)
|
||||||
|
|||||||
@@ -109,12 +109,16 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`), 0o644); err != nil {
|
`), 0o644); err != nil {
|
||||||
t.Fatalf("write explicit campaign: %v", err)
|
t.Fatalf("write explicit campaign: %v", err)
|
||||||
}
|
}
|
||||||
mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(explicitDir, "players.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(explicitDir, "party.yml"), "[]\n")
|
||||||
|
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
var storeInitCalls int
|
var storeInitCalls int
|
||||||
@@ -445,6 +449,9 @@ inputs:
|
|||||||
if !strings.Contains(stdout.String(), "OK audio") {
|
if !strings.Contains(stdout.String(), "OK audio") {
|
||||||
t.Fatalf("stdout = %q, want OK audio", stdout.String())
|
t.Fatalf("stdout = %q, want OK audio", stdout.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "OK inputs players:") || !strings.Contains(stdout.String(), "OK inputs party:") {
|
||||||
|
t.Fatalf("stdout = %q, want players and party input findings", stdout.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
||||||
@@ -862,6 +869,8 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
|
|||||||
"narratio.transcript.final_trimmed locked",
|
"narratio.transcript.final_trimmed locked",
|
||||||
"narratio.transcript.final_trimmed locked remote=published",
|
"narratio.transcript.final_trimmed locked remote=published",
|
||||||
"narratio.transcript.final dest=transcripts/full.json remote=published",
|
"narratio.transcript.final dest=transcripts/full.json remote=published",
|
||||||
|
"Stable input players:",
|
||||||
|
"Stable input party:",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Fatalf("stdout = %q, want %q", out, want)
|
t.Fatalf("stdout = %q, want %q", out, want)
|
||||||
@@ -997,11 +1006,13 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) {
|
|||||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||||
|
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
// The publish stage only checks the manifest statuses and source files.
|
// The publish stage only checks the manifest statuses and source files.
|
||||||
_ = stageName
|
_ = stageName
|
||||||
}
|
}
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`)
|
||||||
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.md"), "# final\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.md"), "# final trimmed\n")
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@@ -1065,7 +1076,7 @@ func writeValidPublishRunConfigFiles(t *testing.T, workspaceRoot string) (string
|
|||||||
m := manifest.New("2026-05-03", nowUTC())
|
m := manifest.New("2026-05-03", nowUTC())
|
||||||
m.Campaign = "sample-campaign"
|
m.Campaign = "sample-campaign"
|
||||||
m.RunID = "20260521T160000Z-test"
|
m.RunID = "20260521T160000Z-test"
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
m.MarkStageSucceeded(name, nowUTC(), nil)
|
m.MarkStageSucceeded(name, nowUTC(), nil)
|
||||||
}
|
}
|
||||||
path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck {
|
|||||||
{name: "speakers", in: cfg.StableInputs.SpeakersFile},
|
{name: "speakers", in: cfg.StableInputs.SpeakersFile},
|
||||||
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
|
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
|
||||||
{name: "glossary", in: cfg.StableInputs.GlossaryFile},
|
{name: "glossary", in: cfg.StableInputs.GlossaryFile},
|
||||||
|
{name: "players", in: cfg.StableInputs.PlayersFile},
|
||||||
|
{name: "party", in: cfg.StableInputs.PartyFile},
|
||||||
}
|
}
|
||||||
out := make([]stableInputCheck, 0, len(items))
|
out := make([]stableInputCheck, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
||||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
t.Fatalf("first output = %q, want workdir prepared", got)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
if !strings.Contains(got, name+": run") {
|
if !strings.Contains(got, name+": run") {
|
||||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
t.Fatalf("first output = %q, missing stage %q", got, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "totals: run=9 skip=0") {
|
if !strings.Contains(got, "totals: run=10 skip=0") {
|
||||||
t.Fatalf("first output = %q, want totals", got)
|
t.Fatalf("first output = %q, want totals", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
|||||||
if !strings.Contains(got, "trim: run") {
|
if !strings.Contains(got, "trim: run") {
|
||||||
t.Fatalf("output = %q, want trim run", got)
|
t.Fatalf("output = %q, want trim run", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "totals: run=7 skip=2") {
|
if !strings.Contains(got, "totals: run=8 skip=2") {
|
||||||
t.Fatalf("output = %q, want totals run=7 skip=2", got)
|
t.Fatalf("output = %q, want totals run=8 skip=2", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +118,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline.yml: %v", err)
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "testing"
|
|||||||
|
|
||||||
func TestBuildFullPlanOrder(t *testing.T) {
|
func TestBuildFullPlanOrder(t *testing.T) {
|
||||||
got := BuildFullPlan()
|
got := BuildFullPlan()
|
||||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"}
|
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
|||||||
@@ -397,6 +397,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline config: %v", err)
|
t.Fatalf("write pipeline config: %v", err)
|
||||||
@@ -407,6 +409,8 @@ inputs:
|
|||||||
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n")
|
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(dir, "players.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
||||||
|
|
||||||
return pipelinePath, campaignPath, sessionPath
|
return pipelinePath, campaignPath, sessionPath
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestDecideStageActions(t *testing.T) {
|
|||||||
|
|
||||||
func TestDownstreamStageNames(t *testing.T) {
|
func TestDownstreamStageNames(t *testing.T) {
|
||||||
got := downstreamStageNames("polish")
|
got := downstreamStageNames("polish")
|
||||||
want := []string{"normalize", "trim", "analyze", "publish", "notify"}
|
want := []string{"normalize", "trim", "render", "analyze", "publish", "notify"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
@@ -52,12 +52,13 @@ func TestInvalidateDownstreamSucceededStages(t *testing.T) {
|
|||||||
m.MarkStageSucceeded("polish", now, nil)
|
m.MarkStageSucceeded("polish", now, nil)
|
||||||
m.MarkStageSucceeded("normalize", now, nil)
|
m.MarkStageSucceeded("normalize", now, nil)
|
||||||
m.MarkStageSucceeded("trim", now, nil)
|
m.MarkStageSucceeded("trim", now, nil)
|
||||||
|
m.MarkStageSucceeded("render", now, nil)
|
||||||
m.MarkStageFailed("analyze", now, "analysis failed")
|
m.MarkStageFailed("analyze", now, "analysis failed")
|
||||||
m.MarkStageSucceeded("publish", now, nil)
|
m.MarkStageSucceeded("publish", now, nil)
|
||||||
m.MarkStageSucceeded("notify", now, nil)
|
m.MarkStageSucceeded("notify", now, nil)
|
||||||
|
|
||||||
got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second))
|
got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second))
|
||||||
want := []string{"normalize", "trim", "publish", "notify"}
|
want := []string{"normalize", "trim", "render", "publish", "notify"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want)
|
t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=7 skipped=2") {
|
if !strings.Contains(out.String(), "executed=8 skipped=2") {
|
||||||
t.Fatalf("output = %q, want executed=7 skipped=2", out.String())
|
t.Fatalf("output = %q, want executed=8 skipped=2", out.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
loaded, err := store.Load(context.Background(), manifestPath)
|
loaded, err := store.Load(context.Background(), manifestPath)
|
||||||
@@ -56,7 +56,7 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
@@ -68,8 +68,8 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=0 skipped=9") {
|
if !strings.Contains(out.String(), "executed=0 skipped=10") {
|
||||||
t.Fatalf("output = %q, want executed=0 skipped=9", out.String())
|
t.Fatalf("output = %q, want executed=0 skipped=10", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ func TestRunForceRerunsSucceeded(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
@@ -97,7 +97,7 @@ func TestRunForceRerunsSucceeded(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=9 skipped=0") {
|
if !strings.Contains(out.String(), "executed=10 skipped=0") {
|
||||||
t.Fatalf("output = %q, want forced full rerun", out.String())
|
t.Fatalf("output = %q, want forced full rerun", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
@@ -196,7 +196,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load manifest after force: %v", err)
|
t.Fatalf("load manifest after force: %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||||
}
|
}
|
||||||
@@ -207,7 +207,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=5 skipped=4") {
|
if !strings.Contains(out.String(), "executed=6 skipped=4") {
|
||||||
t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
|
t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,8 +236,8 @@ func TestRunStageTrimExecutes(t *testing.T) {
|
|||||||
if m.Stages["trim"] == nil || m.Stages["trim"].Status != manifest.StatusSucceeded {
|
if m.Stages["trim"] == nil || m.Stages["trim"].Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("trim stage = %#v, want succeeded", m.Stages["trim"])
|
t.Fatalf("trim stage = %#v, want succeeded", m.Stages["trim"])
|
||||||
}
|
}
|
||||||
if m.Stages["trim"].Metadata == nil || m.Stages["trim"].Metadata["trim_action"] != "copy_disabled" {
|
if m.Stages["trim"].Metadata == nil || m.Stages["trim"].Metadata["trim_action"] != "copy" {
|
||||||
t.Fatalf("trim stage metadata = %#v, want trim_action=copy_disabled", m.Stages["trim"].Metadata)
|
t.Fatalf("trim stage metadata = %#v, want trim_action=copy", m.Stages["trim"].Metadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,3 +266,30 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
|
|||||||
t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"])
|
t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunStageRenderExecutes(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := RunStage(context.Background(), []string{"render", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunStage(render) error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "stage=render executed=1 skipped=0") {
|
||||||
|
t.Fatalf("output = %q, want stage=render executed", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
m, err := store.Load(context.Background(), manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load manifest: %v", err)
|
||||||
|
}
|
||||||
|
if m.Stages["render"] == nil || m.Stages["render"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("render stage = %#v, want succeeded", m.Stages["render"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/logging"
|
"gitea.maximumdirect.net/eric/narratio/internal/logging"
|
||||||
@@ -410,6 +411,8 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
|
|||||||
if stageName == "analyze" {
|
if stageName == "analyze" {
|
||||||
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
|
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
|
||||||
kind = "scriptorium_artifact"
|
kind = "scriptorium_artifact"
|
||||||
|
} else {
|
||||||
|
sourceID = sourceIDForOutputKind(kind)
|
||||||
}
|
}
|
||||||
out = append(out, manifest.ArtifactRecord{
|
out = append(out, manifest.ArtifactRecord{
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
@@ -424,6 +427,22 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sourceIDForOutputKind(kind string) string {
|
||||||
|
trimmed := strings.TrimSpace(kind)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if trimmed == "session_bounds" {
|
||||||
|
return artifacts.ArtifactBoundsSession
|
||||||
|
}
|
||||||
|
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
|
||||||
|
if spec.OutputKind == trimmed {
|
||||||
|
return spec.SourceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) {
|
func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) {
|
||||||
if m == nil || result == nil {
|
if m == nil || result == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ func TestExecuteStagesPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.
|
|||||||
manifestPath := manifestPathFor(cfg)
|
manifestPath := manifestPathFor(cfg)
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
seed.Campaign = cfg.Session.Campaign
|
seed.Campaign = cfg.Session.Campaign
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"} {
|
||||||
seed.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
|
seed.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
@@ -332,8 +332,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(summary.StageNames) != 9 || len(summary.Executed) != 9 || len(summary.Skipped) != 0 {
|
if len(summary.StageNames) != 10 || len(summary.Executed) != 10 || len(summary.Skipped) != 0 {
|
||||||
t.Fatalf("summary = %#v, want all 9 executed", summary)
|
t.Fatalf("summary = %#v, want all 10 executed", summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
@@ -342,7 +342,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
t.Fatalf("Load manifest error = %v", err)
|
t.Fatalf("Load manifest error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
sr := m.Stages[name]
|
sr := m.Stages[name]
|
||||||
if sr == nil {
|
if sr == nil {
|
||||||
t.Fatalf("missing stage record %q", name)
|
t.Fatalf("missing stage record %q", name)
|
||||||
@@ -425,6 +425,15 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if name == "render" {
|
||||||
|
if sr.Metadata == nil || sr.Metadata["stage"] != "render" {
|
||||||
|
t.Fatalf("render metadata missing stage=render: %#v", sr.Metadata)
|
||||||
|
}
|
||||||
|
if len(sr.Outputs) == 0 {
|
||||||
|
t.Fatalf("render outputs missing")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if name == "publish" {
|
if name == "publish" {
|
||||||
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
|
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
|
||||||
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
|
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
|
||||||
@@ -522,7 +531,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
|||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
|
|
||||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "publish", "notify"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "publish", "notify"} {
|
||||||
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
|
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
|
||||||
@@ -553,7 +562,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
|||||||
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
|
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
|
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
|
||||||
}
|
}
|
||||||
for _, stageName := range []string{"normalize", "trim", "publish", "notify"} {
|
for _, stageName := range []string{"normalize", "trim", "render", "publish", "notify"} {
|
||||||
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
|
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
|
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
|
||||||
}
|
}
|
||||||
@@ -977,7 +986,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
|
|||||||
seed.S3Bucket = "my-dnd-archive"
|
seed.S3Bucket = "my-dnd-archive"
|
||||||
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
|
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
|
||||||
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
|
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
@@ -1015,11 +1024,13 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||||
|
|
||||||
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||||
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
|
||||||
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
|
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
||||||
|
mustWriteFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
|
||||||
|
mustWriteFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio")
|
mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio")
|
||||||
|
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
@@ -1044,6 +1055,16 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
ConfigPath: campaignPath,
|
ConfigPath: campaignPath,
|
||||||
Source: "campaign_config",
|
Source: "campaign_config",
|
||||||
},
|
},
|
||||||
|
PlayersFile: config.ResolvedInputFile{
|
||||||
|
Path: "./players.yml",
|
||||||
|
ConfigPath: campaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
},
|
||||||
|
PartyFile: config.ResolvedInputFile{
|
||||||
|
Path: "./party.yml",
|
||||||
|
ConfigPath: campaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Session: &config.SessionConfig{
|
Session: &config.SessionConfig{
|
||||||
SessionID: "2026-05-03",
|
SessionID: "2026-05-03",
|
||||||
@@ -1053,6 +1074,8 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
SpeakersFile: "./speakers.yml",
|
SpeakersFile: "./speakers.yml",
|
||||||
AutocorrectFile: "./autocorrect.yml",
|
AutocorrectFile: "./autocorrect.yml",
|
||||||
GlossaryFile: "./glossary.yml",
|
GlossaryFile: "./glossary.yml",
|
||||||
|
PlayersFile: "./players.yml",
|
||||||
|
PartyFile: "./party.yml",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1075,6 +1098,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
sessionYAML := `session_id: 2026-05-03
|
sessionYAML := `session_id: 2026-05-03
|
||||||
campaign: sample-campaign
|
campaign: sample-campaign
|
||||||
@@ -1083,6 +1108,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
mustWriteFile(t, pipelinePath, pipelineYAML)
|
mustWriteFile(t, pipelinePath, pipelineYAML)
|
||||||
mustWriteFile(t, campaignPath, campaignYAML)
|
mustWriteFile(t, campaignPath, campaignYAML)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
|
||||||
t.Fatalf("write session template: %v", err)
|
t.Fatalf("write session template: %v", err)
|
||||||
@@ -75,6 +77,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const (
|
|||||||
SourceTranscriptPolished = "narratio.transcript.polished"
|
SourceTranscriptPolished = "narratio.transcript.polished"
|
||||||
SourceTranscriptFinal = "narratio.transcript.final"
|
SourceTranscriptFinal = "narratio.transcript.final"
|
||||||
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
|
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
|
||||||
|
SourceTranscriptFinalMarkdown = "narratio.transcript.final_markdown"
|
||||||
|
SourceTranscriptFinalTrimmedMarkdown = "narratio.transcript.final_trimmed_markdown"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -14,6 +16,8 @@ const (
|
|||||||
TranscriptPathPolished = "transcripts/polished.json"
|
TranscriptPathPolished = "transcripts/polished.json"
|
||||||
TranscriptPathFinal = "transcripts/final.json"
|
TranscriptPathFinal = "transcripts/final.json"
|
||||||
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
|
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
|
||||||
|
TranscriptPathFinalMarkdown = "transcripts/final.md"
|
||||||
|
TranscriptPathFinalTrimmedMarkdown = "transcripts/final.trimmed.md"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -21,6 +25,8 @@ const (
|
|||||||
TranscriptOutputKindPolished = "transcript_polished"
|
TranscriptOutputKindPolished = "transcript_polished"
|
||||||
TranscriptOutputKindFinal = "transcript_final"
|
TranscriptOutputKindFinal = "transcript_final"
|
||||||
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
|
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
|
||||||
|
TranscriptOutputKindFinalMarkdown = "transcript_final_markdown"
|
||||||
|
TranscriptOutputKindFinalTrimmedMarkdown = "transcript_final_trimmed_markdown"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TranscriptArtifactSpec describes one built-in transcript artifact mapping.
|
// TranscriptArtifactSpec describes one built-in transcript artifact mapping.
|
||||||
@@ -56,6 +62,18 @@ var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{
|
|||||||
ProducerStage: "trim",
|
ProducerStage: "trim",
|
||||||
OutputKind: TranscriptOutputKindFinalTrimmed,
|
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptFinalMarkdown,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||||
|
ProducerStage: "render",
|
||||||
|
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptFinalTrimmedMarkdown,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||||
|
ProducerStage: "render",
|
||||||
|
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeTranscriptArtifacts returns transcript mappings in pipeline order.
|
// RuntimeTranscriptArtifacts returns transcript mappings in pipeline order.
|
||||||
|
|||||||
60
internal/artifactmodel/transcripts_test.go
Normal file
60
internal/artifactmodel/transcripts_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package artifactmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuntimeTranscriptArtifactsIncludesMarkdownOutputs(t *testing.T) {
|
||||||
|
want := []TranscriptArtifactSpec{
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptBase,
|
||||||
|
CanonicalRelPath: TranscriptPathBase,
|
||||||
|
ProducerStage: "merge",
|
||||||
|
OutputKind: TranscriptOutputKindBase,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptPolished,
|
||||||
|
CanonicalRelPath: TranscriptPathPolished,
|
||||||
|
ProducerStage: "polish",
|
||||||
|
OutputKind: TranscriptOutputKindPolished,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptFinal,
|
||||||
|
CanonicalRelPath: TranscriptPathFinal,
|
||||||
|
ProducerStage: "normalize",
|
||||||
|
OutputKind: TranscriptOutputKindFinal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptFinalTrimmed,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalTrimmed,
|
||||||
|
ProducerStage: "trim",
|
||||||
|
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptFinalMarkdown,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||||
|
ProducerStage: "render",
|
||||||
|
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptFinalTrimmedMarkdown,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||||
|
ProducerStage: "render",
|
||||||
|
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := RuntimeTranscriptArtifacts()
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("RuntimeTranscriptArtifacts() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupRuntimeTranscriptArtifactFindsMarkdownOutputs(t *testing.T) {
|
||||||
|
for _, source := range []string{SourceTranscriptFinalMarkdown, SourceTranscriptFinalTrimmedMarkdown} {
|
||||||
|
if _, ok := LookupRuntimeTranscriptArtifact(source); !ok {
|
||||||
|
t.Fatalf("LookupRuntimeTranscriptArtifact(%q) ok=false, want true", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
SourceBoundsSession = "narratio.bounds.session"
|
SourceBoundsSession = "narratio.bounds.session"
|
||||||
|
|
||||||
|
SourceInputPlayers = "narratio.input.players"
|
||||||
|
SourceInputParty = "narratio.input.party"
|
||||||
|
SourceInputGlossary = "narratio.input.glossary"
|
||||||
|
|
||||||
configuredSourcePrefix = "narratio.artifact."
|
configuredSourcePrefix = "narratio.artifact."
|
||||||
previousConfiguredSrcPrefix = "narratio.previous_session.artifact."
|
previousConfiguredSrcPrefix = "narratio.previous_session.artifact."
|
||||||
)
|
)
|
||||||
@@ -31,6 +35,7 @@ const (
|
|||||||
SourceKindBuiltIn SourceKind = "built_in"
|
SourceKindBuiltIn SourceKind = "built_in"
|
||||||
SourceKindConfiguredArtifact SourceKind = "configured_artifact"
|
SourceKindConfiguredArtifact SourceKind = "configured_artifact"
|
||||||
SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact"
|
SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact"
|
||||||
|
SourceKindStableInput SourceKind = "stable_input"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Source describes one normalized artifact source identifier.
|
// Source describes one normalized artifact source identifier.
|
||||||
@@ -118,6 +123,11 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
|
|||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
|
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
|
||||||
}
|
}
|
||||||
|
if IsStableInputSource(trimmed) {
|
||||||
|
return ScriptoriumInputSourceDescriptor{
|
||||||
|
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
|
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
|
||||||
descriptor, err := DescribePreviousSessionSource(trimmed)
|
descriptor, err := DescribePreviousSessionSource(trimmed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +150,17 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
|
|||||||
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
|
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsStableInputSource reports whether source is a prepared stable input source
|
||||||
|
// available only to Scriptorium input resolution.
|
||||||
|
func IsStableInputSource(source string) bool {
|
||||||
|
switch strings.TrimSpace(source) {
|
||||||
|
case SourceInputPlayers, SourceInputParty, SourceInputGlossary:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DescribePreviousSessionSource validates a canonical previous-session source id
|
// DescribePreviousSessionSource validates a canonical previous-session source id
|
||||||
// and returns both previous and configured-source vocabulary descriptors.
|
// and returns both previous and configured-source vocabulary descriptors.
|
||||||
func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) {
|
func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) {
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ func TestResolvePublishedDestination(t *testing.T) {
|
|||||||
t.Fatalf("built-in destination = %q, want transcripts/final.trimmed.json", got)
|
t.Fatalf("built-in destination = %q, want transcripts/final.trimmed.json", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
got, err = ResolvePublishedDestination("narratio.transcript.final_markdown", "", configured)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolvePublishedDestination(markdown built-in) error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "transcripts/final.md" {
|
||||||
|
t.Fatalf("markdown built-in destination = %q, want transcripts/final.md", got)
|
||||||
|
}
|
||||||
|
|
||||||
got, err = ResolvePublishedDestination("narratio.artifact.session_recap", "", configured)
|
got, err = ResolvePublishedDestination("narratio.artifact.session_recap", "", configured)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolvePublishedDestination(configured) error = %v", err)
|
t.Fatalf("ResolvePublishedDestination(configured) error = %v", err)
|
||||||
@@ -103,6 +111,10 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
|
|||||||
wantErrLike string
|
wantErrLike string
|
||||||
}{
|
}{
|
||||||
{name: "built in", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn},
|
{name: "built in", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn},
|
||||||
|
{name: "built in markdown", source: "narratio.transcript.final_markdown", wantKind: SourceKindBuiltIn},
|
||||||
|
{name: "prepared players input", source: "narratio.input.players", wantKind: SourceKindStableInput},
|
||||||
|
{name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput},
|
||||||
|
{name: "prepared glossary input", source: "narratio.input.glossary", wantKind: SourceKindStableInput},
|
||||||
{name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
|
{name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
|
||||||
{name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true},
|
{name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true},
|
||||||
{name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource},
|
{name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource},
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const (
|
|||||||
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
||||||
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
|
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
|
||||||
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
|
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
|
||||||
|
ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown
|
||||||
|
ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown
|
||||||
ArtifactBoundsSession = "narratio.bounds.session"
|
ArtifactBoundsSession = "narratio.bounds.session"
|
||||||
|
|
||||||
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
|
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
|
||||||
@@ -29,6 +31,8 @@ const (
|
|||||||
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
|
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
|
||||||
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
|
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
|
||||||
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
||||||
|
TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown
|
||||||
|
TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,6 +40,8 @@ const (
|
|||||||
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
|
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
|
||||||
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
|
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
|
||||||
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
|
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
|
||||||
|
TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown
|
||||||
|
TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
||||||
@@ -67,7 +73,7 @@ func buildArtifactRegistry() map[string]artifactSpec {
|
|||||||
CanonicalRelPath: transcript.CanonicalRelPath,
|
CanonicalRelPath: transcript.CanonicalRelPath,
|
||||||
ProducerStage: transcript.ProducerStage,
|
ProducerStage: transcript.ProducerStage,
|
||||||
OutputKind: transcript.OutputKind,
|
OutputKind: transcript.OutputKind,
|
||||||
ContentKind: contentTranscriptJSON,
|
ContentKind: transcriptContentKind(transcript),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
registry[ArtifactBoundsSession] = artifactSpec{
|
registry[ArtifactBoundsSession] = artifactSpec{
|
||||||
@@ -80,6 +86,15 @@ func buildArtifactRegistry() map[string]artifactSpec {
|
|||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func transcriptContentKind(transcript TranscriptArtifactSpec) artifactContentKind {
|
||||||
|
switch transcript.SourceID {
|
||||||
|
case ArtifactTranscriptFinalMarkdown, ArtifactTranscriptFinalTrimmedMarkdown:
|
||||||
|
return contentText
|
||||||
|
default:
|
||||||
|
return contentTranscriptJSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ResolvedSessionArtifact describes one session-level artifact lookup result.
|
// ResolvedSessionArtifact describes one session-level artifact lookup result.
|
||||||
type ResolvedSessionArtifact struct {
|
type ResolvedSessionArtifact struct {
|
||||||
ID string
|
ID string
|
||||||
|
|||||||
@@ -211,6 +211,29 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
|
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(canonicalPath, []byte("# Final transcript\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveSessionArtifact() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Path != canonicalPath {
|
||||||
|
t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath)
|
||||||
|
}
|
||||||
|
if resolved.Provenance != "fallback.canonical_path" {
|
||||||
|
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
|
func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
@@ -244,6 +267,26 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
|
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(canonicalPath, []byte{}, 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "file is empty") {
|
||||||
|
t.Fatalf("error = %q, want empty file validation", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
|
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
|
|||||||
@@ -216,11 +216,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runtimeBuiltInArtifactIDs() []string {
|
func runtimeBuiltInArtifactIDs() []string {
|
||||||
return []string{
|
ids := make([]string, 0, len(RuntimeTranscriptArtifacts())+1)
|
||||||
ArtifactTranscriptBase,
|
for _, transcript := range RuntimeTranscriptArtifacts() {
|
||||||
ArtifactTranscriptPolished,
|
ids = append(ids, transcript.SourceID)
|
||||||
ArtifactTranscriptFinal,
|
|
||||||
ArtifactTranscriptFinalTrimmed,
|
|
||||||
ArtifactBoundsSession,
|
|
||||||
}
|
}
|
||||||
|
ids = append(ids, ArtifactBoundsSession)
|
||||||
|
return ids
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestArtifactCatalogRegisterBuiltInsIncludesMarkdownSources(t *testing.T) {
|
||||||
|
catalog := NewArtifactCatalog()
|
||||||
|
if err := catalog.RegisterBuiltIns(); err != nil {
|
||||||
|
t.Fatalf("RegisterBuiltIns() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sourceID := range []string{
|
||||||
|
ArtifactTranscriptFinalMarkdown,
|
||||||
|
ArtifactTranscriptFinalTrimmedMarkdown,
|
||||||
|
} {
|
||||||
|
entry, ok := catalog.Lookup(sourceID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Lookup(%q) ok=false, want true", sourceID)
|
||||||
|
}
|
||||||
|
if !entry.Planned {
|
||||||
|
t.Fatalf("%s planned=false, want true", sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) {
|
func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) {
|
||||||
catalog := NewArtifactCatalog()
|
catalog := NewArtifactCatalog()
|
||||||
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{
|
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ func TestRuntimeTranscriptArtifacts(t *testing.T) {
|
|||||||
ProducerStage: "trim",
|
ProducerStage: "trim",
|
||||||
OutputKind: TranscriptOutputKindFinalTrimmed,
|
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptFinalMarkdown,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||||
|
ProducerStage: "render",
|
||||||
|
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||||
|
ProducerStage: "render",
|
||||||
|
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := RuntimeTranscriptArtifacts()
|
got := RuntimeTranscriptArtifacts()
|
||||||
@@ -75,6 +87,18 @@ func TestPlannedTranscriptArtifacts(t *testing.T) {
|
|||||||
ProducerStage: "trim",
|
ProducerStage: "trim",
|
||||||
OutputKind: TranscriptOutputKindFinalTrimmed,
|
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptFinalMarkdown,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||||
|
ProducerStage: "render",
|
||||||
|
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||||
|
ProducerStage: "render",
|
||||||
|
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := PlannedTranscriptArtifacts()
|
got := PlannedTranscriptArtifacts()
|
||||||
@@ -113,10 +137,14 @@ func TestRuntimeArtifactRegistryUsesTranscriptSpecs(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("artifactRegistry missing %q", transcript.SourceID)
|
t.Fatalf("artifactRegistry missing %q", transcript.SourceID)
|
||||||
}
|
}
|
||||||
|
wantContentKind := contentTranscriptJSON
|
||||||
|
if transcript.SourceID == ArtifactTranscriptFinalMarkdown || transcript.SourceID == ArtifactTranscriptFinalTrimmedMarkdown {
|
||||||
|
wantContentKind = contentText
|
||||||
|
}
|
||||||
if spec.CanonicalRelPath != transcript.CanonicalRelPath ||
|
if spec.CanonicalRelPath != transcript.CanonicalRelPath ||
|
||||||
spec.ProducerStage != transcript.ProducerStage ||
|
spec.ProducerStage != transcript.ProducerStage ||
|
||||||
spec.OutputKind != transcript.OutputKind ||
|
spec.OutputKind != transcript.OutputKind ||
|
||||||
spec.ContentKind != contentTranscriptJSON {
|
spec.ContentKind != wantContentKind {
|
||||||
t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript)
|
t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ notification:
|
|||||||
|
|
||||||
func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
|
func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
|
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,12 +114,14 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
|||||||
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
|
||||||
|
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./campaign-players.yml", campaignPath, "campaign_config")
|
||||||
|
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n",
|
||||||
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n",
|
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n players_file: ./session-players.yml\n party_file: ./session-party.yml\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
@@ -132,11 +134,32 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
|||||||
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
|
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
|
||||||
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
|
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
|
||||||
|
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./session-players.yml", sessionPath, "session_config")
|
||||||
|
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||||
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||||
|
}
|
||||||
|
err = Validate(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "campaign.inputs.players_file is required") {
|
||||||
|
t.Fatalf("error = %q, want players_file required", err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCampaignSessionMismatchFails(t *testing.T) {
|
func TestCampaignSessionMismatchFails(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
|
||||||
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,7 +174,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadMissingCampaignFileFails(t *testing.T) {
|
func TestLoadMissingCampaignFileFails(t *testing.T) {
|
||||||
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
|
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type PipelineConfig struct {
|
|||||||
Audita AuditaConfig `yaml:"audita"`
|
Audita AuditaConfig `yaml:"audita"`
|
||||||
Normalize *NormalizeConfig `yaml:"normalize"`
|
Normalize *NormalizeConfig `yaml:"normalize"`
|
||||||
Trim *TrimConfig `yaml:"trim"`
|
Trim *TrimConfig `yaml:"trim"`
|
||||||
|
Render *RenderConfig `yaml:"render"`
|
||||||
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Notification NotificationConfig `yaml:"notification"`
|
Notification NotificationConfig `yaml:"notification"`
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,8 @@ type CampaignInputsConfig struct {
|
|||||||
SpeakersFile string `yaml:"speakers_file"`
|
SpeakersFile string `yaml:"speakers_file"`
|
||||||
AutocorrectFile string `yaml:"autocorrect_file"`
|
AutocorrectFile string `yaml:"autocorrect_file"`
|
||||||
GlossaryFile string `yaml:"glossary_file"`
|
GlossaryFile string `yaml:"glossary_file"`
|
||||||
|
PlayersFile string `yaml:"players_file"`
|
||||||
|
PartyFile string `yaml:"party_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionConfig contains per-session inputs and metadata.
|
// SessionConfig contains per-session inputs and metadata.
|
||||||
@@ -187,7 +190,7 @@ type NormalizeConfig struct {
|
|||||||
|
|
||||||
// TrimConfig configures trim-stage transcript boundary behavior.
|
// TrimConfig configures trim-stage transcript boundary behavior.
|
||||||
type TrimConfig struct {
|
type TrimConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled *bool `yaml:"enabled"`
|
||||||
OutputPath string `yaml:"output_path"`
|
OutputPath string `yaml:"output_path"`
|
||||||
Bounds TrimBoundsConfig `yaml:"bounds"`
|
Bounds TrimBoundsConfig `yaml:"bounds"`
|
||||||
Seriatim TrimSeriatimConfig `yaml:"seriatim"`
|
Seriatim TrimSeriatimConfig `yaml:"seriatim"`
|
||||||
@@ -209,6 +212,16 @@ type TrimSeriatimConfig struct {
|
|||||||
Report *bool `yaml:"report"`
|
Report *bool `yaml:"report"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderConfig configures render-stage output formatting behavior.
|
||||||
|
type RenderConfig struct {
|
||||||
|
Enabled *bool `yaml:"enabled"`
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
IncludeTimestamps *bool `yaml:"include_timestamps"`
|
||||||
|
IncludeSegmentIDs *bool `yaml:"include_segment_ids"`
|
||||||
|
IncludeMetadata bool `yaml:"include_metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
// ScriptoriumConfig configures Scriptorium-backed artifact generation.
|
// ScriptoriumConfig configures Scriptorium-backed artifact generation.
|
||||||
type ScriptoriumConfig struct {
|
type ScriptoriumConfig struct {
|
||||||
Binary string `yaml:"binary"`
|
Binary string `yaml:"binary"`
|
||||||
@@ -254,6 +267,8 @@ type SessionInputsConfig struct {
|
|||||||
SpeakersFile string `yaml:"speakers_file"`
|
SpeakersFile string `yaml:"speakers_file"`
|
||||||
AutocorrectFile string `yaml:"autocorrect_file"`
|
AutocorrectFile string `yaml:"autocorrect_file"`
|
||||||
GlossaryFile string `yaml:"glossary_file"`
|
GlossaryFile string `yaml:"glossary_file"`
|
||||||
|
PlayersFile string `yaml:"players_file"`
|
||||||
|
PartyFile string `yaml:"party_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionAudioS3Input configures S3 session-audio input discovery.
|
// SessionAudioS3Input configures S3 session-audio input discovery.
|
||||||
@@ -267,6 +282,8 @@ type ResolvedStableInputs struct {
|
|||||||
SpeakersFile ResolvedInputFile
|
SpeakersFile ResolvedInputFile
|
||||||
AutocorrectFile ResolvedInputFile
|
AutocorrectFile ResolvedInputFile
|
||||||
GlossaryFile ResolvedInputFile
|
GlossaryFile ResolvedInputFile
|
||||||
|
PlayersFile ResolvedInputFile
|
||||||
|
PartyFile ResolvedInputFile
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvedInputFile records one merged config path and its source config file.
|
// ResolvedInputFile records one merged config path and its source config file.
|
||||||
|
|||||||
@@ -38,8 +38,19 @@ const (
|
|||||||
DefaultScriptoriumTimeout = "10m"
|
DefaultScriptoriumTimeout = "10m"
|
||||||
DefaultScriptoriumArtifactOutputRoot = "artifacts"
|
DefaultScriptoriumArtifactOutputRoot = "artifacts"
|
||||||
|
|
||||||
|
DefaultTrimEnabled = true
|
||||||
|
DefaultTrimOutputPath = artifactmodel.TranscriptPathFinalTrimmed
|
||||||
|
DefaultTrimBoundsPromptID = "dnd.session_bounds"
|
||||||
|
DefaultTrimBoundsTranscriptInputName = "transcript"
|
||||||
|
DefaultTrimBoundsOutputPath = "artifacts/session_bounds.json"
|
||||||
DefaultTrimBoundsTimeout = "10m"
|
DefaultTrimBoundsTimeout = "10m"
|
||||||
DefaultTrimSeriatimReport = false
|
DefaultTrimSeriatimReport = false
|
||||||
|
DefaultRenderEnabled = true
|
||||||
|
DefaultRenderFormat = "markdown"
|
||||||
|
DefaultRenderTitle = ""
|
||||||
|
DefaultRenderTimestamps = true
|
||||||
|
DefaultRenderSegmentIDs = true
|
||||||
|
DefaultRenderMetadata = false
|
||||||
|
|
||||||
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
|
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
|
||||||
DefaultNormalizeOutputSchema = "seriatim-intermediate"
|
DefaultNormalizeOutputSchema = "seriatim-intermediate"
|
||||||
@@ -80,6 +91,8 @@ const (
|
|||||||
// Callers should copy this slice before mutating.
|
// Callers should copy this slice before mutating.
|
||||||
var DefaultPublishOutputs = []PublishOutputRule{
|
var DefaultPublishOutputs = []PublishOutputRule{
|
||||||
{Source: artifactmodel.SourceTranscriptFinalTrimmed, Dest: PathTranscriptFinalTrimmed},
|
{Source: artifactmodel.SourceTranscriptFinalTrimmed, Dest: PathTranscriptFinalTrimmed},
|
||||||
|
{Source: artifactmodel.SourceTranscriptFinalMarkdown, Dest: artifactmodel.TranscriptPathFinalMarkdown},
|
||||||
|
{Source: artifactmodel.SourceTranscriptFinalTrimmedMarkdown, Dest: artifactmodel.TranscriptPathFinalTrimmedMarkdown},
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPipelineConfigSearchPaths defines the default search order for
|
// DefaultPipelineConfigSearchPaths defines the default search order for
|
||||||
|
|||||||
@@ -226,11 +226,25 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
|||||||
campaignPath,
|
campaignPath,
|
||||||
sessionPath,
|
sessionPath,
|
||||||
),
|
),
|
||||||
|
PlayersFile: selectStableInput(
|
||||||
|
campaignCfg.Inputs.PlayersFile,
|
||||||
|
sessionCfg.Inputs.PlayersFile,
|
||||||
|
campaignPath,
|
||||||
|
sessionPath,
|
||||||
|
),
|
||||||
|
PartyFile: selectStableInput(
|
||||||
|
campaignCfg.Inputs.PartyFile,
|
||||||
|
sessionCfg.Inputs.PartyFile,
|
||||||
|
campaignPath,
|
||||||
|
sessionPath,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
|
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
|
||||||
sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path
|
sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path
|
||||||
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
|
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
|
||||||
|
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
|
||||||
|
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
|
||||||
return stable, nil
|
return stable, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +350,14 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
|||||||
cfg.Normalize = &NormalizeConfig{}
|
cfg.Normalize = &NormalizeConfig{}
|
||||||
}
|
}
|
||||||
applyNormalizeDefaults(cfg.Normalize)
|
applyNormalizeDefaults(cfg.Normalize)
|
||||||
|
if cfg.Trim == nil {
|
||||||
|
cfg.Trim = &TrimConfig{}
|
||||||
|
}
|
||||||
applyTrimDefaults(cfg.Trim)
|
applyTrimDefaults(cfg.Trim)
|
||||||
|
if trimEnabled(cfg.Trim) && cfg.Scriptorium == nil {
|
||||||
|
cfg.Scriptorium = &ScriptoriumConfig{}
|
||||||
|
}
|
||||||
|
applyRenderDefaults(&cfg.Render)
|
||||||
applyScriptoriumDefaults(cfg.Scriptorium)
|
applyScriptoriumDefaults(cfg.Scriptorium)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +520,21 @@ func applyTrimDefaults(cfg *TrimConfig) {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.Enabled == nil {
|
||||||
|
cfg.Enabled = boolPtr(DefaultTrimEnabled)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.OutputPath) == "" {
|
||||||
|
cfg.OutputPath = DefaultTrimOutputPath
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Bounds.PromptID) == "" {
|
||||||
|
cfg.Bounds.PromptID = DefaultTrimBoundsPromptID
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Bounds.TranscriptInputName) == "" {
|
||||||
|
cfg.Bounds.TranscriptInputName = DefaultTrimBoundsTranscriptInputName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Bounds.OutputPath) == "" {
|
||||||
|
cfg.Bounds.OutputPath = DefaultTrimBoundsOutputPath
|
||||||
|
}
|
||||||
if cfg.Bounds.Timeout == "" {
|
if cfg.Bounds.Timeout == "" {
|
||||||
cfg.Bounds.Timeout = DefaultTrimBoundsTimeout
|
cfg.Bounds.Timeout = DefaultTrimBoundsTimeout
|
||||||
}
|
}
|
||||||
@@ -507,6 +543,34 @@ func applyTrimDefaults(cfg *TrimConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trimEnabled(cfg *TrimConfig) bool {
|
||||||
|
return cfg != nil && cfg.Enabled != nil && *cfg.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRenderDefaults(cfg **RenderConfig) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *cfg == nil {
|
||||||
|
*cfg = &RenderConfig{}
|
||||||
|
}
|
||||||
|
if (*cfg).Enabled == nil {
|
||||||
|
(*cfg).Enabled = boolPtr(DefaultRenderEnabled)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace((*cfg).Format) == "" {
|
||||||
|
(*cfg).Format = DefaultRenderFormat
|
||||||
|
}
|
||||||
|
if strings.TrimSpace((*cfg).Title) == "" {
|
||||||
|
(*cfg).Title = DefaultRenderTitle
|
||||||
|
}
|
||||||
|
if (*cfg).IncludeTimestamps == nil {
|
||||||
|
(*cfg).IncludeTimestamps = boolPtr(DefaultRenderTimestamps)
|
||||||
|
}
|
||||||
|
if (*cfg).IncludeSegmentIDs == nil {
|
||||||
|
(*cfg).IncludeSegmentIDs = boolPtr(DefaultRenderSegmentIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func applyNormalizeDefaults(cfg *NormalizeConfig) {
|
func applyNormalizeDefaults(cfg *NormalizeConfig) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
checkDefault: true,
|
checkDefault: true,
|
||||||
wantRoot: "/tmp/narratio",
|
wantRoot: "/tmp/narratio",
|
||||||
@@ -55,6 +57,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
checkDefault: true,
|
checkDefault: true,
|
||||||
wantRoot: "/tmp/narratio",
|
wantRoot: "/tmp/narratio",
|
||||||
@@ -72,6 +76,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
checkDefault: true,
|
checkDefault: true,
|
||||||
wantRoot: DefaultWorkspaceRoot,
|
wantRoot: DefaultWorkspaceRoot,
|
||||||
@@ -88,6 +94,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "pipeline file",
|
wantLoadErr: "pipeline file",
|
||||||
},
|
},
|
||||||
@@ -106,6 +114,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
@@ -123,6 +133,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
@@ -143,6 +155,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
@@ -163,6 +177,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured",
|
||||||
},
|
},
|
||||||
@@ -177,6 +193,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
unknown_field: true
|
unknown_field: true
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "session file",
|
wantLoadErr: "session file",
|
||||||
@@ -196,6 +214,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "session config \"session.yml\" invalid: session.session_id is required",
|
wantValidate: "session config \"session.yml\" invalid: session.session_id is required",
|
||||||
},
|
},
|
||||||
@@ -215,6 +235,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -233,6 +255,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id",
|
wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id",
|
||||||
},
|
},
|
||||||
@@ -250,6 +274,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url is required",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url is required",
|
||||||
},
|
},
|
||||||
@@ -268,6 +294,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url must be a valid URL",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url must be a valid URL",
|
||||||
},
|
},
|
||||||
@@ -287,6 +315,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.timeout must be a valid duration",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.timeout must be a valid duration",
|
||||||
},
|
},
|
||||||
@@ -306,6 +336,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retry_delay must be a valid duration",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retry_delay must be a valid duration",
|
||||||
},
|
},
|
||||||
@@ -325,6 +357,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retries must be >= 0",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retries must be >= 0",
|
||||||
},
|
},
|
||||||
@@ -344,6 +378,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0",
|
||||||
},
|
},
|
||||||
@@ -363,6 +399,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
@@ -383,6 +421,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
@@ -401,6 +441,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -419,6 +461,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.timeout must be a valid duration",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.timeout must be a valid duration",
|
||||||
},
|
},
|
||||||
@@ -438,6 +482,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full",
|
||||||
},
|
},
|
||||||
@@ -457,6 +503,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.coalesce_gap must be >= 0",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.coalesce_gap must be >= 0",
|
||||||
},
|
},
|
||||||
@@ -477,6 +525,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.env.overlap_word_run_gap must be > 0 when provided",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.env.overlap_word_run_gap must be > 0 when provided",
|
||||||
},
|
},
|
||||||
@@ -498,6 +548,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
@@ -518,6 +570,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -538,6 +592,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.timeout must be a valid duration",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.timeout must be a valid duration",
|
||||||
},
|
},
|
||||||
@@ -559,6 +615,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -581,6 +639,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be non-empty",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be non-empty",
|
||||||
},
|
},
|
||||||
@@ -604,6 +664,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be one of: glossary, homophones, spoken_word, grammar",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be one of: glossary, homophones, spoken_word, grammar",
|
||||||
},
|
},
|
||||||
@@ -625,6 +687,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.base_url must be a valid URL",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.base_url must be a valid URL",
|
||||||
},
|
},
|
||||||
@@ -646,6 +710,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
@@ -667,6 +733,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.total_llm_concurrency must be > 0",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.total_llm_concurrency must be > 0",
|
||||||
},
|
},
|
||||||
@@ -688,6 +756,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.proposal_llm_concurrency must be > 0",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.proposal_llm_concurrency must be > 0",
|
||||||
},
|
},
|
||||||
@@ -709,6 +779,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.validation_llm_concurrency must be > 0",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.validation_llm_concurrency must be > 0",
|
||||||
},
|
},
|
||||||
@@ -730,6 +802,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.output_schema must be one of: bare-segments, audita-v1",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.output_schema must be one of: bare-segments, audita-v1",
|
||||||
},
|
},
|
||||||
@@ -751,6 +825,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.work_dir_retention must be one of: always, auto, never",
|
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.work_dir_retention must be one of: always, auto, never",
|
||||||
},
|
},
|
||||||
@@ -916,7 +992,16 @@ func TestValidateMissingAudioSource(t *testing.T) {
|
|||||||
Report: boolPtr(true),
|
Report: boolPtr(true),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Campaign: &CampaignConfig{CampaignID: "sample-campaign"},
|
Campaign: &CampaignConfig{
|
||||||
|
CampaignID: "sample-campaign",
|
||||||
|
Inputs: CampaignInputsConfig{
|
||||||
|
SpeakersFile: "speakers.yml",
|
||||||
|
AutocorrectFile: "autocorrect.yml",
|
||||||
|
GlossaryFile: "glossary.yml",
|
||||||
|
PlayersFile: "players.yml",
|
||||||
|
PartyFile: "party.yml",
|
||||||
|
},
|
||||||
|
},
|
||||||
Session: &SessionConfig{
|
Session: &SessionConfig{
|
||||||
SessionID: "2026-05-03",
|
SessionID: "2026-05-03",
|
||||||
Campaign: "sample-campaign",
|
Campaign: "sample-campaign",
|
||||||
@@ -924,6 +1009,8 @@ func TestValidateMissingAudioSource(t *testing.T) {
|
|||||||
SpeakersFile: "speakers.yml",
|
SpeakersFile: "speakers.yml",
|
||||||
AutocorrectFile: "autocorrect.yml",
|
AutocorrectFile: "autocorrect.yml",
|
||||||
GlossaryFile: "glossary.yml",
|
GlossaryFile: "glossary.yml",
|
||||||
|
PlayersFile: "players.yml",
|
||||||
|
PartyFile: "party.yml",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1009,6 +1096,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write campaign.yml: %v", err)
|
t.Fatalf("write campaign.yml: %v", err)
|
||||||
|
|||||||
140
internal/config/render_test.go
Normal file
140
internal/config/render_test.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderLoadAndValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
renderYAML string
|
||||||
|
wantLoadErr string
|
||||||
|
wantValidateErr string
|
||||||
|
assert func(t *testing.T, cfg *Config)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "render defaults when omitted",
|
||||||
|
renderYAML: "",
|
||||||
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
if cfg.Pipeline.Render == nil {
|
||||||
|
t.Fatal("render config should be present via defaults")
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Render.Enabled == nil || !*cfg.Pipeline.Render.Enabled {
|
||||||
|
t.Fatalf("render.enabled = %#v, want true", cfg.Pipeline.Render.Enabled)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Render.Format != "markdown" {
|
||||||
|
t.Fatalf("render.format = %q, want markdown", cfg.Pipeline.Render.Format)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Render.IncludeTimestamps == nil || !*cfg.Pipeline.Render.IncludeTimestamps {
|
||||||
|
t.Fatalf("render.include_timestamps = %#v, want true", cfg.Pipeline.Render.IncludeTimestamps)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Render.IncludeSegmentIDs == nil || !*cfg.Pipeline.Render.IncludeSegmentIDs {
|
||||||
|
t.Fatalf("render.include_segment_ids = %#v, want true", cfg.Pipeline.Render.IncludeSegmentIDs)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Render.IncludeMetadata {
|
||||||
|
t.Fatalf("render.include_metadata = true, want false")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid explicit render config",
|
||||||
|
renderYAML: `render:
|
||||||
|
enabled: false
|
||||||
|
format: markdown
|
||||||
|
title: Session Render
|
||||||
|
include_timestamps: false
|
||||||
|
include_segment_ids: true
|
||||||
|
include_metadata: true
|
||||||
|
`,
|
||||||
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
if cfg.Pipeline.Render == nil {
|
||||||
|
t.Fatal("render config should be present")
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Render.Enabled == nil || *cfg.Pipeline.Render.Enabled {
|
||||||
|
t.Fatalf("render.enabled = %#v, want false", cfg.Pipeline.Render.Enabled)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Render.IncludeTimestamps == nil || *cfg.Pipeline.Render.IncludeTimestamps {
|
||||||
|
t.Fatalf("render.include_timestamps = %#v, want false", cfg.Pipeline.Render.IncludeTimestamps)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Render.IncludeSegmentIDs == nil || !*cfg.Pipeline.Render.IncludeSegmentIDs {
|
||||||
|
t.Fatalf("render.include_segment_ids = %#v, want true", cfg.Pipeline.Render.IncludeSegmentIDs)
|
||||||
|
}
|
||||||
|
if !cfg.Pipeline.Render.IncludeMetadata {
|
||||||
|
t.Fatalf("render.include_metadata = false, want true")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit segment ids false overrides default",
|
||||||
|
renderYAML: `render:
|
||||||
|
include_segment_ids: false
|
||||||
|
`,
|
||||||
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
if cfg.Pipeline.Render.IncludeSegmentIDs == nil || *cfg.Pipeline.Render.IncludeSegmentIDs {
|
||||||
|
t.Fatalf("render.include_segment_ids = %#v, want false", cfg.Pipeline.Render.IncludeSegmentIDs)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid render format fails",
|
||||||
|
renderYAML: `render:
|
||||||
|
format: html
|
||||||
|
`,
|
||||||
|
wantValidateErr: "pipeline.render.format must be markdown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown render field fails strict decoding",
|
||||||
|
renderYAML: `render:
|
||||||
|
format: markdown
|
||||||
|
unknown: true
|
||||||
|
`,
|
||||||
|
wantLoadErr: "strict decode failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
pipelineYAML := testPipelineBaseYAML
|
||||||
|
if tt.renderYAML != "" {
|
||||||
|
pipelineYAML += "\n" + tt.renderYAML
|
||||||
|
}
|
||||||
|
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
|
||||||
|
|
||||||
|
cfg, err := Load(pipelinePath, sessionPath)
|
||||||
|
if tt.wantLoadErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected load error containing %q, got nil", tt.wantLoadErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantLoadErr) {
|
||||||
|
t.Fatalf("load error = %q, want to contain %q", err.Error(), tt.wantLoadErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.assert != nil {
|
||||||
|
tt.assert(t, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = Validate(cfg)
|
||||||
|
if tt.wantValidateErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error containing %q, got nil", tt.wantValidateErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantValidateErr) {
|
||||||
|
t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidateErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,6 +200,42 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
transcript:
|
transcript:
|
||||||
source: narratio.transcript.final_trimmed
|
source: narratio.transcript.final_trimmed
|
||||||
required: true
|
required: true
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "markdown built in artifact source is accepted",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
transcript_markdown:
|
||||||
|
source: narratio.transcript.final_markdown
|
||||||
|
required: true
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prepared stable input sources are accepted",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
players:
|
||||||
|
source: narratio.input.players
|
||||||
|
required: true
|
||||||
|
party:
|
||||||
|
source: narratio.input.party
|
||||||
|
required: true
|
||||||
|
glossary:
|
||||||
|
source: narratio.input.glossary
|
||||||
|
required: false
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -560,4 +596,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
@@ -40,6 +42,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
@@ -65,6 +69,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
@@ -93,6 +99,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
@@ -124,6 +132,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
@@ -148,6 +158,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
|||||||
@@ -179,18 +179,29 @@ func TestSpoolAndPublishDefaults(t *testing.T) {
|
|||||||
if cfg.Pipeline.Publish.UploadRun == nil || !*cfg.Pipeline.Publish.UploadRun {
|
if cfg.Pipeline.Publish.UploadRun == nil || !*cfg.Pipeline.Publish.UploadRun {
|
||||||
t.Fatalf("publish.upload_run = %#v, want true", cfg.Pipeline.Publish.UploadRun)
|
t.Fatalf("publish.upload_run = %#v, want true", cfg.Pipeline.Publish.UploadRun)
|
||||||
}
|
}
|
||||||
if len(cfg.Pipeline.Publish.Outputs) != 1 {
|
if len(cfg.Pipeline.Publish.Outputs) != 3 {
|
||||||
t.Fatalf("publish.outputs len = %d, want 1 default", len(cfg.Pipeline.Publish.Outputs))
|
t.Fatalf("publish.outputs len = %d, want 3 defaults", len(cfg.Pipeline.Publish.Outputs))
|
||||||
}
|
}
|
||||||
item := cfg.Pipeline.Publish.Outputs[0]
|
wantBySource := map[string]string{
|
||||||
|
"narratio.transcript.final_trimmed": "transcripts/final.trimmed.json",
|
||||||
|
"narratio.transcript.final_markdown": "transcripts/final.md",
|
||||||
|
"narratio.transcript.final_trimmed_markdown": "transcripts/final.trimmed.md",
|
||||||
|
}
|
||||||
|
for i, item := range cfg.Pipeline.Publish.Outputs {
|
||||||
if item.Required == nil || !*item.Required {
|
if item.Required == nil || !*item.Required {
|
||||||
t.Fatalf("publish.outputs[0].required = %#v, want true", item.Required)
|
t.Fatalf("publish.outputs[%d].required = %#v, want true", i, item.Required)
|
||||||
}
|
}
|
||||||
if item.Source != "narratio.transcript.final_trimmed" {
|
wantDest, ok := wantBySource[item.Source]
|
||||||
t.Fatalf("publish.outputs[0].source = %q, want narratio.transcript.final_trimmed", item.Source)
|
if !ok {
|
||||||
|
t.Fatalf("publish.outputs[%d].source = %q, want known default source", i, item.Source)
|
||||||
}
|
}
|
||||||
if item.Dest != "transcripts/final.trimmed.json" {
|
if item.Dest != wantDest {
|
||||||
t.Fatalf("publish.outputs[0].dest = %q, want transcripts/final.trimmed.json", item.Dest)
|
t.Fatalf("publish.outputs[%d].dest = %q, want %q", i, item.Dest, wantDest)
|
||||||
|
}
|
||||||
|
delete(wantBySource, item.Source)
|
||||||
|
}
|
||||||
|
if len(wantBySource) != 0 {
|
||||||
|
t.Fatalf("missing default publish outputs for sources: %#v", wantBySource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +238,15 @@ func TestPublishOutputValidation(t *testing.T) {
|
|||||||
`,
|
`,
|
||||||
wantErr: "source \"narratio.unknown\" is unsupported",
|
wantErr: "source \"narratio.unknown\" is unsupported",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "prepared input source rejected",
|
||||||
|
ruleYML: `publish:
|
||||||
|
outputs:
|
||||||
|
- source: "narratio.input.players"
|
||||||
|
dest: "inputs/players.yml"
|
||||||
|
`,
|
||||||
|
wantErr: "source \"narratio.input.players\" is unsupported",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "duplicate destination rejected",
|
name: "duplicate destination rejected",
|
||||||
ruleYML: `publish:
|
ruleYML: `publish:
|
||||||
@@ -337,6 +357,15 @@ publish:
|
|||||||
`,
|
`,
|
||||||
wantDest: "artifacts/session_recap.md",
|
wantDest: "artifacts/session_recap.md",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "markdown built in derives canonical destination",
|
||||||
|
pipelineYML: testPipelineBaseYAML + `
|
||||||
|
publish:
|
||||||
|
outputs:
|
||||||
|
- source: narratio.transcript.final_markdown
|
||||||
|
`,
|
||||||
|
wantDest: "transcripts/final.md",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -406,6 +435,15 @@ publish:
|
|||||||
`,
|
`,
|
||||||
wantErr: "pipeline.publish.locks[0].source \"narratio.unknown\" is unsupported",
|
wantErr: "pipeline.publish.locks[0].source \"narratio.unknown\" is unsupported",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "prepared input source rejected",
|
||||||
|
pipelineYML: testPipelineBaseYAML + `
|
||||||
|
publish:
|
||||||
|
locks:
|
||||||
|
- source: narratio.input.players
|
||||||
|
`,
|
||||||
|
wantErr: "pipeline.publish.locks[0].source \"narratio.input.players\" is unsupported",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "duplicate source rejected",
|
name: "duplicate source rejected",
|
||||||
pipelineYML: testPipelineBaseYAML + `
|
pipelineYML: testPipelineBaseYAML + `
|
||||||
@@ -559,6 +597,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -571,6 +611,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantErr: "session.inputs.audio_s3.prefix must be a relative path",
|
wantErr: "session.inputs.audio_s3.prefix must be a relative path",
|
||||||
},
|
},
|
||||||
@@ -584,6 +626,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantErr: "session.inputs.audio_s3.prefix must not contain path traversal",
|
wantErr: "session.inputs.audio_s3.prefix must not contain path traversal",
|
||||||
},
|
},
|
||||||
@@ -598,6 +642,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`,
|
`,
|
||||||
wantErr: "mutually exclusive",
|
wantErr: "mutually exclusive",
|
||||||
},
|
},
|
||||||
@@ -644,6 +690,8 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`
|
`
|
||||||
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML)
|
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML)
|
||||||
cfg, err := Load(pipelinePath, sessionPath)
|
cfg, err := Load(pipelinePath, sessionPath)
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ func TestTrimLoadAndValidate(t *testing.T) {
|
|||||||
wantValidateErr string
|
wantValidateErr string
|
||||||
assert func(t *testing.T, cfg *Config)
|
assert func(t *testing.T, cfg *Config)
|
||||||
}{
|
}{
|
||||||
|
{
|
||||||
|
name: "trim defaults when omitted",
|
||||||
|
trimYAML: "",
|
||||||
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
assertDefaultTrimConfig(t, cfg)
|
||||||
|
if cfg.Pipeline.Scriptorium == nil {
|
||||||
|
t.Fatal("scriptorium config should be defaulted when trim is enabled by default")
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Scriptorium.Binary != DefaultScriptoriumBinary {
|
||||||
|
t.Fatalf("scriptorium.binary = %q, want %q", cfg.Pipeline.Scriptorium.Binary, DefaultScriptoriumBinary)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "valid trim config",
|
name: "valid trim config",
|
||||||
trimYAML: `trim:
|
trimYAML: `trim:
|
||||||
@@ -34,8 +48,8 @@ func TestTrimLoadAndValidate(t *testing.T) {
|
|||||||
if cfg.Pipeline.Trim == nil {
|
if cfg.Pipeline.Trim == nil {
|
||||||
t.Fatal("trim config should be present")
|
t.Fatal("trim config should be present")
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Trim.Enabled != true {
|
if cfg.Pipeline.Trim.Enabled == nil || !*cfg.Pipeline.Trim.Enabled {
|
||||||
t.Fatalf("trim.enabled = %t, want true", cfg.Pipeline.Trim.Enabled)
|
t.Fatalf("trim.enabled = %#v, want true", cfg.Pipeline.Trim.Enabled)
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Trim.Bounds.ProfileID != "" {
|
if cfg.Pipeline.Trim.Bounds.ProfileID != "" {
|
||||||
t.Fatalf("trim.bounds.profile_id = %q, want empty", cfg.Pipeline.Trim.Bounds.ProfileID)
|
t.Fatalf("trim.bounds.profile_id = %q, want empty", cfg.Pipeline.Trim.Bounds.ProfileID)
|
||||||
@@ -43,68 +57,26 @@ func TestTrimLoadAndValidate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "enabled omitted defaults disabled",
|
name: "enabled omitted defaults enabled",
|
||||||
trimYAML: `trim:
|
trimYAML: `trim:
|
||||||
output_path: transcripts/final.trimmed.json
|
|
||||||
bounds:
|
|
||||||
prompt_id: dnd_session.bounds
|
|
||||||
transcript_input_name: transcript
|
|
||||||
output_path: artifacts/session_bounds.json
|
|
||||||
`,
|
`,
|
||||||
assert: func(t *testing.T, cfg *Config) {
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if cfg.Pipeline.Trim == nil {
|
assertDefaultTrimConfig(t, cfg)
|
||||||
t.Fatal("trim config should be present")
|
},
|
||||||
}
|
},
|
||||||
if cfg.Pipeline.Trim.Enabled {
|
{
|
||||||
t.Fatal("trim.enabled should default to false when omitted")
|
name: "explicit disabled remains disabled",
|
||||||
|
trimYAML: `trim:
|
||||||
|
enabled: false
|
||||||
|
`,
|
||||||
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
if cfg.Pipeline.Trim == nil || cfg.Pipeline.Trim.Enabled == nil || *cfg.Pipeline.Trim.Enabled {
|
||||||
|
t.Fatalf("trim.enabled = %#v, want false", cfg.Pipeline.Trim)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "missing prompt id fails when enabled",
|
|
||||||
trimYAML: `trim:
|
|
||||||
enabled: true
|
|
||||||
output_path: transcripts/final.trimmed.json
|
|
||||||
bounds:
|
|
||||||
transcript_input_name: transcript
|
|
||||||
output_path: artifacts/session_bounds.json
|
|
||||||
`,
|
|
||||||
wantValidateErr: "pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing transcript input name fails when enabled",
|
|
||||||
trimYAML: `trim:
|
|
||||||
enabled: true
|
|
||||||
output_path: transcripts/final.trimmed.json
|
|
||||||
bounds:
|
|
||||||
prompt_id: dnd_session.bounds
|
|
||||||
output_path: artifacts/session_bounds.json
|
|
||||||
`,
|
|
||||||
wantValidateErr: "pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing bounds output path fails when enabled",
|
|
||||||
trimYAML: `trim:
|
|
||||||
enabled: true
|
|
||||||
output_path: transcripts/final.trimmed.json
|
|
||||||
bounds:
|
|
||||||
prompt_id: dnd_session.bounds
|
|
||||||
transcript_input_name: transcript
|
|
||||||
`,
|
|
||||||
wantValidateErr: "pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing trimmed output path fails when enabled",
|
|
||||||
trimYAML: `trim:
|
|
||||||
enabled: true
|
|
||||||
bounds:
|
|
||||||
prompt_id: dnd_session.bounds
|
|
||||||
transcript_input_name: transcript
|
|
||||||
output_path: artifacts/session_bounds.json
|
|
||||||
`,
|
|
||||||
wantValidateErr: "pipeline.trim.output_path is required when pipeline.trim.enabled is true",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "invalid timeout fails",
|
name: "invalid timeout fails",
|
||||||
trimYAML: `trim:
|
trimYAML: `trim:
|
||||||
@@ -185,3 +157,31 @@ func TestTrimLoadAndValidate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertDefaultTrimConfig(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
if cfg.Pipeline.Trim == nil {
|
||||||
|
t.Fatal("trim config should be present")
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Trim.Enabled == nil || !*cfg.Pipeline.Trim.Enabled {
|
||||||
|
t.Fatalf("trim.enabled = %#v, want true", cfg.Pipeline.Trim.Enabled)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Trim.OutputPath != DefaultTrimOutputPath {
|
||||||
|
t.Fatalf("trim.output_path = %q, want %q", cfg.Pipeline.Trim.OutputPath, DefaultTrimOutputPath)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Trim.Bounds.PromptID != DefaultTrimBoundsPromptID {
|
||||||
|
t.Fatalf("trim.bounds.prompt_id = %q, want %q", cfg.Pipeline.Trim.Bounds.PromptID, DefaultTrimBoundsPromptID)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Trim.Bounds.TranscriptInputName != DefaultTrimBoundsTranscriptInputName {
|
||||||
|
t.Fatalf("trim.bounds.transcript_input_name = %q, want %q", cfg.Pipeline.Trim.Bounds.TranscriptInputName, DefaultTrimBoundsTranscriptInputName)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Trim.Bounds.OutputPath != DefaultTrimBoundsOutputPath {
|
||||||
|
t.Fatalf("trim.bounds.output_path = %q, want %q", cfg.Pipeline.Trim.Bounds.OutputPath, DefaultTrimBoundsOutputPath)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Trim.Bounds.Timeout != DefaultTrimBoundsTimeout {
|
||||||
|
t.Fatalf("trim.bounds.timeout = %q, want %q", cfg.Pipeline.Trim.Bounds.Timeout, DefaultTrimBoundsTimeout)
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Trim.Seriatim.Report == nil || *cfg.Pipeline.Trim.Seriatim.Report != DefaultTrimSeriatimReport {
|
||||||
|
t.Fatalf("trim.seriatim.report = %#v, want %t", cfg.Pipeline.Trim.Seriatim.Report, DefaultTrimSeriatimReport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ func validateCampaign(cfg *CampaignConfig) error {
|
|||||||
if CampaignID(cfg) == "" {
|
if CampaignID(cfg) == "" {
|
||||||
return fmt.Errorf("campaign.campaign_id is required")
|
return fmt.Errorf("campaign.campaign_id is required")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" {
|
||||||
|
return fmt.Errorf("campaign.inputs.speakers_file is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" {
|
||||||
|
return fmt.Errorf("campaign.inputs.autocorrect_file is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
||||||
|
return fmt.Errorf("campaign.inputs.glossary_file is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
|
||||||
|
return fmt.Errorf("campaign.inputs.players_file is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||||
|
return fmt.Errorf("campaign.inputs.party_file is required")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +103,9 @@ func validatePipeline(cfg *PipelineConfig) error {
|
|||||||
if err := validateTrim(cfg.Trim); err != nil {
|
if err := validateTrim(cfg.Trim); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := validateRender(cfg.Render); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := validateScriptorium(cfg.Scriptorium); err != nil {
|
if err := validateScriptorium(cfg.Scriptorium); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -273,7 +291,10 @@ func validateTrim(cfg *TrimConfig) error {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !cfg.Enabled {
|
if cfg.Enabled == nil {
|
||||||
|
return fmt.Errorf("pipeline.trim.enabled must be set (defaults should populate this)")
|
||||||
|
}
|
||||||
|
if !*cfg.Enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +320,29 @@ func validateTrim(cfg *TrimConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRender(cfg *RenderConfig) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.Enabled == nil {
|
||||||
|
return fmt.Errorf("pipeline.render.enabled must be set (defaults should populate this)")
|
||||||
|
}
|
||||||
|
if cfg.IncludeTimestamps == nil {
|
||||||
|
return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)")
|
||||||
|
}
|
||||||
|
if cfg.IncludeSegmentIDs == nil {
|
||||||
|
return fmt.Errorf("pipeline.render.include_segment_ids must be set (defaults should populate this)")
|
||||||
|
}
|
||||||
|
format := strings.TrimSpace(cfg.Format)
|
||||||
|
if format != "markdown" {
|
||||||
|
return fmt.Errorf("pipeline.render.format must be markdown")
|
||||||
|
}
|
||||||
|
if cfg.Title != "" && strings.TrimSpace(cfg.Title) == "" {
|
||||||
|
return fmt.Errorf("pipeline.render.title must be non-empty when provided")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateWhisperX(cfg WhisperXConfig) error {
|
func validateWhisperX(cfg WhisperXConfig) error {
|
||||||
if strings.TrimSpace(cfg.TranscribeURL) == "" {
|
if strings.TrimSpace(cfg.TranscribeURL) == "" {
|
||||||
return fmt.Errorf("pipeline.whisperx.transcribe_url is required")
|
return fmt.Errorf("pipeline.whisperx.transcribe_url is required")
|
||||||
@@ -566,6 +610,12 @@ func validateSession(cfg *SessionConfig) error {
|
|||||||
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
||||||
return fmt.Errorf("session.inputs.glossary_file is required")
|
return fmt.Errorf("session.inputs.glossary_file is required")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
|
||||||
|
return fmt.Errorf("session.inputs.players_file is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||||
|
return fmt.Errorf("session.inputs.party_file is required")
|
||||||
|
}
|
||||||
|
|
||||||
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
|
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
|
||||||
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
|
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
|
||||||
|
|||||||
@@ -632,6 +632,16 @@ func resolveScriptoriumInput(
|
|||||||
if describeErr != nil {
|
if describeErr != nil {
|
||||||
return "", false, nil, describeErr
|
return "", false, nil, describeErr
|
||||||
}
|
}
|
||||||
|
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
|
||||||
|
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, paths)
|
||||||
|
if err != nil {
|
||||||
|
if inputCfg.Required {
|
||||||
|
return "", false, nil, err
|
||||||
|
}
|
||||||
|
return "", false, nil, nil
|
||||||
|
}
|
||||||
|
return resolvedPath, ok, nil, nil
|
||||||
|
}
|
||||||
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
||||||
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
|
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -670,6 +680,12 @@ func resolveScriptoriumInput(
|
|||||||
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
|
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
|
||||||
case artifacts.ArtifactTranscriptFinalTrimmed:
|
case artifacts.ArtifactTranscriptFinalTrimmed:
|
||||||
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
|
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
|
||||||
|
case artifacts.ArtifactTranscriptFinalMarkdown, artifacts.ArtifactTranscriptFinalTrimmedMarkdown:
|
||||||
|
return "", false, nil, fmt.Errorf(
|
||||||
|
"rendered markdown transcript input is unavailable for source %q; run narratio run-stage render %s --force",
|
||||||
|
descriptor.Source.ID,
|
||||||
|
paths.SessionID,
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return "", false, nil, nil
|
return "", false, nil, nil
|
||||||
}
|
}
|
||||||
@@ -678,6 +694,36 @@ func resolveScriptoriumInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) {
|
||||||
|
filename, ok := preparedStableInputFilename(sourceID)
|
||||||
|
if !ok {
|
||||||
|
return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID)
|
||||||
|
}
|
||||||
|
path := filepath.Join(paths.InputsDir, filename)
|
||||||
|
if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil {
|
||||||
|
return "", false, fmt.Errorf(
|
||||||
|
"prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w",
|
||||||
|
sourceID,
|
||||||
|
paths.SessionID,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return path, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparedStableInputFilename(sourceID string) (string, bool) {
|
||||||
|
switch strings.TrimSpace(sourceID) {
|
||||||
|
case artifactpolicy.SourceInputPlayers:
|
||||||
|
return "players.yml", true
|
||||||
|
case artifactpolicy.SourceInputParty:
|
||||||
|
return "party.yml", true
|
||||||
|
case artifactpolicy.SourceInputGlossary:
|
||||||
|
return "glossary.yml", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildAnalyzeRuntimeArtifactCatalog(
|
func buildAnalyzeRuntimeArtifactCatalog(
|
||||||
paths artifacts.SessionPaths,
|
paths artifacts.SessionPaths,
|
||||||
scriptoriumCfg *config.ScriptoriumConfig,
|
scriptoriumCfg *config.ScriptoriumConfig,
|
||||||
|
|||||||
@@ -961,6 +961,121 @@ func TestAnalyzeSupportsNormalizedTranscriptSourceWhenConfigured(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeSupportsRenderedMarkdownTranscriptSourceWhenConfigured(t *testing.T) {
|
||||||
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
markdownPath := filepath.Join(paths.TranscriptsDir, "final.md")
|
||||||
|
writeAnalyzeFile(t, markdownPath, "# Session Transcript\n")
|
||||||
|
|
||||||
|
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.transcript.final_markdown",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 1 {
|
||||||
|
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
|
||||||
|
}
|
||||||
|
if fake.RunRequests[0].InputPaths["transcript"] != markdownPath {
|
||||||
|
t.Fatalf("transcript input = %q, want markdown transcript path", fake.RunRequests[0].InputPaths["transcript"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
|
||||||
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
|
||||||
|
playersPath := filepath.Join(paths.InputsDir, "players.yml")
|
||||||
|
partyPath := filepath.Join(paths.InputsDir, "party.yml")
|
||||||
|
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
|
||||||
|
writeAnalyzeFile(t, playersPath, "- Eric\n")
|
||||||
|
writeAnalyzeFile(t, partyPath, "- Arannis\n")
|
||||||
|
writeAnalyzeFile(t, glossaryPath, "- term: Ten Towns\n")
|
||||||
|
|
||||||
|
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.input.players",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
artifact.Inputs["party"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.input.party",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
artifact.Inputs["glossary"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.input.glossary",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 1 {
|
||||||
|
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
|
||||||
|
}
|
||||||
|
if fake.RunRequests[0].InputPaths["players"] != playersPath {
|
||||||
|
t.Fatalf("players input = %q, want %q", fake.RunRequests[0].InputPaths["players"], playersPath)
|
||||||
|
}
|
||||||
|
if fake.RunRequests[0].InputPaths["party"] != partyPath {
|
||||||
|
t.Fatalf("party input = %q, want %q", fake.RunRequests[0].InputPaths["party"], partyPath)
|
||||||
|
}
|
||||||
|
if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath {
|
||||||
|
t.Fatalf("glossary input = %q, want %q", fake.RunRequests[0].InputPaths["glossary"], glossaryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeMissingRequiredPreparedStableInputFailsWithPrepareGuidance(t *testing.T) {
|
||||||
|
env, m, _ := setupAnalyzeEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
|
||||||
|
|
||||||
|
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.input.players",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "prepared input source \"narratio.input.players\" is unavailable") ||
|
||||||
|
!strings.Contains(err.Error(), "run narratio run-stage prepare 2026-05-03 --force") {
|
||||||
|
t.Fatalf("error = %q, want prepared input guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeMissingOptionalPreparedStableInputIsOmitted(t *testing.T) {
|
||||||
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
|
||||||
|
|
||||||
|
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.input.players",
|
||||||
|
Required: false,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 1 {
|
||||||
|
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
|
||||||
|
}
|
||||||
|
if _, ok := fake.RunRequests[0].InputPaths["players"]; ok {
|
||||||
|
t.Fatalf("players input should be omitted: %#v", fake.RunRequests[0].InputPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
|
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
|
||||||
env, m, fake := setupAnalyzeEnv(t)
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
paths := sessionPathsForEnv(env, m.SessionID)
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
@@ -1045,6 +1160,42 @@ func TestAnalyzeFailsWhenNormalizedTranscriptMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeFailsWhenRenderedMarkdownTranscriptMissing(t *testing.T) {
|
||||||
|
env, m, _ := setupAnalyzeEnv(t)
|
||||||
|
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.transcript.final_markdown",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run narratio run-stage render") || !strings.Contains(err.Error(), "--force") {
|
||||||
|
t.Fatalf("error = %q, want render guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeFailsWhenRenderedTrimmedMarkdownTranscriptMissing(t *testing.T) {
|
||||||
|
env, m, _ := setupAnalyzeEnv(t)
|
||||||
|
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.transcript.final_trimmed_markdown",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run narratio run-stage render") || !strings.Contains(err.Error(), "--force") {
|
||||||
|
t.Fatalf("error = %q, want render guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyzeFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) {
|
func TestAnalyzeFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) {
|
||||||
env, m, _ := setupAnalyzeEnv(t)
|
env, m, _ := setupAnalyzeEnv(t)
|
||||||
paths := sessionPathsForEnv(env, m.SessionID)
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func (normalizeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (
|
|||||||
stderrPath = filepath.Join(runLayout.LogsDir, "seriatim.normalize.stderr.log")
|
stderrPath = filepath.Join(runLayout.LogsDir, "seriatim.normalize.stderr.log")
|
||||||
generatedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.normalize.generated.yml")
|
generatedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.normalize.generated.yml")
|
||||||
}
|
}
|
||||||
timeout, err := resolveTrimSeriatimTimeout(env.Config.Pipeline.Seriatim.Timeout)
|
timeout, err := resolveSeriatimStageTimeout(env.Config.Pipeline.Seriatim.Timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("normalize: resolve seriatim timeout: %w", err)
|
return nil, fmt.Errorf("normalize: resolve seriatim timeout: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func All() []Stage {
|
|||||||
polishStage{},
|
polishStage{},
|
||||||
normalizeStage{},
|
normalizeStage{},
|
||||||
trimStage{},
|
trimStage{},
|
||||||
|
renderStage{},
|
||||||
analyzeStage{},
|
analyzeStage{},
|
||||||
publishStage{},
|
publishStage{},
|
||||||
placeholderStage{name: "notify"},
|
placeholderStage{name: "notify"},
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
|||||||
campaignPath := filepath.Join(cfgDir, "campaign.yml")
|
campaignPath := filepath.Join(cfgDir, "campaign.yml")
|
||||||
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||||
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n")
|
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n")
|
||||||
writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
|
||||||
writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n")
|
writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n")
|
||||||
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||||
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||||
writeStageTestFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
writeStageTestFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
||||||
|
writeStageTestFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
|
||||||
|
writeStageTestFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
|
||||||
writeStageTestFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "a")
|
writeStageTestFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "a")
|
||||||
|
|
||||||
wf := &whisperx.FakeClient{}
|
wf := &whisperx.FakeClient{}
|
||||||
@@ -82,6 +84,16 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
|||||||
ConfigPath: campaignPath,
|
ConfigPath: campaignPath,
|
||||||
Source: "campaign_config",
|
Source: "campaign_config",
|
||||||
},
|
},
|
||||||
|
PlayersFile: config.ResolvedInputFile{
|
||||||
|
Path: "./players.yml",
|
||||||
|
ConfigPath: campaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
},
|
||||||
|
PartyFile: config.ResolvedInputFile{
|
||||||
|
Path: "./party.yml",
|
||||||
|
ConfigPath: campaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Session: &config.SessionConfig{
|
Session: &config.SessionConfig{
|
||||||
SessionID: "2026-05-03",
|
SessionID: "2026-05-03",
|
||||||
@@ -101,7 +113,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||||
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
m.RunID = "20260516T000000Z-abcdef12"
|
m.RunID = "20260516T000000Z-abcdef12"
|
||||||
@@ -194,6 +206,15 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if s.Name() == "render" {
|
||||||
|
if result.Metadata["stage"] != "render" {
|
||||||
|
t.Fatalf("render metadata = %#v, want stage=render", result.Metadata)
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 2 {
|
||||||
|
t.Fatalf("render outputs = %#v, want 2 markdown outputs", result.Outputs)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if s.Name() == "publish" {
|
if s.Name() == "publish" {
|
||||||
if result.Metadata["stage"] != "publish" {
|
if result.Metadata["stage"] != "publish" {
|
||||||
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)
|
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ func (prepareStage) Declares() IODecl {
|
|||||||
{Kind: "config", Category: "inputs", RelativePath: "speakers.yml"},
|
{Kind: "config", Category: "inputs", RelativePath: "speakers.yml"},
|
||||||
{Kind: "config", Category: "inputs", RelativePath: "autocorrect.yml"},
|
{Kind: "config", Category: "inputs", RelativePath: "autocorrect.yml"},
|
||||||
{Kind: "config", Category: "inputs", RelativePath: "glossary.yml"},
|
{Kind: "config", Category: "inputs", RelativePath: "glossary.yml"},
|
||||||
|
{Kind: "config", Category: "inputs", RelativePath: "players.yml"},
|
||||||
|
{Kind: "config", Category: "inputs", RelativePath: "party.yml"},
|
||||||
{Kind: "audio", Category: "audio", RelativePath: "*.flac"},
|
{Kind: "audio", Category: "audio", RelativePath: "*.flac"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc)
|
speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc)
|
||||||
autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, sessionSrc)
|
autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, sessionSrc)
|
||||||
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
|
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
|
||||||
|
playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc)
|
||||||
|
partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc)
|
||||||
|
|
||||||
speakersSrc, err := resolveConfigRelativePath(speakersInput)
|
speakersSrc, err := resolveConfigRelativePath(speakersInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,6 +92,14 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("prepare: glossary path: %w", err)
|
return nil, fmt.Errorf("prepare: glossary path: %w", err)
|
||||||
}
|
}
|
||||||
|
playersSrc, err := resolveConfigRelativePath(playersInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare: players path: %w", err)
|
||||||
|
}
|
||||||
|
partySrc, err := resolveConfigRelativePath(partyInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare: party path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
for _, required := range []struct {
|
for _, required := range []struct {
|
||||||
path string
|
path string
|
||||||
@@ -96,6 +108,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
{path: speakersSrc, name: "speakers.yml"},
|
{path: speakersSrc, name: "speakers.yml"},
|
||||||
{path: autocorrectSrc, name: "autocorrect.yml"},
|
{path: autocorrectSrc, name: "autocorrect.yml"},
|
||||||
{path: glossarySrc, name: "glossary.yml"},
|
{path: glossarySrc, name: "glossary.yml"},
|
||||||
|
{path: playersSrc, name: "players.yml"},
|
||||||
|
{path: partySrc, name: "party.yml"},
|
||||||
} {
|
} {
|
||||||
if err := requireFile(required.path, required.name); err != nil {
|
if err := requireFile(required.path, required.name); err != nil {
|
||||||
return nil, fmt.Errorf("prepare: %w", err)
|
return nil, fmt.Errorf("prepare: %w", err)
|
||||||
@@ -107,7 +121,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
|
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
inputs := make([]manifest.InputRecord, 0, 6+len(resolvedLocalAudio))
|
inputs := make([]manifest.InputRecord, 0, 8+len(resolvedLocalAudio))
|
||||||
registerInput := func(kind, path, checksum string) {
|
registerInput := func(kind, path, checksum string) {
|
||||||
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
|
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
|
||||||
}
|
}
|
||||||
@@ -166,6 +180,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
|
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
|
||||||
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
|
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
|
||||||
{kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source},
|
{kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source},
|
||||||
|
{kind: "players", src: playersSrc, dst: filepath.Join(paths.InputsDir, "players.yml"), source: playersInput.Source},
|
||||||
|
{kind: "party", src: partySrc, dst: filepath.Join(paths.InputsDir, "party.yml"), source: partyInput.Source},
|
||||||
} {
|
} {
|
||||||
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
|
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) {
|
|||||||
filepath.Join(paths.InputsDir, "speakers.yml"),
|
filepath.Join(paths.InputsDir, "speakers.yml"),
|
||||||
filepath.Join(paths.InputsDir, "autocorrect.yml"),
|
filepath.Join(paths.InputsDir, "autocorrect.yml"),
|
||||||
filepath.Join(paths.InputsDir, "glossary.yml"),
|
filepath.Join(paths.InputsDir, "glossary.yml"),
|
||||||
|
filepath.Join(paths.InputsDir, "players.yml"),
|
||||||
|
filepath.Join(paths.InputsDir, "party.yml"),
|
||||||
filepath.Join(paths.AudioDir, "alice.flac"),
|
filepath.Join(paths.AudioDir, "alice.flac"),
|
||||||
filepath.Join(paths.AudioDir, "bob.flac"),
|
filepath.Join(paths.AudioDir, "bob.flac"),
|
||||||
} {
|
} {
|
||||||
@@ -49,8 +51,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.Inputs) != 8 {
|
if len(m.Inputs) != 10 {
|
||||||
t.Fatalf("manifest inputs len = %d, want 8", len(m.Inputs))
|
t.Fatalf("manifest inputs len = %d, want 10", len(m.Inputs))
|
||||||
}
|
}
|
||||||
for _, in := range m.Inputs {
|
for _, in := range m.Inputs {
|
||||||
if in.Checksum == "" {
|
if in.Checksum == "" {
|
||||||
@@ -613,11 +615,15 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
|
players_file: ./players.yml
|
||||||
|
party_file: ./party.yml
|
||||||
`)
|
`)
|
||||||
writeFile(t, sessionPath, "session_id: 2026-05-03\n")
|
writeFile(t, sessionPath, "session_id: 2026-05-03\n")
|
||||||
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||||
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||||
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
||||||
|
writeFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
|
||||||
|
writeFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Pipeline: &config.PipelineConfig{
|
Pipeline: &config.PipelineConfig{
|
||||||
@@ -651,6 +657,16 @@ inputs:
|
|||||||
ConfigPath: campaignPath,
|
ConfigPath: campaignPath,
|
||||||
Source: "campaign_config",
|
Source: "campaign_config",
|
||||||
},
|
},
|
||||||
|
PlayersFile: config.ResolvedInputFile{
|
||||||
|
Path: "./players.yml",
|
||||||
|
ConfigPath: campaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
},
|
||||||
|
PartyFile: config.ResolvedInputFile{
|
||||||
|
Path: "./party.yml",
|
||||||
|
ConfigPath: campaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ var publishPrerequisiteStages = []string{
|
|||||||
"polish",
|
"polish",
|
||||||
"normalize",
|
"normalize",
|
||||||
"trim",
|
"trim",
|
||||||
|
"render",
|
||||||
"analyze",
|
"analyze",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
292
internal/stage/render.go
Normal file
292
internal/stage/render.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type renderStage struct{}
|
||||||
|
|
||||||
|
func (renderStage) Name() string { return "render" }
|
||||||
|
|
||||||
|
func (renderStage) Declares() IODecl {
|
||||||
|
return IODecl{
|
||||||
|
Inputs: []artifacts.Ref{
|
||||||
|
{Kind: "transcript_final", Category: "transcripts", RelativePath: "transcripts/final.json"},
|
||||||
|
{Kind: "transcript_final_trimmed", Category: "transcripts", RelativePath: "transcripts/final.trimmed.json"},
|
||||||
|
},
|
||||||
|
Outputs: []artifacts.Ref{
|
||||||
|
{Kind: artifacts.TranscriptOutputKindFinalMarkdown, Category: "transcripts", RelativePath: artifacts.TranscriptPathFinalMarkdown},
|
||||||
|
{Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, Category: "transcripts", RelativePath: artifacts.TranscriptPathFinalTrimmedMarkdown},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (renderStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
|
||||||
|
if env == nil || env.Config == nil {
|
||||||
|
return nil, fmt.Errorf("render: stage environment config is required")
|
||||||
|
}
|
||||||
|
if env.ArtifactStore == nil {
|
||||||
|
return nil, fmt.Errorf("render: artifact store is required")
|
||||||
|
}
|
||||||
|
if env.Config.Pipeline == nil || env.Config.Session == nil {
|
||||||
|
return nil, fmt.Errorf("render: resolved config must include pipeline and session")
|
||||||
|
}
|
||||||
|
if env.Seriatim == nil {
|
||||||
|
return nil, fmt.Errorf("render: seriatim adapter is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionID string
|
||||||
|
if m != nil {
|
||||||
|
sessionID = strings.TrimSpace(m.SessionID)
|
||||||
|
}
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = strings.TrimSpace(env.Config.Session.SessionID)
|
||||||
|
}
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil, fmt.Errorf("render: session id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := sessionPathsForEnv(env, sessionID)
|
||||||
|
runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "render")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: resolve run-stage layout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCfg := renderConfigOrDefault(env.Config.Pipeline.Render)
|
||||||
|
enabled := renderCfg.Enabled == nil || *renderCfg.Enabled
|
||||||
|
format := strings.TrimSpace(renderCfg.Format)
|
||||||
|
if format == "" {
|
||||||
|
format = config.DefaultRenderFormat
|
||||||
|
}
|
||||||
|
title := resolveRenderTitle(renderCfg, env.Config.Session)
|
||||||
|
includeTimestamps := renderCfg.IncludeTimestamps == nil || *renderCfg.IncludeTimestamps
|
||||||
|
includeSegmentIDs := config.DefaultRenderSegmentIDs
|
||||||
|
if renderCfg.IncludeSegmentIDs != nil {
|
||||||
|
includeSegmentIDs = *renderCfg.IncludeSegmentIDs
|
||||||
|
}
|
||||||
|
includeMetadata := renderCfg.IncludeMetadata
|
||||||
|
|
||||||
|
meta := map[string]any{
|
||||||
|
"stage": "render",
|
||||||
|
"render_enabled": enabled,
|
||||||
|
"format": format,
|
||||||
|
"title": title,
|
||||||
|
"include_timestamps": includeTimestamps,
|
||||||
|
"include_segment_ids": includeSegmentIDs,
|
||||||
|
"include_metadata": includeMetadata,
|
||||||
|
"binary": env.Config.Pipeline.Seriatim.Binary,
|
||||||
|
"timeout": env.Config.Pipeline.Seriatim.Timeout,
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
meta["skipped"] = true
|
||||||
|
meta["reason"] = "pipeline.render.enabled is false"
|
||||||
|
return &StageResult{Metadata: meta}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
finalInput, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapRenderInputResolveError(err, sessionID, artifacts.ArtifactTranscriptFinal, "normalize")
|
||||||
|
}
|
||||||
|
finalTrimmedInput, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinalTrimmed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapRenderInputResolveError(err, sessionID, artifacts.ArtifactTranscriptFinalTrimmed, "trim")
|
||||||
|
}
|
||||||
|
|
||||||
|
canonicalFinalMarkdownPath, err := resolveScriptoriumOutputPath(paths, artifacts.TranscriptPathFinalMarkdown)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: resolve canonical final markdown output path: %w", err)
|
||||||
|
}
|
||||||
|
canonicalFinalTrimmedMarkdownPath, err := resolveScriptoriumOutputPath(paths, artifacts.TranscriptPathFinalTrimmedMarkdown)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: resolve canonical final trimmed markdown output path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runFinalMarkdownPath, err := runLocalPathForCanonical(runLayout, paths, canonicalFinalMarkdownPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: resolve run-local final markdown output path: %w", err)
|
||||||
|
}
|
||||||
|
runFinalTrimmedMarkdownPath, err := runLocalPathForCanonical(runLayout, paths, canonicalFinalTrimmedMarkdownPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: resolve run-local final trimmed markdown output path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final.stdout.log")
|
||||||
|
finalStderrLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final.stderr.log")
|
||||||
|
finalGeneratedConfigPath := filepath.Join(paths.ConfigDir, "seriatim.render.final.generated.yml")
|
||||||
|
finalTrimmedStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final_trimmed.stdout.log")
|
||||||
|
finalTrimmedStderrLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final_trimmed.stderr.log")
|
||||||
|
finalTrimmedGeneratedConfigPath := filepath.Join(paths.ConfigDir, "seriatim.render.final_trimmed.generated.yml")
|
||||||
|
if runLayout.Enabled {
|
||||||
|
finalStdoutLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final.stdout.log")
|
||||||
|
finalStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final.stderr.log")
|
||||||
|
finalGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.render.final.generated.yml")
|
||||||
|
finalTrimmedStdoutLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final_trimmed.stdout.log")
|
||||||
|
finalTrimmedStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final_trimmed.stderr.log")
|
||||||
|
finalTrimmedGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.render.final_trimmed.generated.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout, err := resolveSeriatimStageTimeout(env.Config.Pipeline.Seriatim.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: resolve seriatim timeout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalReq := seriatim.RenderRequest{
|
||||||
|
Binary: env.Config.Pipeline.Seriatim.Binary,
|
||||||
|
InputTranscriptPath: finalInput.Path,
|
||||||
|
OutputRenderedPath: runFinalMarkdownPath,
|
||||||
|
Format: format,
|
||||||
|
Title: title,
|
||||||
|
IncludeTimestamps: includeTimestamps,
|
||||||
|
IncludeSegmentIDs: includeSegmentIDs,
|
||||||
|
IncludeMetadata: includeMetadata,
|
||||||
|
StdoutLogPath: finalStdoutLogPath,
|
||||||
|
StderrLogPath: finalStderrLogPath,
|
||||||
|
GeneratedConfigPath: finalGeneratedConfigPath,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
finalRes, err := env.Seriatim.Render(ctx, finalReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinal, err)
|
||||||
|
}
|
||||||
|
finalRenderedPath := coalesceString(finalRes.OutputRenderedPath, finalReq.OutputRenderedPath)
|
||||||
|
if err := requireNonEmptyFile(finalRenderedPath, "final transcript markdown output"); err != nil {
|
||||||
|
return nil, fmt.Errorf("render: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalTrimmedReq := seriatim.RenderRequest{
|
||||||
|
Binary: env.Config.Pipeline.Seriatim.Binary,
|
||||||
|
InputTranscriptPath: finalTrimmedInput.Path,
|
||||||
|
OutputRenderedPath: runFinalTrimmedMarkdownPath,
|
||||||
|
Format: format,
|
||||||
|
Title: title,
|
||||||
|
IncludeTimestamps: includeTimestamps,
|
||||||
|
IncludeSegmentIDs: includeSegmentIDs,
|
||||||
|
IncludeMetadata: includeMetadata,
|
||||||
|
StdoutLogPath: finalTrimmedStdoutLogPath,
|
||||||
|
StderrLogPath: finalTrimmedStderrLogPath,
|
||||||
|
GeneratedConfigPath: finalTrimmedGeneratedConfigPath,
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
finalTrimmedRes, err := env.Seriatim.Render(ctx, finalTrimmedReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinalTrimmed, err)
|
||||||
|
}
|
||||||
|
finalTrimmedRenderedPath := coalesceString(finalTrimmedRes.OutputRenderedPath, finalTrimmedReq.OutputRenderedPath)
|
||||||
|
if err := requireNonEmptyFile(finalTrimmedRenderedPath, "final trimmed transcript markdown output"); err != nil {
|
||||||
|
return nil, fmt.Errorf("render: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
materializedFinalMarkdown, err := materializeRunLocalOutput(env.ArtifactStore, finalRenderedPath, canonicalFinalMarkdownPath, artifacts.Ref{
|
||||||
|
Kind: artifacts.TranscriptOutputKindFinalMarkdown,
|
||||||
|
Category: "transcripts",
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: materialize canonical final markdown output: %w", err)
|
||||||
|
}
|
||||||
|
materializedFinalTrimmedMarkdown, err := materializeRunLocalOutput(env.ArtifactStore, finalTrimmedRenderedPath, canonicalFinalTrimmedMarkdownPath, artifacts.Ref{
|
||||||
|
Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown,
|
||||||
|
Category: "transcripts",
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render: materialize canonical final trimmed markdown output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta["final_input_path"] = finalInput.Path
|
||||||
|
meta["final_input_provenance"] = finalInput.Provenance
|
||||||
|
meta["final_input_run_id"] = finalInput.ProducerRunID
|
||||||
|
meta["final_trimmed_input_path"] = finalTrimmedInput.Path
|
||||||
|
meta["final_trimmed_input_provenance"] = finalTrimmedInput.Provenance
|
||||||
|
meta["final_trimmed_input_run_id"] = finalTrimmedInput.ProducerRunID
|
||||||
|
meta["run_final_markdown_path"] = finalRenderedPath
|
||||||
|
meta["final_markdown_path"] = canonicalFinalMarkdownPath
|
||||||
|
meta["run_final_trimmed_markdown_path"] = finalTrimmedRenderedPath
|
||||||
|
meta["final_trimmed_markdown_path"] = canonicalFinalTrimmedMarkdownPath
|
||||||
|
populateRenderAdapterMetadata(meta, "final_adapter_", finalRes)
|
||||||
|
populateRenderAdapterMetadata(meta, "final_trimmed_adapter_", finalTrimmedRes)
|
||||||
|
|
||||||
|
return &StageResult{
|
||||||
|
Outputs: []artifacts.Ref{
|
||||||
|
materializedFinalMarkdown,
|
||||||
|
materializedFinalTrimmedMarkdown,
|
||||||
|
},
|
||||||
|
Logs: dedupeAndSortPaths([]string{
|
||||||
|
finalStdoutLogPath,
|
||||||
|
finalStderrLogPath,
|
||||||
|
finalTrimmedStdoutLogPath,
|
||||||
|
finalTrimmedStderrLogPath,
|
||||||
|
}),
|
||||||
|
GeneratedConfigs: dedupeAndSortPaths([]string{
|
||||||
|
finalGeneratedConfigPath,
|
||||||
|
finalTrimmedGeneratedConfigPath,
|
||||||
|
}),
|
||||||
|
Metadata: meta,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderConfigOrDefault(cfg *config.RenderConfig) *config.RenderConfig {
|
||||||
|
if cfg != nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
includeTimestamps := true
|
||||||
|
includeSegmentIDs := config.DefaultRenderSegmentIDs
|
||||||
|
return &config.RenderConfig{
|
||||||
|
Enabled: &enabled,
|
||||||
|
Format: config.DefaultRenderFormat,
|
||||||
|
IncludeTimestamps: &includeTimestamps,
|
||||||
|
IncludeSegmentIDs: &includeSegmentIDs,
|
||||||
|
IncludeMetadata: config.DefaultRenderMetadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRenderTitle(renderCfg *config.RenderConfig, sessionCfg *config.SessionConfig) string {
|
||||||
|
if renderCfg != nil && strings.TrimSpace(renderCfg.Title) != "" {
|
||||||
|
return strings.TrimSpace(renderCfg.Title)
|
||||||
|
}
|
||||||
|
if sessionCfg != nil && strings.TrimSpace(sessionCfg.Title) != "" {
|
||||||
|
return strings.TrimSpace(sessionCfg.Title)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapRenderInputResolveError(err error, sessionID, sourceID, guidanceStage string) error {
|
||||||
|
var notFound *artifacts.SessionArtifactNotFoundError
|
||||||
|
if errors.As(err, ¬Found) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"render: required input %q is unavailable; run narratio run-stage %s %s --force",
|
||||||
|
sourceID,
|
||||||
|
guidanceStage,
|
||||||
|
sessionID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("render: resolve %q input: %w", sourceID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateRenderAdapterMetadata(meta map[string]any, prefix string, result seriatim.RenderResult) {
|
||||||
|
if meta == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta[prefix+"duration_ms"] = result.Duration.Milliseconds()
|
||||||
|
meta[prefix+"exit_code"] = result.ExitCode
|
||||||
|
meta[prefix+"invoked_binary"] = result.InvokedBinary
|
||||||
|
meta[prefix+"format"] = result.Format
|
||||||
|
meta[prefix+"title"] = result.Title
|
||||||
|
meta[prefix+"output_path"] = result.OutputRenderedPath
|
||||||
|
meta[prefix+"generated_config"] = result.GeneratedConfigPath
|
||||||
|
meta[prefix+"stdout_log_path"] = result.StdoutLogPath
|
||||||
|
meta[prefix+"stderr_log_path"] = result.StderrLogPath
|
||||||
|
if result.Metadata != nil {
|
||||||
|
meta[prefix+"metadata"] = result.Metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
208
internal/stage/render_test.go
Normal file
208
internal/stage/render_test.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderStageDisabledSucceedsWithoutOutputs(t *testing.T) {
|
||||||
|
env, m, ser := setupRenderEnv(t)
|
||||||
|
enabled := false
|
||||||
|
env.Config.Pipeline.Render.Enabled = &enabled
|
||||||
|
|
||||||
|
result, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 0 {
|
||||||
|
t.Fatalf("outputs = %#v, want empty when disabled", result.Outputs)
|
||||||
|
}
|
||||||
|
if len(ser.RenderRequests) != 0 {
|
||||||
|
t.Fatalf("render requests = %d, want 0 when disabled", len(ser.RenderRequests))
|
||||||
|
}
|
||||||
|
if result.Metadata["render_enabled"] != false {
|
||||||
|
t.Fatalf("metadata render_enabled = %#v, want false", result.Metadata["render_enabled"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageResolvesManifestInputsFirst(t *testing.T) {
|
||||||
|
env, m, ser := setupRenderEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
|
||||||
|
manifestFinal := filepath.Join(paths.ArtifactsDir, "final.from-manifest.json")
|
||||||
|
manifestTrimmed := filepath.Join(paths.ArtifactsDir, "final.trimmed.from-manifest.json")
|
||||||
|
writeFile(t, manifestFinal, `{"segments":[{"id":101}]}`)
|
||||||
|
writeFile(t, manifestTrimmed, `{"segments":[{"id":202}]}`)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||||
|
|
||||||
|
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final", LocalPath: manifestFinal}})
|
||||||
|
m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final_trimmed", LocalPath: manifestTrimmed}})
|
||||||
|
|
||||||
|
result, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(ser.RenderRequests) != 2 {
|
||||||
|
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
|
||||||
|
}
|
||||||
|
if ser.RenderRequests[0].InputTranscriptPath != manifestFinal {
|
||||||
|
t.Fatalf("first input path = %q, want %q", ser.RenderRequests[0].InputTranscriptPath, manifestFinal)
|
||||||
|
}
|
||||||
|
if ser.RenderRequests[1].InputTranscriptPath != manifestTrimmed {
|
||||||
|
t.Fatalf("second input path = %q, want %q", ser.RenderRequests[1].InputTranscriptPath, manifestTrimmed)
|
||||||
|
}
|
||||||
|
if result.Metadata["final_input_provenance"] != "manifest.normalize.outputs" {
|
||||||
|
t.Fatalf("final_input_provenance = %#v, want manifest.normalize.outputs", result.Metadata["final_input_provenance"])
|
||||||
|
}
|
||||||
|
if result.Metadata["final_trimmed_input_provenance"] != "manifest.trim.outputs" {
|
||||||
|
t.Fatalf("final_trimmed_input_provenance = %#v, want manifest.trim.outputs", result.Metadata["final_trimmed_input_provenance"])
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 2 {
|
||||||
|
t.Fatalf("outputs = %#v, want 2", result.Outputs)
|
||||||
|
}
|
||||||
|
if result.Outputs[0].Kind != artifacts.TranscriptOutputKindFinalMarkdown {
|
||||||
|
t.Fatalf("outputs[0].kind = %q, want %q", result.Outputs[0].Kind, artifacts.TranscriptOutputKindFinalMarkdown)
|
||||||
|
}
|
||||||
|
if result.Outputs[1].Kind != artifacts.TranscriptOutputKindFinalTrimmedMarkdown {
|
||||||
|
t.Fatalf("outputs[1].kind = %q, want %q", result.Outputs[1].Kind, artifacts.TranscriptOutputKindFinalTrimmedMarkdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageTitleFallsBackToSessionTitle(t *testing.T) {
|
||||||
|
env, m, ser := setupRenderEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||||
|
env.Config.Pipeline.Render.Title = ""
|
||||||
|
env.Config.Session.Title = "Session Title"
|
||||||
|
|
||||||
|
_, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(ser.RenderRequests) != 2 {
|
||||||
|
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
|
||||||
|
}
|
||||||
|
if ser.RenderRequests[0].Title != "Session Title" || ser.RenderRequests[1].Title != "Session Title" {
|
||||||
|
t.Fatalf("render titles = %q/%q, want Session Title", ser.RenderRequests[0].Title, ser.RenderRequests[1].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageFailsWhenFinalInputMissing(t *testing.T) {
|
||||||
|
env, m, _ := setupRenderEnv(t)
|
||||||
|
_, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run narratio run-stage normalize") || !strings.Contains(err.Error(), "--force") {
|
||||||
|
t.Fatalf("error = %q, want normalize guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageFailsWhenFinalTrimmedInputMissing(t *testing.T) {
|
||||||
|
env, m, _ := setupRenderEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
|
||||||
|
_, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run narratio run-stage trim") || !strings.Contains(err.Error(), "--force") {
|
||||||
|
t.Fatalf("error = %q, want trim guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderStageUsesRunLocalOutputsAndMaterializesCanonical(t *testing.T) {
|
||||||
|
env, m, ser := setupRenderEnv(t)
|
||||||
|
env.Config.Session.Campaign = "sample-campaign"
|
||||||
|
m.Campaign = "sample-campaign"
|
||||||
|
m.RunID = "20260518T010203Z-abcdef12"
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||||
|
|
||||||
|
result, err := (renderStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(ser.RenderRequests) != 2 {
|
||||||
|
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
|
||||||
|
}
|
||||||
|
if !strings.Contains(ser.RenderRequests[0].OutputRenderedPath, filepath.Join("runs", m.RunID, "render", "outputs")) {
|
||||||
|
t.Fatalf("run output path = %q, want run-local output path", ser.RenderRequests[0].OutputRenderedPath)
|
||||||
|
}
|
||||||
|
for _, out := range result.Outputs {
|
||||||
|
if strings.Contains(out.AbsolutePath, string(filepath.Separator)+"runs"+string(filepath.Separator)) {
|
||||||
|
t.Fatalf("materialized output path = %q, want canonical session path", out.AbsolutePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result.GeneratedConfigs) != 2 {
|
||||||
|
t.Fatalf("generated configs = %#v, want two generated config paths", result.GeneratedConfigs)
|
||||||
|
}
|
||||||
|
if len(result.Logs) != 4 {
|
||||||
|
t.Fatalf("logs = %#v, want four log paths", result.Logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRenderEnv(t *testing.T) (*Env, *manifest.Manifest, *seriatim.FakeRunner) {
|
||||||
|
t.Helper()
|
||||||
|
workspace := t.TempDir()
|
||||||
|
cfgDir := t.TempDir()
|
||||||
|
sessionPath := filepath.Join(cfgDir, "session.yml")
|
||||||
|
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||||
|
writeFile(t, sessionPath, "session_id: 2026-05-03\n")
|
||||||
|
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
includeTimestamps := true
|
||||||
|
includeSegmentIDs := false
|
||||||
|
seriatimReport := false
|
||||||
|
cfg := &config.Config{
|
||||||
|
PipelinePath: pipelinePath,
|
||||||
|
SessionPath: sessionPath,
|
||||||
|
Pipeline: &config.PipelineConfig{
|
||||||
|
Workspace: config.WorkspaceConfig{Root: workspace},
|
||||||
|
Seriatim: config.SeriatimConfig{
|
||||||
|
Binary: "seriatim",
|
||||||
|
Timeout: "10m",
|
||||||
|
OutputSchema: "seriatim-intermediate",
|
||||||
|
CoalesceGap: func() *float64 { v := 3.0; return &v }(),
|
||||||
|
Report: &seriatimReport,
|
||||||
|
},
|
||||||
|
Render: &config.RenderConfig{
|
||||||
|
Enabled: &enabled,
|
||||||
|
Format: "markdown",
|
||||||
|
Title: "Pipeline Title",
|
||||||
|
IncludeTimestamps: &includeTimestamps,
|
||||||
|
IncludeSegmentIDs: &includeSegmentIDs,
|
||||||
|
IncludeMetadata: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: &config.SessionConfig{
|
||||||
|
SessionID: "2026-05-03",
|
||||||
|
Campaign: "sample-campaign",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := artifacts.NewLocalStore(workspace)
|
||||||
|
if _, err := store.EnsureLayoutFor("sample-campaign", "2026-05-03"); err != nil {
|
||||||
|
t.Fatalf("EnsureLayout() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ser := &seriatim.FakeRunner{}
|
||||||
|
return &Env{
|
||||||
|
Config: cfg,
|
||||||
|
ArtifactStore: store,
|
||||||
|
Seriatim: ser,
|
||||||
|
}, manifest.New("2026-05-03", time.Now().UTC()), ser
|
||||||
|
}
|
||||||
@@ -236,10 +236,12 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
|
|||||||
campaignPath := filepath.Join(cfgDir, "campaign.yml")
|
campaignPath := filepath.Join(cfgDir, "campaign.yml")
|
||||||
writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n")
|
writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n")
|
||||||
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||||
writeFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
writeFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
|
||||||
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||||
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||||
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
||||||
|
writeFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
|
||||||
|
writeFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
|
||||||
|
|
||||||
retries := 3
|
retries := 3
|
||||||
concurrency := 2
|
concurrency := 2
|
||||||
@@ -267,12 +269,16 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
|
|||||||
SpeakersFile: "./speakers.yml",
|
SpeakersFile: "./speakers.yml",
|
||||||
AutocorrectFile: "./autocorrect.yml",
|
AutocorrectFile: "./autocorrect.yml",
|
||||||
GlossaryFile: "./glossary.yml",
|
GlossaryFile: "./glossary.yml",
|
||||||
|
PlayersFile: "./players.yml",
|
||||||
|
PartyFile: "./party.yml",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
StableInputs: config.ResolvedStableInputs{
|
StableInputs: config.ResolvedStableInputs{
|
||||||
SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"},
|
SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"},
|
||||||
AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.yml", ConfigPath: campaignPath, Source: "campaign_config"},
|
AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.yml", ConfigPath: campaignPath, Source: "campaign_config"},
|
||||||
GlossaryFile: config.ResolvedInputFile{Path: "./glossary.yml", ConfigPath: campaignPath, Source: "campaign_config"},
|
GlossaryFile: config.ResolvedInputFile{Path: "./glossary.yml", ConfigPath: campaignPath, Source: "campaign_config"},
|
||||||
|
PlayersFile: config.ResolvedInputFile{Path: "./players.yml", ConfigPath: campaignPath, Source: "campaign_config"},
|
||||||
|
PartyFile: config.ResolvedInputFile{Path: "./party.yml", ConfigPath: campaignPath, Source: "campaign_config"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag
|
|||||||
}
|
}
|
||||||
|
|
||||||
trimCfg := env.Config.Pipeline.Trim
|
trimCfg := env.Config.Pipeline.Trim
|
||||||
enabled := trimCfg != nil && trimCfg.Enabled
|
enabled := trimCfg != nil && trimCfg.Enabled != nil && *trimCfg.Enabled
|
||||||
|
|
||||||
canonicalTrimmedPath, err := resolveTrimmedOutputPath(paths, trimCfg)
|
canonicalTrimmedPath, err := resolveTrimmedOutputPath(paths, trimCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -315,7 +315,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag
|
|||||||
trimStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.trim.stderr.log")
|
trimStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.trim.stderr.log")
|
||||||
trimGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.trim.generated.yml")
|
trimGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.trim.generated.yml")
|
||||||
}
|
}
|
||||||
trimTimeout, err := resolveTrimSeriatimTimeout(env.Config.Pipeline.Seriatim.Timeout)
|
trimTimeout, err := resolveSeriatimStageTimeout(env.Config.Pipeline.Seriatim.Timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("trim: resolve seriatim timeout: %w", err)
|
return nil, fmt.Errorf("trim: resolve seriatim timeout: %w", err)
|
||||||
}
|
}
|
||||||
@@ -398,7 +398,7 @@ func copyTranscript(store artifacts.Store, src, dst string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveTrimSeriatimTimeout(raw string) (time.Duration, error) {
|
func resolveSeriatimStageTimeout(raw string) (time.Duration, error) {
|
||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
@@ -275,7 +275,8 @@ func TestTrimStageDisabledCopiesNormalizedTranscript(t *testing.T) {
|
|||||||
writeFile(t, normalized, normalizedBody)
|
writeFile(t, normalized, normalizedBody)
|
||||||
|
|
||||||
disabled := *env.Config.Pipeline.Trim
|
disabled := *env.Config.Pipeline.Trim
|
||||||
disabled.Enabled = false
|
enabled := false
|
||||||
|
disabled.Enabled = &enabled
|
||||||
env.Config.Pipeline.Trim = &disabled
|
env.Config.Pipeline.Trim = &disabled
|
||||||
|
|
||||||
result, err := (trimStage{}).Run(context.Background(), env, m)
|
result, err := (trimStage{}).Run(context.Background(), env, m)
|
||||||
@@ -424,6 +425,7 @@ func setupTrimEnv(t *testing.T) (*Env, *manifest.Manifest, *boundsScriptoriumRun
|
|||||||
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||||
|
|
||||||
seriatimReport := false
|
seriatimReport := false
|
||||||
|
trimEnabled := true
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
PipelinePath: pipelinePath,
|
PipelinePath: pipelinePath,
|
||||||
SessionPath: sessionPath,
|
SessionPath: sessionPath,
|
||||||
@@ -437,7 +439,7 @@ func setupTrimEnv(t *testing.T) (*Env, *manifest.Manifest, *boundsScriptoriumRun
|
|||||||
Report: &seriatimReport,
|
Report: &seriatimReport,
|
||||||
},
|
},
|
||||||
Trim: &config.TrimConfig{
|
Trim: &config.TrimConfig{
|
||||||
Enabled: true,
|
Enabled: &trimEnabled,
|
||||||
OutputPath: "transcripts/final.trimmed.json",
|
OutputPath: "transcripts/final.trimmed.json",
|
||||||
Bounds: config.TrimBoundsConfig{
|
Bounds: config.TrimBoundsConfig{
|
||||||
PromptID: "dnd_session.bounds",
|
PromptID: "dnd_session.bounds",
|
||||||
|
|||||||
Reference in New Issue
Block a user