14 Commits

Author SHA1 Message Date
df58595d1e Remove completed documentation alignment roadmaps 2026-08-09 22:02:07 +00:00
c3c14e7468 Complete documentation alignment roadmap 2026-08-09 21:53:04 +00:00
e7319ea016 Align internal documentation and maintained examples 2026-08-09 21:50:21 +00:00
bd2d5e2496 Clarify user and integration documentation contracts 2026-08-09 21:41:23 +00:00
115a44f629 Align documentation entry points and internal overview 2026-08-09 21:32:11 +00:00
18411dc5b5 Move contributor guidance to its canonical location 2026-08-09 21:27:58 +00:00
e23dc1ab6e Refocus the Narratio architecture policy 2026-08-09 21:26:18 +00:00
e1359ea227 Adopt canonical documentation ownership policy 2026-08-09 21:23:50 +00:00
7fdd99ec27 Prepare roadmap for documentation policy update 2026-08-09 21:21:00 +00:00
a90231ce0c Implement support for passing a session_id variable to scriptorium to support sticky routing 2026-07-02 21:04:37 -05:00
ed879b8bb0 Clean up obsolete placeholder code 2026-07-02 20:47:08 -05:00
717451512a Implemented new campaign/session stable inputs and corresponding input source references
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-27 09:34:05 -05:00
3ddb3a947b Update pipeline defaults so trim is enabled when omitted 2026-05-27 08:35:02 -05:00
c6632d5576 Bugfix in the seriatim adapter
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-27 08:09:22 -05:00
80 changed files with 2570 additions and 1063 deletions

View File

@@ -1,22 +1,41 @@
# narratio # narratio
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`, `render`, `analyze`, and `publish`, with manifest-driven continuation and restore support. It runs a deterministic workflow with manifest-driven continuation, remote
publish, and restore support.
```bash ```sh
narratio run 2026-04-04 narratio run 2026-04-04
``` ```
This requires resolvable `pipeline.yml`, `campaign.yml`, and concrete `session.yml` (or explicit config flags). This requires resolvable `pipeline.yml`, `campaign.yml`, and concrete
`session.yml` files or their explicit command-line alternatives.
## Documentation ## Documentation
- [CLI Reference](docs/cli.md) - [CLI reference](docs/cli.md) — commands, arguments, flags, and invocation
- [Configuration](docs/config.md) behavior.
- [Operations](docs/operations.md) - [Configuration](docs/config.md) — discovery, fields, defaults, and
- [Troubleshooting](docs/troubleshooting.md) validation.
- [Internal Component Contracts](docs/internal/README.md) - [Operations](docs/operations.md) — runtime workflow, state, publishing,
- [Development Guide](docs/policy/development.md) recovery, and cleanup.
- [Architecture Principles](docs/policy/architecture.md) - [Troubleshooting](docs/troubleshooting.md) — symptom-driven diagnosis and
- [Maintained Examples](examples/) safe remedies.
- [Integration contracts](docs/integrations/) — external tools, formats, and
compatibility expectations.
- [Maintained examples](examples/README.md) — complete copyable configuration
and input files.
## Maintainer Documentation
- [Development guide](docs/development.md) — first-read orientation and
task-specific reading routes.
- [Internal overview](docs/internal/overview.md) — implemented component map.
- [Architecture](docs/policy/architecture.md) — normative boundaries and
invariants.
- [Documentation policy](docs/policy/documentation.md) — canonical ownership
and maintenance rules.
- [Testing policy](docs/policy/testing.md) — test value, boundaries, and
sufficiency.

View File

@@ -135,14 +135,13 @@ narratio clean --all [--dry-run] [--clear-cache] [--config <pipeline.yml>]
Behavior: Behavior:
- session mode removes: - session mode removes the selected session's local work and spool state;
- `{workspace.root}/work/{campaign}/{session_id}` - `--all` removes all local session work and spool state;
- `{spool.root}/{campaign}/{session_id}`
- `--all` removes:
- `{workspace.root}/work/*`
- direct children under `{spool.root}`
- cache remains unless `--clear-cache` is provided. - cache remains unless `--clear-cache` is provided.
See [Operations: Cleanup](./operations.md#cleanup) for deletion scope and
post-publish cleanup behavior.
### `session plan` ### `session plan`
```bash ```bash
@@ -207,17 +206,11 @@ Behavior:
- discovers committed remote current state; - discovers committed remote current state;
- plans local restores; - plans local restores;
- writes `reports/restore-latest.json` on execution; - writes an execution report;
- blocks conflicting overwrites unless `--force` is set. - blocks conflicting overwrites unless `--force` is set.
Default restore scope: See [Operations: Restore Workflow](./operations.md#restore-workflow) for the
default restore scope, report location, and conflict-handling workflow.
- `manifest.json`
- `transcripts/**`
- `artifacts/**`
- `previous/**` when required by configured previous-session inputs
`audio/**` is included only with `--include-audio`.
### `session artifacts` ### `session artifacts`
@@ -237,10 +230,13 @@ narratio session locks remove <session_id> <source> [...common config flags]
Behavior: Behavior:
- list mode merges static `pipeline.publish.locks` with remote `{session_prefix}/locks.yml`; - list mode reports the effective merge of static and remote locks;
- add/remove mutate only remote locks; - add/remove mutate only remote locks;
- static locks from pipeline config cannot be removed by CLI commands. - static locks from pipeline config cannot be removed by CLI commands.
See [Operations: Publish Locks](./operations.md#publish-locks) for lock storage
and precedence.
## `--artifacts` Selection Rules ## `--artifacts` Selection Rules
- accepted on `run`, `run-stage`, `analyze`, and `publish`; - accepted on `run`, `run-stage`, `analyze`, and `publish`;
@@ -279,3 +275,18 @@ Force publish only:
```bash ```bash
narratio publish 2026-04-04 narratio publish 2026-04-04
``` ```
## Output And Exit Behavior
- Successful commands write their result or summary to standard output and
exit with status `0`.
- Command failures and invalid invocations write an error to standard error and
exit with status `1`.
- An unknown top-level command also prints the top-level usage summary to
standard error.
- `session restore --help` prints its command-specific usage and exits with
status `0`.
Output is intended for operator inspection. Narratio does not currently offer
a machine-readable CLI output mode; durable machine-readable state is recorded
in manifests and reports described in [Operations](./operations.md).

View File

@@ -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)
@@ -184,12 +186,12 @@ 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 |
@@ -198,7 +200,7 @@ Rules:
| `pipeline.render.format` | string | No | `markdown` (only supported value) | | `pipeline.render.format` | string | No | `markdown` (only supported value) |
| `pipeline.render.title` | string | No | empty (falls back to `session.title` when set) | | `pipeline.render.title` | string | No | empty (falls back to `session.title` when set) |
| `pipeline.render.include_timestamps` | bool | No | `true` | | `pipeline.render.include_timestamps` | bool | No | `true` |
| `pipeline.render.include_segment_ids` | bool | No | `false` | | `pipeline.render.include_segment_ids` | bool | No | `true` |
| `pipeline.render.include_metadata` | bool | No | `false` | | `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 |
@@ -207,7 +209,7 @@ Rules:
| `pipeline.scriptorium.artifacts` | map | No | empty | | `pipeline.scriptorium.artifacts` | map | No | empty |
| `pipeline.notification.backend` | string | No | empty | | `pipeline.notification.backend` | string | No | empty |
| `pipeline.notification.recipient` | string | No | empty | | `pipeline.notification.recipient` | string | No | empty |
| `pipeline.notification.timeout` | duration | No | `30s` | | `pipeline.notification.timeout` | duration | No | empty |
### Scriptorium Artifact Entries ### Scriptorium Artifact Entries
@@ -223,13 +225,15 @@ For each `pipeline.scriptorium.artifacts.<name>`:
| `output_path` | string | Conditional | required when enabled; also required when referenced by publish/output/input rules | | `output_path` | string | Conditional | required when enabled; also required when referenced by publish/output/input rules |
| `timeout` | duration | No | artifact override | | `timeout` | duration | No | artifact override |
| `inputs` | map | No | input key names must be non-empty | | `inputs` | map | No | input key names must be non-empty |
| `vars` | map | No | values must be string or bool | | `vars` | map | No | values must be string or bool; `session_id` is reserved and overwritten by Narratio |
Narratio adds `session_id=narratio-session-<session_id>` to every Scriptorium request for sticky upstream LLM routing. If an artifact config sets `vars.session_id`, Narratio replaces that value before invoking Scriptorium. Use a different variable name if a prompt needs the raw Narratio session ID as content.
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`: For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
| 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 |
@@ -243,6 +247,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
@@ -256,6 +262,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 |
@@ -266,10 +274,6 @@ Audio rules:
## Maintained Examples ## Maintained Examples
- `examples/pipeline.minimal.yml` See the [maintained examples index](../examples/README.md) for complete pipeline,
- `examples/pipeline.production.yml` campaign, session, template, and input fixtures. Keep complete copyable files
- `examples/pipeline.full.annotated.yml` there rather than duplicating them in this reference.
- `examples/campaigns/sample-campaign/campaign.yml`
- `examples/session.local-audio.yml`
- `examples/session.s3-audio.yml`
- `examples/session.template.yml`

43
docs/development.md Normal file
View File

@@ -0,0 +1,43 @@
# Development
This is the first-read landing page for people and LLM coding agents working on
Narratio. It provides a concise repository orientation and routes each kind of
change to its canonical documentation.
Narratio is a stage-driven Go orchestrator for turning D&D session audio into
polished transcripts and generated artifacts. Start with the
[README](../README.md) for product context,
[Architecture](policy/architecture.md) for normative system boundaries, and the
[Internal Overview](internal/overview.md) for implemented component ownership.
## What To Read
| When working on | Read | Why |
| --- | --- | --- |
| Finding the package or component that owns current behavior | [Internal Overview](internal/overview.md) | It is the implemented component inventory and routes to focused internal documents. |
| Application shape, boundaries, dependency direction, runtime invariants, safety properties, or dependencies | [Architecture](policy/architecture.md) | It defines the intended system shape, ownership, and non-goals. |
| Any documentation addition or revision | [Documentation Policy](policy/documentation.md) | It defines canonical owners, audiences, current-behavior rules, and maintenance requirements. |
| Adding, changing, reviewing, rewriting, or deleting tests | [Testing Policy](policy/testing.md) | It defines risk-based sufficiency, durable test boundaries, test-double guidance, and test lifecycle decisions. |
| CLI composition or command behavior | [Internal Overview](internal/overview.md) and [CLI Reference](cli.md) | The overview routes to command ownership; the reference owns public syntax and invocation behavior. |
| Configuration loading, resolution, or user-visible configuration | [Internal Overview](internal/overview.md) and [Configuration](config.md) | The overview routes to implementation ownership; the reference owns fields, defaults, discovery, and validation. |
| Session workflow, status, restore, cleanup, or object storage | [Restore Internals](internal/command-restore.md), [Workspace Internals](internal/workspace.md), [Storage Internals](internal/storage.md), [Operations](operations.md), and [Troubleshooting](troubleshooting.md) | These separate implementation mechanics, operator procedures, and symptom-driven recovery. |
| Pipeline sequencing or the behavior of a stage | [Internal Overview](internal/overview.md) and its focused stage documents | The overview owns the implemented stage inventory and routes to each stage contract. |
| Adapters or external tool contracts | [Adapter Internals](internal/adapters.md) and [Integration Contracts](integrations/README.md) | The internal guide owns adapter composition and mechanics; integration documents own external formats and protocols. |
| Manifests, artifacts, workspace paths, or publish behavior | [Manifest Internals](internal/manifest.md), [Artifact Internals](internal/artifacts.md), [Workspace Internals](internal/workspace.md), [Publish Internals](internal/stage-publish.md), and [Operations](operations.md) | These separate implementation state and resolution from operator-visible layout and lifecycle. |
| Maintained configuration or input examples | [Configuration](config.md) and [Examples](../examples/README.md) | The reference owns field meanings; the examples directory owns complete copyable files. |
| Proposed or unimplemented behavior | [Roadmap](roadmap/) | Future work belongs only in roadmap documentation until implemented. |
For an existing subsystem, also inspect its focused tests and package-level
contracts before changing behavior.
## Validation
Use focused package tests while iterating. Run the repository-wide checks when a
change affects shared contracts, application behavior, or maintained
documentation examples:
```sh
go test ./...
go vet ./...
go build ./cmd/narratio
```

View File

@@ -1,19 +1,33 @@
# Integrations Index # Integrations Index
## Audience ## Audience
Developers and coding agents changing Narratio's external integration boundaries.
Operators, developers, and coding agents who need to understand Narratio's
externally observable integration boundaries.
## Scope ## Scope
`docs/integrations/` is the implementation-level reference for downstream tool adapter contracts.
These docs cover what Narratio expects from external tools and what each adapter guarantees back to stage code. `docs/integrations/` is the canonical reference for protocols, invocation and
data contracts, logical outputs, and compatibility behavior at external tool
boundaries.
These documents describe what Narratio sends or invokes, what it accepts in
return, and how failures are surfaced. Internal composition and stage mechanics
belong in [the adapter implementation guide](../internal/adapters.md) and the
focused stage documents.
## Integration Contracts ## Integration Contracts
- `audita.md`: transcript polishing adapter (`audita process`).
- `seriatim.md`: merge/normalize/trim/render adapter (`seriatim`). - [Audita](./audita.md): transcript polishing (`audita process`).
- `scriptorium.md`: artifact run/render adapter (`scriptorium run|render`). - [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
(`scriptorium run|render`).
- [WhisperX](./whisperx.md): speaker-audio transcription over HTTP.
## Related Canonical Docs ## Related Canonical Docs
- `docs/config.md`: operator-facing configuration reference.
- `docs/internal/adapters.md`: shared adapter boundary and runner wiring. - [Configuration](../config.md): operator-facing configuration reference.
- `docs/internal/stage-*.md`: stage-specific integration usage. - [Adapter implementation](../internal/adapters.md): shared adapter boundary and
runner wiring.
- [Internal documentation](../internal/overview.md): stage-specific integration
usage and component ownership.

View File

@@ -3,16 +3,12 @@
## Purpose ## Purpose
Define the Audita adapter contract used by the `polish` stage. Define the Audita adapter contract used by the `polish` stage.
## Adapter Boundary ## External Boundary
Interface:
- `audita.Runner`
- method: `Run(ctx, PolishRequest) (PolishResult, error)`
Primary implementation: Narratio invokes `audita process` as a subprocess for each polish operation.
- `internal/adapters/audita/SubprocessRunner` The configured timeout and parent cancellation bound the invocation. Internal
runner composition is documented in
Execution mode: [the adapter implementation guide](../internal/adapters.md).
- subprocess invocation of `audita process`
## Request Contract ## Request Contract
`PolishRequest` carries: `PolishRequest` carries:
@@ -52,9 +48,12 @@ Failure results still include output/log/config/exit metadata for diagnostics.
- Generated invocation YAML (`audita.generated.v1`) is emitted when requested. - Generated invocation YAML (`audita.generated.v1`) is emitted when requested.
- Manifest writes are stage-owned; adapter itself is stateless. - Manifest writes are stage-owned; adapter itself is stateless.
## Config Mapping ## Configuration
Config fields consumed through runner/stage wiring are under `pipeline.audita.*`.
Operator-selected values are defined under `pipeline.audita.*` in the
[configuration reference](../config.md#pipeline).
Maintained example with Audita config: Maintained example with Audita config:
- `examples/pipeline.full.annotated.yml`
- `examples/pipeline.production.yml` - [Full annotated pipeline](../../examples/pipeline.full.annotated.yml)
- [Production-shaped pipeline](../../examples/pipeline.production.yml)

View File

@@ -3,20 +3,17 @@
## Purpose ## Purpose
Define the Scriptorium adapter contract used by `analyze` and trim-bounds generation in `trim`. Define the Scriptorium adapter contract used by `analyze` and trim-bounds generation in `trim`.
## Adapter Boundary ## External Boundary
Interface:
- `scriptorium.Runner`
- methods:
- `RunArtifact(ctx, RunArtifactRequest)`
- `RenderArtifact(ctx, RenderArtifactRequest)`
Primary implementation: Narratio invokes Scriptorium as a subprocess in these modes:
- `internal/adapters/scriptorium/SubprocessRunner`
Execution modes:
- `scriptorium run` - `scriptorium run`
- `scriptorium render` - `scriptorium render`
The request timeout and parent cancellation bound each invocation. Internal
runner composition is documented in
[the adapter implementation guide](../internal/adapters.md).
## Request Contract ## Request Contract
Both request types carry: Both request types carry:
- binary/config/prompt/profile IDs; - binary/config/prompt/profile IDs;
@@ -55,12 +52,17 @@ Render behavior:
## Deterministic Behavior ## Deterministic Behavior
- input and var maps are sorted into deterministic `--input` and `--var` CLI args. - input and var maps are sorted into deterministic `--input` and `--var` CLI args.
- stage wiring adds `session_id=narratio-session-<session_id>` to every Scriptorium request for sticky upstream routing, overriding any configured `vars.session_id`.
- generated invocation YAML (`scriptorium.generated.v1`) is emitted when requested. - generated invocation YAML (`scriptorium.generated.v1`) is emitted when requested.
- adapter is stateless and does not own artifact-selection policy. - adapter is stateless and does not own artifact-selection policy.
## Config Mapping ## Configuration
Config fields consumed through runner/stage wiring are under `pipeline.scriptorium.*` plus per-artifact settings under `pipeline.scriptorium.artifacts.*`.
Operator-selected values are defined under `pipeline.scriptorium.*`, including
per-artifact settings under `pipeline.scriptorium.artifacts.*`, in the
[configuration reference](../config.md#pipeline).
Maintained examples with Scriptorium config: Maintained examples with Scriptorium config:
- `examples/pipeline.full.annotated.yml`
- `examples/pipeline.production.yml` - [Full annotated pipeline](../../examples/pipeline.full.annotated.yml)
- [Production-shaped pipeline](../../examples/pipeline.production.yml)

View File

@@ -3,24 +3,19 @@
## Purpose ## Purpose
Define the Seriatim adapter contract used by `merge`, `normalize`, `trim`, and `render`. Define the Seriatim adapter contract used by `merge`, `normalize`, `trim`, and `render`.
## Adapter Boundary ## External Boundary
Interface:
- `seriatim.Runner`
- methods:
- `Run(ctx, MergeRequest)`
- `Normalize(ctx, NormalizeRequest)`
- `Trim(ctx, TrimRequest)`
- `Render(ctx, RenderRequest)`
Primary implementation: Narratio invokes Seriatim as a subprocess in these modes:
- `internal/adapters/seriatim/SubprocessRunner`
Execution modes:
- `seriatim merge` - `seriatim merge`
- `seriatim normalize` - `seriatim normalize`
- `seriatim trim` - `seriatim trim`
- `seriatim render` - `seriatim render`
The configured timeout and parent cancellation bound each invocation. Internal
runner composition is documented in
[the adapter implementation guide](../internal/adapters.md).
## 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.
@@ -53,9 +48,13 @@ When report paths are provided/enabled, report files must parse as JSON.
- generated invocation YAML (`seriatim.generated.v1`) is emitted when requested. - generated invocation YAML (`seriatim.generated.v1`) is emitted when requested.
- adapter does not write manifests or choose stage inputs. - adapter does not write manifests or choose stage inputs.
## Config Mapping ## Configuration
Config fields consumed through runner/stage wiring are under `pipeline.seriatim.*` and `pipeline.render.*`.
Operator-selected values are defined under `pipeline.seriatim.*` and
`pipeline.render.*` in the
[configuration reference](../config.md#pipeline).
Maintained examples with Seriatim config: Maintained examples with Seriatim config:
- `examples/pipeline.full.annotated.yml`
- `examples/pipeline.production.yml` - [Full annotated pipeline](../../examples/pipeline.full.annotated.yml)
- [Production-shaped pipeline](../../examples/pipeline.production.yml)

View File

@@ -0,0 +1,66 @@
# Integration: WhisperX
## Purpose
WhisperX transcribes each prepared speaker audio file for Narratio's
`transcribe` stage. Narratio uses an HTTP boundary and installs each successful
response as that speaker's raw transcript JSON.
## HTTP Boundary
Narratio sends an HTTP `POST` to the configured transcription URL using
`multipart/form-data` with:
- `file`: the audio file, retaining its base filename; and
- `language`: the configured language string.
The server must return a `2xx` response whose body is valid JSON. Narratio does
not currently require a more specific response schema at this boundary.
## Request And Result Contract
Each adapter request identifies a speaker, a readable audio file, and the
destination for the raw transcript. The HTTP request carries the audio and
language; the speaker identifier remains Narratio orchestration metadata.
On success, Narratio atomically writes the response body to the requested
destination. The adapter result reports that logical output together with the
attempt count, final HTTP status when available, elapsed duration, and adapter
identity metadata. A failed or invalid response is not installed as the
transcript output.
## Retry, Timeout, And Cancellation
- The configured timeout applies independently to each HTTP attempt.
- `retries` means additional attempts after the first.
- HTTP `429`, HTTP `5xx`, attempt timeouts, and network errors are retryable.
- Other HTTP `4xx` responses and explicit cancellation are not retryable.
- Narratio waits the configured retry delay between attempts and aborts that
wait when the parent context is canceled.
## Validation And Failure Semantics
Client construction rejects a missing or invalid absolute transcription URL,
a missing language, a non-positive timeout, negative retries, or a negative
retry delay. A request fails before transmission when its audio or output path
is missing.
Non-`2xx` status, transport failure, response-size overflow, invalid JSON, or
failure to install the output causes the transcription to fail. Errors include
attempt context, and the result retains attempts, final status when available,
and elapsed duration for diagnostics.
## Determinism And Concurrency
Each audio request has stable multipart field names, and successful bytes are
installed atomically. The transcribe stage may process speaker files in
parallel, bounded by the configured concurrency. It records results in stable
speaker order after all work completes; any speaker failure fails the stage.
## Related Canonical Docs
- [Configuration](../config.md#pipeline) defines the operator-selected
WhisperX URL, language, timeouts, retry policy, and concurrency.
- [Adapter implementation](../internal/adapters.md) describes internal wiring.
- [Transcribe stage](../internal/stage-transcribe.md) describes stage mechanics,
durable artifacts, and manifests.

View File

@@ -1,45 +0,0 @@
# Internal Documentation Index
## Audience
Developers and coding agents changing Narratio internals.
## Scope
`docs/internal/` documents implemented internal contracts: stage boundaries, manifest/state behavior, artifact resolution, restore behavior, storage boundaries, and workspace invariants.
User and operator behavior belongs in:
- `docs/cli.md`
- `docs/config.md`
- `docs/operations.md`
- `docs/troubleshooting.md`
## Pipeline Stage Set
Canonical stage order from `internal/stage.All()`:
1. `prepare`
2. `transcribe`
3. `merge`
4. `polish`
5. `normalize`
6. `trim`
7. `render`
8. `analyze`
9. `publish`
10. `notify` (placeholder)
`notify` is currently a placeholder stage with optional notifier call behavior; it has no persisted pipeline outputs.
## Internal Component Docs
- `adapters.md`: external adapter boundaries and default runtime wiring.
- `artifacts.md`: canonical source IDs, runtime catalog behavior, and resolution rules.
- `manifest.md`: session and run manifest contracts.
- `storage.md`: object-store interface and S3 implementation behavior.
- `workspace.md`: local session layout, run-local layout, and cleanup guardrails.
- `command-restore.md`: restore discovery, planning, execution, and reporting.
- `stage-prepare.md`
- `stage-transcribe.md`
- `stage-merge.md`
- `stage-polish.md`
- `stage-normalize.md`
- `stage-trim.md`
- `stage-render.md`
- `stage-analyze.md`
- `stage-publish.md`

View File

@@ -1,12 +1,17 @@
# Internal: Adapters # Internal: Adapters
## Purpose ## Purpose
Define external integration boundaries and default adapter wiring used by app/stage orchestration.
Explain the adapter interfaces and production composition used by application
and stage orchestration. Externally observable protocols and formats belong in
the [integration contracts](../integrations/).
## Adapter Boundaries ## Adapter Boundaries
Narratio stage logic depends on adapter interfaces, not transport-specific details. Narratio stage logic depends on adapter interfaces, not transport-specific details.
Primary adapters: Primary adapters:
- `whisperx.Client` - `whisperx.Client`
- `seriatim.Runner` - `seriatim.Runner`
- `audita.Runner` - `audita.Runner`
@@ -15,17 +20,22 @@ Primary adapters:
- `notify.Sender` - `notify.Sender`
## Ownership ## Ownership
Adapters own: Adapters own:
- HTTP/subprocess/SDK argument and transport details. - HTTP/subprocess/SDK argument and transport details.
- Backend-specific request/response mapping. - Backend-specific request/response mapping.
Adapters do not own: Adapters do not own:
- stage ordering/skip/force logic; - stage ordering/skip/force logic;
- manifest transitions; - manifest transitions;
- canonical path policy. - canonical path policy.
## Default Wiring ## Default Wiring
`internal/app/runner.go` initializes default adapters when not injected: `internal/app/runner.go` initializes default adapters when not injected:
- WhisperX HTTP client from pipeline config. - WhisperX HTTP client from pipeline config.
- Seriatim subprocess runner. - Seriatim subprocess runner.
- Audita subprocess runner. - Audita subprocess runner.
@@ -33,17 +43,29 @@ Adapters do not own:
- Noop notifier (`notify.NoopSender`). - Noop notifier (`notify.NoopSender`).
- Object store only when required by selected stages/config. - Object store only when required by selected stages/config.
Object-store construction goes through `newCommandObjectStore`, which loads configured filesystem secrets before adapter initialization. Object-store construction goes through `newCommandObjectStore`, which loads
configured filesystem secrets before adapter initialization.
## Failure Semantics ## Failure Semantics
- Constructor errors fail stage execution setup early. - Constructor errors fail stage execution setup early.
- Runtime adapter errors propagate to stage code and then manifest failure handling. - Runtime adapter errors propagate to stage code and then manifest failure handling.
- Subprocess adapters persist stage logs/generated configs through stage-managed paths. - Subprocess adapters persist stage logs/generated configs through stage-managed paths.
## Test Surfaces ## Implementation And Tests
- Composition: `internal/app/runner.go`, `internal/app/object_store.go`
- Shared subprocess mechanics: `internal/adapters/subprocess`
- Focused adapters: `internal/adapters/{whisperx,seriatim,audita,scriptorium,storage,notify}`
- `internal/adapters/whisperx/http_test.go` - `internal/adapters/whisperx/http_test.go`
- `internal/adapters/seriatim/subprocess_test.go` - `internal/adapters/seriatim/subprocess_test.go`
- `internal/adapters/audita/subprocess_test.go` - `internal/adapters/audita/subprocess_test.go`
- `internal/adapters/scriptorium/subprocess_test.go` - `internal/adapters/scriptorium/subprocess_test.go`
- `internal/adapters/storage/*_test.go` - `internal/adapters/storage/*_test.go`
- `internal/app/runner_test.go` - `internal/app/runner_test.go`
See the [WhisperX](../integrations/whisperx.md),
[Seriatim](../integrations/seriatim.md), [Audita](../integrations/audita.md),
and [Scriptorium](../integrations/scriptorium.md) contracts before changing an
externally visible boundary. Operator-selected values belong in
[Configuration](../config.md).

View File

@@ -1,17 +1,28 @@
# Internal: Artifacts # Internal: Artifacts
## Purpose ## Purpose
Define canonical artifact IDs, runtime catalog behavior, source resolution rules, and shared current-state mechanics used by app and previous-cache code.
Explain the artifact registry, runtime catalog, resolver, previous-input
requirements, and shared remote current-state mechanics implemented by
`internal/artifacts`. Configuration fields that accept source IDs belong in
[Configuration](../config.md); physical placement belongs in
[Operations](../operations.md).
## Built-in Source IDs ## Built-in Source IDs
- `narratio.transcript.base` -> `transcripts/base.json` (`merge`) The internal registry recognizes these stable built-in source IDs:
- `narratio.transcript.polished` -> `transcripts/polished.json` (`polish`)
- `narratio.transcript.final` -> `transcripts/final.json` (`normalize`) - `narratio.transcript.base`
- `narratio.transcript.final_trimmed` -> `transcripts/final.trimmed.json` (`trim`) - `narratio.transcript.polished`
- `narratio.transcript.final_markdown` -> `transcripts/final.md` (`render`) - `narratio.transcript.final`
- `narratio.transcript.final_trimmed_markdown` -> `transcripts/final.trimmed.md` (`render`) - `narratio.transcript.final_trimmed`
- `narratio.bounds.session` -> `artifacts/session_bounds.json` (`trim`) - `narratio.transcript.final_markdown`
- `narratio.transcript.final_trimmed_markdown`
- `narratio.bounds.session`
Registry entries bind each ID to its producer, output kind, canonical fallback,
and content validator. The focused stage documents own their input/output flow;
[Configuration](../config.md) owns where operators may select these IDs.
## Configured and Previous-Session Sources ## Configured and Previous-Session Sources
@@ -72,7 +83,8 @@ Validation by content type:
## Current-State Helpers ## Current-State Helpers
Artifacts package owns shared remote current-state loading mechanics used by restore, status/validate checks, and previous-cache planning. Artifacts package owns shared remote current-state loading mechanics used by
restore, status and validation checks, and previous-cache planning.
Core helpers: Core helpers:
@@ -107,9 +119,27 @@ Caller policy is intentionally outside artifacts helpers:
- spool/cache paths; - spool/cache paths;
- S3 session/run/current-state key layout. - S3 session/run/current-state key layout.
See [Workspace Internals](workspace.md) for how callers consume local helpers
and [Operations](../operations.md#local-state-layout) for the authoritative
physical layout.
## Invariants ## Invariants
- source ID formats are stable contracts; - source ID formats are stable contracts;
- artifact resolution is deterministic and manifest-aware; - artifact resolution is deterministic and manifest-aware;
- previous-session source resolution in `analyze` is local-only; - previous-session source resolution in `analyze` is local-only;
- remote current-state key construction remains centralized in artifacts helpers. - remote current-state key construction remains centralized in artifacts helpers.
## Implementation And Tests
- Registry and resolution: `internal/artifacts/artifact_resolver.go`,
`internal/artifacts/catalog.go`, `internal/artifacts/transcripts.go`
- Current state: `internal/artifacts/current_state.go`
- Paths and keys: `internal/artifacts/paths.go`,
`internal/artifacts/s3_keys.go`
- Previous requirements: `internal/artifacts/previous_requirements.go`
- Tests: `internal/artifacts/artifact_resolver_test.go`,
`internal/artifacts/catalog_test.go`,
`internal/artifacts/current_state_test.go`,
`internal/artifacts/paths_model_test.go`,
`internal/artifacts/previous_requirements_test.go`

View File

@@ -1,21 +1,20 @@
# Internal: Command Restore # Internal: Command Restore
## Purpose ## Purpose
Define the implemented `narratio session restore` command contract:
- committed remote current-state discovery; Explain the implemented restore discovery, planning, installation, and
- deterministic restore planning; reporting flow in `internal/app`. User invocation belongs in
- safe local install semantics; [CLI](../cli.md#session-restore), and the operator recovery procedure and
- durable restore reporting. physical restore scope belong in
[Operations](../operations.md#restore-workflow).
Restore is split into explicit phases so remote authority, local conflict
policy, and filesystem mutation can be tested independently.
## Discovery Contract ## Discovery Contract
Restore resolves remote committed state from the session publish current pointers: Discovery delegates current-state pointer and manifest loading to
`internal/artifacts`, then validates the result against the resolved request:
- `current/run_id.txt` (required, non-empty);
- `current/manifest.json` (required, decodable).
Current-state discovery uses shared artifacts-level mechanics and validates identity against the resolved request config:
- campaign must match; - campaign must match;
- session ID must match. - session ID must match.
@@ -34,26 +33,11 @@ Planner behavior:
- remote list scope is the resolved session prefix; - remote list scope is the resolved session prefix;
- remote-to-local mapping is traversal-safe; - remote-to-local mapping is traversal-safe;
- actions are sorted deterministically by local relative path. - actions are sorted by local relative path and then remote key;
- force converts differing local targets from conflicts to downloads.
Restore scope from current remote state: Previous-cache files are planned separately through `previouscache.BuildPlan`
when configured previous-session requirements exist.
- include `manifest.json`;
- include `transcripts/**`;
- include `artifacts/**`;
- include `audio/**` only with `--include-audio`.
Explicit exclusions from current remote state mapping:
- `current/**`;
- `runs/**`;
- `logs/**`;
- `reports/**`;
- `config/**`;
- `inputs/**`;
- `previous/**`.
Previous-cache files are planned separately through `previouscache.BuildPlan` when configured previous-session requirements exist.
## Execution Contract ## Execution Contract
@@ -73,8 +57,9 @@ Audio restore path:
## Reporting Contract ## Reporting Contract
- `--dry-run`: prints summary only; no local writes. - dry-run mode prints a summary and performs no local writes;
- non-dry-run: writes `reports/restore-latest.json`. - execution mode persists the canonical restore report described in
[Operations](../operations.md#restore-workflow);
- report includes plan counts, per-action status, and execution failures. - report includes plan counts, per-action status, and execution failures.
## Invariants ## Invariants
@@ -82,3 +67,15 @@ Audio restore path:
- restore uses committed remote current state as authority; - restore uses committed remote current state as authority;
- `current/run_id.txt` is the remote publish commit marker; - `current/run_id.txt` is the remote publish commit marker;
- restore does not execute pipeline stages. - restore does not execute pipeline stages.
## Implementation And Tests
- Discovery: `internal/app/restore_discovery.go`
- Planning: `internal/app/restore_plan.go`, `internal/previouscache`
- Execution: `internal/app/restore_execute.go`
- Reporting and command coordination: `internal/app/restore_report.go`,
`internal/app/restore.go`
- Tests: `internal/app/restore_discovery_test.go`,
`internal/app/restore_plan_test.go`,
`internal/app/restore_execution_test.go`,
`internal/app/restore_workflow_test.go`

View File

@@ -1,13 +1,15 @@
# Internal: Manifest # Internal: Manifest
## Purpose ## Purpose
Define durable session state (`manifest.json`) and invocation state (`runs/{run_id}/manifest.json`) contracts.
Explain the session-progress and invocation-audit models implemented by
`internal/manifest`. Physical manifest placement belongs in
[Operations](../operations.md#local-state-layout).
## Session Manifest ## Session Manifest
Path:
- `{workspace.root}/work/{campaign}/{session_id}/manifest.json`
Primary model (`manifest.Manifest`): `manifest.Manifest` records:
- identity (`session_id`, `campaign`, `run_id`) - identity (`session_id`, `campaign`, `run_id`)
- local path metadata (`local_workdir`, `local_spool_dir`) - local path metadata (`local_workdir`, `local_spool_dir`)
- remote identity metadata (`s3_bucket`, `s3_session_prefix`, `s3_run_prefix`) - remote identity metadata (`s3_bucket`, `s3_session_prefix`, `s3_run_prefix`)
@@ -15,7 +17,8 @@ Primary model (`manifest.Manifest`):
- durable `artifacts` records - durable `artifacts` records
- per-stage `stages` map - per-stage `stages` map
Stage status enum: The model admits these stage states:
- `pending` - `pending`
- `running` - `running`
- `succeeded` - `succeeded`
@@ -25,10 +28,9 @@ Stage status enum:
- `interrupted` - `interrupted`
## Run Manifest ## Run Manifest
Path:
- `{workspace.root}/work/{campaign}/{session_id}/runs/{run_id}/manifest.json`
Run model (`manifest.RunManifest`): `manifest.RunManifest` is created for each invocation and records:
- invocation identity and `force` flag - invocation identity and `force` flag
- requested stages - requested stages
- per-stage action (`run` or `skip`) - per-stage action (`run` or `skip`)
@@ -36,22 +38,40 @@ Run model (`manifest.RunManifest`):
- overall run status (`running`, `succeeded`, `failed`) - overall run status (`running`, `succeeded`, `failed`)
## Persistence Semantics ## Persistence Semantics
`manifest.LocalStore`: `manifest.LocalStore`:
- validates loaded documents; - validates loaded documents;
- normalizes missing maps/stage records; - normalizes missing maps/stage records;
- writes atomically via temp file + rename; - writes atomically via temp file + rename;
- updates `updated_at` on save. - updates `updated_at` on save.
## Execution Semantics ## Execution Semantics
Runner updates both manifests per stage transition:
- mark running The application runner marks an executing stage running and then succeeded or
- mark succeeded/failed/skipped failed in both manifests, persisting each transition. On success it records
- persist logs/generated config refs and metadata outputs, logs, generated configuration references, and metadata. A successful
forced rerun marks only succeeded downstream session-stage records stale.
When an already-succeeded stage is skipped, the invocation run manifest records
the `skip` action and reason. The session manifest deliberately retains its
existing succeeded record because it remains the cross-invocation progress
authority.
Session manifest is the authoritative stage-progress ledger across invocations. Session manifest is the authoritative stage-progress ledger across invocations.
Run manifest is invocation-scoped audit state. Run manifest is invocation-scoped audit state.
## Invariants ## Invariants
- stage resume/skip decisions are session-manifest driven. - stage resume/skip decisions are session-manifest driven.
- force reruns stale downstream succeeded stages. - force reruns stale downstream succeeded stages.
- run manifest does not replace session manifest as progress authority. - run manifest does not replace session manifest as progress authority.
## Implementation And Tests
- Models and transitions: `internal/manifest/manifest.go`,
`internal/manifest/run_manifest.go`
- Persistence and validation: `internal/manifest/store.go`
- Package tests: `internal/manifest/*_test.go`
- Assembled execution behavior: `internal/app/runner_test.go`,
`internal/app/run_stage_test.go`

96
docs/internal/overview.md Normal file
View File

@@ -0,0 +1,96 @@
# Internal Overview
This document is the implemented component map for Narratio. Normative system
boundaries and dependency direction belong in
[Architecture](../policy/architecture.md). User and operator contracts belong
in the [CLI](../cli.md), [Configuration](../config.md),
[Operations](../operations.md), and [Troubleshooting](../troubleshooting.md).
Externally observable tool and format contracts belong under
[Integrations](../integrations/).
## Execution Path
```text
cmd/narratio -> internal/app -> configuration and production composition
-> internal/stage -> adapters and external systems
-> manifests and artifact resolution -> durable local/remote output
```
The executable delegates process behavior to the application boundary. The
application resolves configuration, composes concrete collaborators, acquires
session safety controls, and runs commands. Pipeline commands execute the
canonical stage sequence through adapter interfaces, while manifests record
progress and artifact services resolve durable inputs and outputs.
## Components
| Area | Implemented owners | Responsibility |
| --- | --- | --- |
| Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. |
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, cleanup gates, and user-facing reporting. |
| Configuration | `internal/config` | Strict YAML loading, discovery, defaults, normalization, session templating, and validation. |
| Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. |
| External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. |
| Manifests | `internal/manifest` | Durable session progress, invocation audit state, stage transitions, validation, and atomic persistence. |
| Artifacts and paths | `internal/artifacts`, `internal/pathsafe` | Artifact identities and resolution, local and remote path/key models, current-state discovery, and confined relative destinations. |
| Previous-session cache | `internal/previouscache` | Deterministic planning and materialization requirements for configured previous-session inputs. |
| Artifact policy | `internal/artifactpolicy` | Source and destination policy, configured artifact identity validation, and publish destination safety. |
| Shared models and file operations | `internal/artifactmodel`, `internal/contracts`, `internal/fileops` | Transcript and artifact data contracts plus narrow atomic filesystem helpers. |
| Logging | `internal/logging` | Application logger construction and shared structured logging behavior. |
The application boundary composes concrete implementations. Stages depend on
Narratio-level contracts; external transport and SDK details remain in
adapters. The normative rules for these relationships remain in
[Architecture](../policy/architecture.md).
## Pipeline Stage Set
The implemented canonical order is:
1. [`prepare`](stage-prepare.md)
2. [`transcribe`](stage-transcribe.md)
3. [`merge`](stage-merge.md)
4. [`polish`](stage-polish.md)
5. [`normalize`](stage-normalize.md)
6. [`trim`](stage-trim.md)
7. [`render`](stage-render.md)
8. [`analyze`](stage-analyze.md)
9. [`publish`](stage-publish.md)
10. `notify` (placeholder)
`notify` currently has optional notifier call behavior and no persisted pipeline
outputs; its default collaborator is a no-op sender. The focused stage
documents own implementation mechanics. The
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
and execution semantics.
## Focused Documentation
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
failure behavior, and test surfaces.
- [Artifact Internals](artifacts.md): source identities, runtime catalog,
resolution, previous requirements, and current-state helpers.
- [Manifest Internals](manifest.md): session and run records, persistence, and
execution transitions.
- [Storage Internals](storage.md): object-store interface and S3 behavior.
- [Workspace Internals](workspace.md): local layout, locking, and cleanup
guardrails.
- [Restore Internals](command-restore.md): discovery, planning, execution, and
reporting.
- [`prepare`](stage-prepare.md)
- [`transcribe`](stage-transcribe.md)
- [`merge`](stage-merge.md)
- [`polish`](stage-polish.md)
- [`normalize`](stage-normalize.md)
- [`trim`](stage-trim.md)
- [`render`](stage-render.md)
- [`analyze`](stage-analyze.md)
- [`publish`](stage-publish.md)
Use this map to find an owner, then read its focused documentation and tests
before changing behavior.
The stage registry is implemented in `internal/stage/placeholders.go` and its
ordering is protected by `internal/app/planner_test.go`. Cross-invocation skip,
force, failure, and invalidation behavior is exercised in
`internal/app/runner_test.go` and `internal/app/run_stage_test.go`.

View File

@@ -1,39 +1,60 @@
# Stage: analyze # Stage: analyze
## Purpose ## Purpose
Execute selected configured Scriptorium artifacts in dependency order and materialize outputs. Execute selected configured Scriptorium artifacts in dependency order and materialize outputs.
## Inputs ## Inputs
- configured artifacts from `pipeline.scriptorium.artifacts` - configured artifacts from `pipeline.scriptorium.artifacts`
- optional selected artifact filter (`--artifacts`) - optional selected artifact keys supplied through the stage environment
- built-in/configured/previous-session source references in artifact inputs - built-in/configured/previous-session source references in artifact inputs
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>`
## Outputs ## Outputs
- one materialized output per executed configured artifact (`output_path`) - one materialized output per executed configured artifact (`output_path`)
- stage metadata describing selected/generated/reused artifacts - stage metadata describing selected/generated/reused artifacts
## Key Behavior ## Key Behavior
- skips with metadata when Scriptorium config is missing or no executable artifacts remain. - skips with metadata when Scriptorium config is missing or no executable artifacts remain.
- builds runtime artifact catalog (built-ins + configured artifacts). - builds runtime artifact catalog (built-ins + configured artifacts).
- 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. - 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.
## Invariants ## Invariants
- `analyze` performs no remote storage calls for previous-session source resolution. - `analyze` performs no remote storage calls for previous-session source resolution.
- output provenance and metadata are deterministic per execution. - output provenance and metadata are deterministic per execution.
## Related Contracts And Tests
- [Configuration](../config.md#scriptorium-artifact-entries) owns artifact
fields and source-selection rules.
- [CLI](../cli.md) owns user-visible artifact selection.
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
- Implementation and tests: `internal/stage/analyze.go`,
`internal/stage/analyze_test.go`

View File

@@ -1,18 +1,22 @@
# Stage: merge # Stage: merge
## Purpose ## Purpose
Normalize raw transcript inputs and merge into base transcript via Seriatim. Normalize raw transcript inputs and merge into base transcript via Seriatim.
## Inputs ## Inputs
- `transcripts/raw/*.json` - `transcripts/raw/*.json`
- `inputs/speakers.yml` - `inputs/speakers.yml`
- `inputs/autocorrect.yml` - `inputs/autocorrect.yml`
## Outputs ## Outputs
- `transcripts/base.json` - `transcripts/base.json`
- optional `artifacts/seriatim.report.json` - optional `artifacts/seriatim.report.json`
## Key Behavior ## Key Behavior
- discovers and validates raw transcript inputs. - discovers and validates raw transcript inputs.
- normalizes each raw transcript (`seriatim.Normalize`) into run-local scratch output. - normalizes each raw transcript (`seriatim.Normalize`) into run-local scratch output.
- merges normalized inputs (`seriatim.Run`) into base transcript. - merges normalized inputs (`seriatim.Run`) into base transcript.
@@ -20,6 +24,14 @@ Normalize raw transcript inputs and merge into base transcript via Seriatim.
- materializes canonical outputs and records stage logs/generated configs. - materializes canonical outputs and records stage logs/generated configs.
## Invariants ## Invariants
- merge always consumes normalized forms of raw inputs. - merge always consumes normalized forms of raw inputs.
- base transcript must validate before stage success. - base transcript must validate before stage success.
- report output is config-gated. - report output is config-gated.
## Related Contracts And Tests
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
- Implementation and tests: `internal/stage/merge.go`,
`internal/stage/merge_test.go`

View File

@@ -1,16 +1,20 @@
# Stage: normalize # Stage: normalize
## Purpose ## Purpose
Normalize polished transcript into final transcript using Seriatim. Normalize polished transcript into final transcript using Seriatim.
## Inputs ## Inputs
- `transcripts/polished.json` - `transcripts/polished.json`
## Outputs ## Outputs
- `transcripts/final.json` (or configured normalize output path) - `transcripts/final.json` (or configured normalize output path)
- optional `artifacts/seriatim.normalize.report.json` - optional `artifacts/seriatim.normalize.report.json`
## Key Behavior ## Key Behavior
- resolves polished transcript from manifest outputs/canonical fallback. - resolves polished transcript from manifest outputs/canonical fallback.
- applies `pipeline.normalize` config or default normalize config. - applies `pipeline.normalize` config or default normalize config.
- runs Seriatim normalize with configured timeout/binary. - runs Seriatim normalize with configured timeout/binary.
@@ -18,5 +22,13 @@ Normalize polished transcript into final transcript using Seriatim.
- materializes canonical outputs and records logs/generated configs. - materializes canonical outputs and records logs/generated configs.
## Invariants ## Invariants
- final transcript must validate as processed transcript JSON (`segments` array). - final transcript must validate as processed transcript JSON (`segments` array).
- normalize defaults are applied when `pipeline.normalize` is unset. - normalize defaults are applied when `pipeline.normalize` is unset.
## Related Contracts And Tests
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
- [Configuration](../config.md#pipeline) owns normalize fields and defaults.
- Implementation and tests: `internal/stage/normalize.go`,
`internal/stage/normalize_test.go`

View File

@@ -1,17 +1,21 @@
# Stage: polish # Stage: polish
## Purpose ## Purpose
Run Audita polishing on base transcript and produce polished transcript. Run Audita polishing on base transcript and produce polished transcript.
## Inputs ## Inputs
- `transcripts/base.json` - `transcripts/base.json`
- `inputs/glossary.yml` - `inputs/glossary.yml`
## Outputs ## Outputs
- `transcripts/polished.json` - `transcripts/polished.json`
- optional `artifacts/audita.report.json` - optional `artifacts/audita.report.json`
## Key Behavior ## Key Behavior
- resolves base transcript from merge outputs/canonical fallback. - resolves base transcript from merge outputs/canonical fallback.
- invokes Audita with configured model/module/runtime options. - invokes Audita with configured model/module/runtime options.
- validates processed transcript structure (`segments` array required). - validates processed transcript structure (`segments` array required).
@@ -19,5 +23,14 @@ Run Audita polishing on base transcript and produce polished transcript.
- materializes canonical outputs; records logs/generated config and adapter metadata. - materializes canonical outputs; records logs/generated config and adapter metadata.
## Invariants ## Invariants
- polished transcript schema validation is mandatory. - polished transcript schema validation is mandatory.
- report output is config-gated. - report output is config-gated.
## Related Contracts And Tests
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
semantics.
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
- Implementation and tests: `internal/stage/polish.go`,
`internal/stage/polish_test.go`

View File

@@ -1,29 +1,33 @@
# Stage: prepare # Stage: prepare
## Purpose ## Purpose
Materialize canonical current-session inputs before processing stages. Materialize canonical current-session inputs before processing stages.
## Inputs ## Inputs
- resolved `campaign.yml`, `session.yml`, and pipeline config
- stable input files (`speakers`, `autocorrect`, `glossary`) - resolved campaign, session, and pipeline configuration
- audio source: - stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
- local `audio_dir`/`audio_files`, or - one resolved local or S3 audio source
- S3 `audio_s3.prefix`
- enabled configured artifact input requirements for previous-session sources - enabled configured artifact input requirements for previous-session sources
## Outputs ## Outputs
- `inputs/campaign.yml` - `inputs/campaign.yml`
- `inputs/session.yml` - `inputs/session.yml`
- `inputs/pipeline.resolved.yml` - `inputs/pipeline.resolved.yml`
- `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/**`
- deterministic `manifest.inputs` entries (checksums + provenance) - deterministic `manifest.inputs` entries (checksums + provenance)
## Key Behavior ## Key Behavior
- validates required config/store state. - validates required config/store state.
- enforces local audio vs S3 audio mutual exclusivity. - enforces local audio vs S3 audio mutual exclusivity.
- materializes S3 audio through spool/cache-aware logic. - materializes S3 audio through spool/cache-aware logic.
@@ -37,6 +41,20 @@ Materialize canonical current-session inputs before processing stages.
Required previous-session inputs fail when unavailable; optional missing inputs are skipped. Required previous-session inputs fail when unavailable; optional missing inputs are skipped.
## Invariants ## Invariants
- only `prepare` hydrates canonical `previous/` cache state. - only `prepare` hydrates canonical `previous/` cache state.
- managed previous artifacts are stored under `previous/artifacts/**` without duplicate `artifacts/artifacts/` nesting. - managed previous artifacts are stored under `previous/artifacts/**` without
duplicate `artifacts/artifacts/` nesting.
- `manifest.inputs` ordering is deterministic (`kind`, `path`). - `manifest.inputs` ordering is deterministic (`kind`, `path`).
## Related Contracts And Tests
- [Configuration](../config.md) owns audio selection, stable input fields, and
previous-session settings.
- [Operations](../operations.md) owns physical input, audio, spool, cache, and
previous-state layout.
- [Storage Internals](storage.md) and [Artifact Internals](artifacts.md) explain
the internal collaborators.
- Implementation and tests: `internal/stage/prepare.go`,
`internal/stage/prepare_test.go`, `internal/audio/s3_audio_test.go`,
`internal/previouscache/*_test.go`

View File

@@ -1,23 +1,29 @@
# Stage: publish # Stage: publish
## Purpose ## Purpose
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`, `render`, `analyze`
- run root `runs/{run_id}/**` - successful preceding stages from the [canonical stage set](overview.md#pipeline-stage-set)
- publish output rules (`pipeline.publish.outputs`) - invocation-scoped run files
- resolved publish output rules
- effective publish locks (static + remote merged lock set) - effective publish locks (static + remote merged lock set)
- local `previous/**` files when present - durable previous-session cache files when present
## Outputs ## Outputs
- uploaded run files under remote `runs/{run_id}/...` (excluding `audio/**`)
- uploaded selected publish outputs under session prefix - uploaded invocation record and selected publish outputs;
- uploaded `previous/**` files under session prefix when present - uploaded durable previous-session cache files when present;
- uploaded `current/manifest.json` - updated remote current manifest; and
- uploaded `current/run_id.txt` written last - remote current-run commit marker, written last.
Exact remote placement and the operator workflow belong in
[Operations](../operations.md#publish-workflow).
## Key Behavior ## Key Behavior
- stage can self-skip when publish disabled or run upload disabled. - stage can self-skip when publish disabled or run upload disabled.
- validates prerequisite stage success and object-store availability. - validates prerequisite stage success and object-store availability.
- collects deterministic run file list plus run `manifest.json`. - collects deterministic run file list plus run `manifest.json`.
@@ -28,6 +34,7 @@ Upload run/session outputs to object storage and atomically advance remote curre
- writes remote current manifest before current run pointer. - writes remote current manifest before current run pointer.
## Metadata Signals ## Metadata Signals
Includes counts/lists for: Includes counts/lists for:
- run uploads - run uploads
- published output uploads - published output uploads
@@ -39,6 +46,22 @@ Includes counts/lists for:
- `current_pointer_written` - `current_pointer_written`
## Invariants ## Invariants
- `current/run_id.txt` is the remote commit marker and is written last. - `current/run_id.txt` is the remote commit marker and is written last.
- run upload excludes `audio/**`. - run upload excludes `audio/**`.
- publish locks are not overridden by `--force`. - publish locks are not overridden by `--force`.
The commit boundary and cleanup gate are normative architecture invariants; see
[Architecture](../policy/architecture.md#publish-commit-boundary).
## Related Contracts And Tests
- [Configuration](../config.md#publish-configuration-summary) owns output and
static-lock fields.
- [Operations](../operations.md#publish-locks) owns remote lock lifecycle and
physical remote state.
- [Artifact Internals](artifacts.md) explains source resolution and current-state
helpers.
- Implementation and tests: `internal/stage/publish.go`,
`internal/stage/publish_test.go`, `internal/app/operator_helpers_test.go`,
`internal/app/post_publish_cleanup_test.go`

View File

@@ -1,17 +1,21 @@
# Stage: render # Stage: render
## Purpose ## Purpose
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim. Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
## Inputs ## Inputs
- `narratio.transcript.final` (`transcripts/final.json`) - `narratio.transcript.final` (`transcripts/final.json`)
- `narratio.transcript.final_trimmed` (`transcripts/final.trimmed.json`) - `narratio.transcript.final_trimmed` (`transcripts/final.trimmed.json`)
## Outputs ## Outputs
- `narratio.transcript.final_markdown` -> `transcripts/final.md` - `narratio.transcript.final_markdown` -> `transcripts/final.md`
- `narratio.transcript.final_trimmed_markdown` -> `transcripts/final.trimmed.md` - `narratio.transcript.final_trimmed_markdown` -> `transcripts/final.trimmed.md`
## Key Behavior ## Key Behavior
- uses `pipeline.render` settings (enabled/format/title/booleans). - uses `pipeline.render` settings (enabled/format/title/booleans).
- resolves inputs manifest-first, then canonical fallback. - resolves inputs manifest-first, then canonical fallback.
- writes run-local outputs first, then materializes canonical session outputs. - writes run-local outputs first, then materializes canonical session outputs.
@@ -19,11 +23,20 @@ Render Markdown transcript artifacts from normalized JSON transcripts via Seriat
- skips with stage metadata when `pipeline.render.enabled=false`. - skips with stage metadata when `pipeline.render.enabled=false`.
## Failure Semantics ## Failure Semantics
- missing normalized input fails with normalize rerun guidance. - missing normalized input fails with normalize rerun guidance.
- missing trimmed input fails with trim rerun guidance. - missing trimmed input fails with trim rerun guidance.
- adapter/subprocess failure fails stage. - adapter/subprocess failure fails stage.
- empty render output files fail validation. - empty render output files fail validation.
## Invariants ## Invariants
- only `format: markdown` is supported. - only `format: markdown` is supported.
- render stage owns production of built-in Markdown transcript sources. - render stage owns production of built-in Markdown transcript sources.
## Related Contracts And Tests
- [Seriatim](../integrations/seriatim.md) owns render subprocess behavior.
- [Configuration](../config.md#pipeline) owns render fields and defaults.
- Implementation and tests: `internal/stage/render.go`,
`internal/stage/render_test.go`

View File

@@ -1,22 +1,36 @@
# Stage: transcribe # Stage: transcribe
## Purpose ## Purpose
Generate raw per-speaker transcripts from prepared audio using WhisperX. Generate raw per-speaker transcripts from prepared audio using WhisperX.
## Inputs ## Inputs
- `audio/*.flac` from `prepare` - `audio/*.flac` from `prepare`
## Outputs ## Outputs
- `transcripts/raw/<speaker>.json` - `transcripts/raw/<speaker>.json`
## Key Behavior ## Key Behavior
- discovers prepared audio from manifest inputs or canonical audio directory. - discovers prepared audio from manifest inputs or canonical audio directory.
- derives speaker ID from `.flac` basename. - derives speaker ID from `.flac` basename.
- runs WhisperX with configured concurrency/retry settings. - dispatches WhisperX requests through a bounded worker pool.
- validates each output as JSON. - validates each output as JSON.
- writes run-local outputs then materializes canonical transcript outputs. - writes run-local outputs then materializes canonical transcript outputs.
## Invariants ## Invariants
- speaker basenames must be unique. - speaker basenames must be unique.
- output path returned by adapter must match requested output path. - output path returned by adapter must match requested output path.
- each successful output is validated before stage success. - each successful output is validated before stage success.
## Related Contracts And Tests
- [WhisperX](../integrations/whisperx.md) owns HTTP, retry, timeout, and
cancellation semantics.
- [Configuration](../config.md#pipeline) owns concurrency and other
operator-selected values.
- Implementation and tests: `internal/stage/transcribe.go`,
`internal/stage/transcribe_test.go`

View File

@@ -1,18 +1,20 @@
# 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`
## Outputs ## Outputs
- `transcripts/final.trimmed.json` (or configured trim output path) - `transcripts/final.trimmed.json` (or configured trim output path)
- 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;
@@ -22,7 +24,20 @@ 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.
- render-debug output is diagnostic and not a declared stage output. - render-debug output is diagnostic and not a declared stage output.
## Related Contracts And Tests
- [Scriptorium](../integrations/scriptorium.md) owns bounds generation and
debug-render subprocess behavior.
- [Seriatim](../integrations/seriatim.md) owns transcript trimming behavior.
- [Configuration](../config.md#pipeline) owns trim fields and defaults.
- Implementation and tests: `internal/stage/trim.go`,
`internal/stage/trim_test.go`

View File

@@ -1,10 +1,16 @@
# Internal: Storage # Internal: Storage
## Purpose ## Purpose
Document remote object-store contracts and S3 implementation behavior.
Explain the object-store interface and S3 implementation used by Narratio.
Remote key layout and lifecycle belong in [Operations](../operations.md), while
operator-selected storage fields and credential mechanisms belong in
[Configuration](../config.md).
## Primary Contract ## Primary Contract
`storage.ObjectStore` interface: `storage.ObjectStore` interface:
- `List(ctx, prefix)` - `List(ctx, prefix)`
- `Download(ctx, key, localPath)` - `Download(ctx, key, localPath)`
- `Upload(ctx, localPath, key, opts)` - `Upload(ctx, localPath, key, opts)`
@@ -14,15 +20,15 @@ Key invariant:
- callers pass full bucket-relative keys; - callers pass full bucket-relative keys;
- storage implementations do not infer campaign/session/run prefixes. - storage implementations do not infer campaign/session/run prefixes.
## Configuration ## Composition
`NewObjectStoreFromConfig` currently supports S3-backed stores from `pipeline.storage.*` config.
S3 constructor behavior: `NewObjectStoreFromConfig` constructs the S3-backed implementation from
- requires configured bucket; resolved configuration. The application loads configured filesystem secrets
- uses region/endpoint/path-style options when set; before calling it. The storage adapter consumes already-resolved values; it does
- resolves credentials from configured env var names (with defaults). not own discovery, defaults, or configuration validation.
## S3 Backend Behavior ## S3 Backend Behavior
- normalizes object keys. - normalizes object keys.
- `List` paginates and returns normalized `ObjectInfo`. - `List` paginates and returns normalized `ObjectInfo`.
- `Download` writes local files with parent directory creation. - `Download` writes local files with parent directory creation.
@@ -30,5 +36,13 @@ S3 constructor behavior:
- `Exists` maps not-found responses to `false`. - `Exists` maps not-found responses to `false`.
## Invariants ## Invariants
- storage layer is stateless regarding manifest/stage progression. - storage layer is stateless regarding manifest/stage progression.
- publish ordering semantics are owned by stage/app code, not storage adapters. - publish ordering semantics are owned by stage/app code, not storage adapters.
## Implementation And Tests
- Contract and S3 adapter: `internal/adapters/storage`
- Composition: `internal/app/object_store.go`
- Tests: `internal/adapters/storage/*_test.go`,
`internal/app/object_store_test.go`

View File

@@ -1,57 +1,62 @@
# Internal: Workspace # Internal: Workspace
## Purpose ## Purpose
Define local session layout, run-local stage layout, and cleanup guardrails.
## Canonical Session Layout Explain the helpers that construct local session and run paths, coordinate
Session root: single-writer access, and confine cleanup. The authoritative physical layout and
- `{workspace.root}/work/{campaign}/{session_id}` retention workflow belong in [Operations](../operations.md#local-state-layout).
Core directories/files: ## Path Ownership
- `inputs/`
- `audio/`
- `transcripts/`
- `artifacts/`
- `reports/`
- `logs/`
- `config/`
- `current/`
- `runs/`
- `previous/`
- `manifest.json`
- `.lock`
`previous/` reserved files: `internal/artifacts` owns canonical session, run, spool, cache, and
- `previous/manifest.json` previous-cache path construction. `SessionPathsFor` provides the session-scoped
- `previous/artifacts/**` path model, and layout creation goes through `EnsureLayoutFor`. Callers should
consume those helpers instead of rebuilding relative paths.
`internal/pathsafe` and application cleanup helpers enforce confinement for
relative destinations and deletion targets.
## Run-Local Stage Layout ## Run-Local Stage Layout
When run context is available, stages use:
- `runs/{run_id}/{stage}/outputs/`
- `runs/{run_id}/{stage}/logs/`
- `runs/{run_id}/{stage}/reports/`
- `runs/{run_id}/{stage}/config/`
- `runs/{run_id}/{stage}/scratch/`
Run-local outputs are materialized back into canonical session paths before stage success. `internal/stage/run_local.go` maps stage outputs and diagnostics into an
`previous/**` writes are never redirected to run-local output paths. invocation-scoped layout. Successful outputs are validated and atomically
materialized into canonical session paths before stage success. Managed
previous-session cache paths remain session-durable and are never redirected
into run-local output space.
## Locking ## Locking
`artifacts.LocalStore` enforces single-writer session lock via `.lock` file (`ErrLockConflict` on contention).
`artifacts.LocalStore` enforces the single-writer session lock via `.lock`
(`ErrLockConflict` on contention).
## Cleanup Semantics ## Cleanup Semantics
Automatic post-publish cleanup: Automatic post-publish cleanup:
- only runs when publish actually executed and succeeded; - only runs when publish actually executed and succeeded;
- requires `uploaded=true` and `current_pointer_written=true` metadata; - requires `uploaded=true` and `current_pointer_written=true` metadata;
- respects `pipeline.spool.delete_audio_after_publish` and `pipeline.workspace.cleanup_after_publish`; - consumes the resolved cleanup policy described in
[Configuration](../config.md);
- refuses unsafe deletes (root delete, out-of-root delete, symlink paths). - refuses unsafe deletes (root delete, out-of-root delete, symlink paths).
Manual clean command: Manual cleanup uses the same scoped-target checks. Invocation syntax and exact
- `clean <session_id>` removes session work and spool subtree. deletion scope belong in [CLI](../cli.md#clean) and
- `clean --all` removes all workspace work and spool children. [Operations](../operations.md#cleanup).
- durable cache is preserved unless `--clear-cache` is requested.
## Invariants ## Invariants
- campaign-aware session root is mandatory. - campaign-aware session root is mandatory.
- manifest-driven stage state is durable across runs. - manifest-driven stage state is durable across runs.
- cleanup guardrails prevent destructive root/out-of-scope deletion. - cleanup guardrails prevent destructive root/out-of-scope deletion.
## Implementation And Tests
- Path model and local store: `internal/artifacts/paths.go`,
`internal/artifacts/local.go`
- Run-local materialization: `internal/stage/run_local.go`
- Cleanup confinement: `internal/app/cleanup_targets.go`,
`internal/app/post_publish_cleanup.go`
- Tests: `internal/artifacts/paths_model_test.go`,
`internal/artifacts/local_test.go`, `internal/stage/run_local_test.go`,
`internal/app/cleanup_targets_test.go`,
`internal/app/post_publish_cleanup_test.go`

View File

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

View File

@@ -1,202 +1,230 @@
# Narratio Architecture # Architecture
## Purpose This document defines Narratio's intended high-level architecture and the
invariants that changes must preserve. Implemented component details belong in
the [Internal Overview](../internal/overview.md) and its linked documents.
Significant architectural decision history belongs under `docs/adr/` when such
records exist.
`narratio` is a Go orchestration application for processing D&D session audio into polished transcripts and generated session artifacts. ## System Shape
This document defines the development principles for the project. It is inward-facing: its audience is developers and LLM coding agents. It should guide future changes, not serve as a complete implementation reference. Narratio is a small Go application that turns D&D session audio into polished
transcripts and generated session artifacts. It is an explicit, stage-driven
orchestrator, not a general workflow engine.
Implemented component details belong under `docs/internal/`. Narratio coordinates specialized external systems rather than reimplementing
their domains:
## Project Shape - WhisperX performs transcription;
- Seriatim performs deterministic transcript processing and rendering;
- Audita performs transcript correction and polishing; and
- Scriptorium executes prompts and produces configured artifacts.
Narratio is a modular, stage-driven orchestrator. Narratio owns orchestration, configuration resolution, session and run state,
artifact and path modeling, manifest persistence, stage sequencing, resume,
restore, cleanup gates, and publish semantics. External contracts are defined
in the [integration documentation](../integrations/).
It coordinates specialized downstream systems rather than reimplementing their domains: The pipeline has one canonical ordered stage set. Configuration may enable,
disable, or parameterize supported behavior, but it must not turn that sequence
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
The implemented stage inventory belongs in the
[Internal Overview](../internal/overview.md).
- WhisperX handles transcription. Narratio is contract-first without being abstraction-heavy. Interfaces and
- Seriatim handles deterministic transcript merge/normalization/trim behavior. extension points should protect demonstrated boundaries. New abstraction is not
- Audita handles transcript correction and polishing. itself an architectural goal.
- Scriptorium handles prompt execution and generated artifacts.
Narratio owns orchestration, configuration loading, session/run state, local and remote path modeling, manifest persistence, stage sequencing, resume behavior, and publish semantics. ## Ownership And Dependency Direction
Narratio should remain explicit and comprehensible. It is not intended to become a generic workflow engine. The application boundary owns command dispatch, configuration selection,
production composition, session locking, and top-level lifecycle. It may depend
on concrete implementations to assemble a run.
## Core Principles Stage orchestration expresses intent in Narratio-level data and interfaces.
Stages may depend on configuration, manifest, artifact, path, and adapter
contracts, but they must not depend on transport-specific request types,
subprocess argument construction, cloud SDK types, or downstream tool internals.
### Modular and composable Adapters translate between Narratio contracts and external systems. They own
HTTP, subprocess, notification, and object-storage mechanics, including command
construction, transport behavior, provider response handling, and external
error adaptation. External dependency types must remain inside the adapter that
owns them unless that dependency is the adapter's explicit public contract.
WhisperX HTTP behavior, Seriatim, Audita, and Scriptorium command construction,
notification transport, and object-storage SDK details remain behind these
boundaries.
Code should be organized around clear responsibilities. Stages, adapters, config loading, manifest persistence, path construction, and storage behavior should remain separable and independently testable. State and path services must not infer stage policy. Storage implementations
receive explicit bucket-relative keys and do not infer campaign, session, run,
or root-prefix semantics. Manifest persistence records transitions but does not
choose orchestration policy. Artifact resolution identifies and validates
artifacts but does not execute producers.
### Hexagonal boundaries Dependencies should remain narrow and point toward Narratio-owned contracts.
Prefer the Go standard library. Add an external dependency only when it provides
a clear correctness, security, interoperability, or complexity benefit, and
confine it to the boundary that needs it.
External systems should be isolated behind narrow adapters. Stage logic should depend on Narratio-level interfaces and data structures, not on external SDK types, subprocess argument construction, or transport-specific details. ## Stage Boundaries
### Standard library preference Each stage has one explicit responsibility and declares:
Prefer the Go standard library. Add dependencies only when they provide substantial value, are necessary for an external integration, or are a widely used de facto standard.
Accepted examples include a YAML library for configuration and the AWS SDK for S3-compatible storage.
### Explicit orchestration
The pipeline should remain stage-driven and explicit. New behavior should be added through clear stage, adapter, config, or manifest contracts rather than implicit side effects or generic workflow abstraction.
## Stage Design
Each stage should have a clear scope of responsibility.
A stage should define:
- its purpose;
- required input state; - required input state;
- produced output state; - produced output state;
- config fields it consumes; - configuration it consumes;
- external adapters it uses; - external adapters it uses;
- manifest refs it reads or writes; - manifest references and metadata it reads or writes;
- skip, force, and resume behavior; - skip, force, invalidation, and resume behavior; and
- failure behavior; - failure behavior.
- tests that protect its contract.
Stages should avoid reaching across boundaries. If shared behavior is needed, prefer a helper or service with a narrow interface over duplicating ad hoc logic between stages. Stages write and validate run-local results before materializing canonical
outputs where that distinction applies. A stage is complete only after its
required outputs have been written, validated, and recorded in durable manifest
state. Later stages depend on recorded success and artifact resolution, not
merely on incidental files existing on disk.
## Transactionality and Resume A failed or interrupted stage must not be presented as successful. Failure
should preserve enough local state and diagnostics for inspection, recovery,
and resume. Forcing an upstream stage invalidates succeeded downstream work
according to the canonical stage order.
A stage should behave transactionally. Shared behavior should live behind a narrow service or helper with one clear
owner. Stages must not reach across boundaries or reproduce adapter, manifest,
artifact, or path policy ad hoc.
A stage is complete only when its outputs have been written, validated, and recorded in the manifest. If a stage fails, Narratio should preserve enough local state for inspection, recovery, and resume. ## Manifest, Resume, And Restore
A failed or incomplete run must not be treated as successful. Later stages should depend on manifest-recorded success, not merely on incidental files existing on disk. The session manifest is the durable ledger for progress across invocations. It
records session and run identity, stage state, input and output references,
diagnostic references, checksums or provenance where useful, and non-secret
adapter and publish metadata.
## Manifest Model Resume and skip decisions are manifest-driven. Filesystem state may be
inspected and validated, but file presence alone does not replace recorded
stage state. Invocation-scoped run records provide an audit of one execution;
they do not replace the session manifest as progress authority.
The manifest is the durable local ledger for a run. Restore treats committed remote current state as its authority. It must plan
deterministically, confine remote-to-local paths, protect local conflicts, and
install the validated session manifest after other restored durable files. The
physical workflow and recovery procedures belong in
[Operations](../operations.md).
It should record: ## Configuration
- run identity; Configuration is strict, explicit, centralized, and operator-oriented.
- stage status;
- input and output refs;
- logs and generated config refs;
- checksums or provenance where useful;
- non-secret adapter and publish metadata.
Resume behavior should be manifest-driven. Filesystem state may be inspected and validated, but it should not replace manifest stage state as the source of run progress. - YAML decoding rejects unknown fields.
- Defaults are centralized and testable.
- Empty configured values do not silently replace meaningful defaults.
- Validation rejects invalid composition before stage execution where
practical.
- Session templating remains narrow and deterministic rather than becoming a
general configuration language.
- Secret values are supplied indirectly and are not persisted in ordinary
configuration.
## Adapter Boundaries Narratio must not become a second configuration system for downstream tools.
External systems own their runtime defaults wherever practical; Narratio passes
the paths required by its stage contracts and explicit operator overrides. The
field-level contract and credential-supply mechanisms belong in
[Configuration](../config.md).
Adapters own external integration details. ## Artifacts, Paths, And Storage
Expected boundaries: Artifact identities and local and remote paths are application contracts.
Canonical helpers own workspace, spool, cache, session, run, input, transcript,
artifact, log, report, configuration, and publish-current paths. Callers must
not reconstruct canonical paths through scattered string concatenation.
- WhisperX HTTP details stay in the WhisperX adapter. Artifact resolution is deterministic and manifest-aware. Producers materialize
- Seriatim CLI construction stays in the Seriatim adapter. canonical outputs before reporting success, and consumers resolve declared
- Audita CLI construction stays in the Audita adapter. artifact identities rather than infer files from unrelated directory contents.
- Scriptorium CLI construction stays in the Scriptorium adapter.
- Object-storage details stay behind the storage adapter interface.
- AWS SDK types stay inside the S3 storage implementation.
Stage code should express intent in Narratio terms and call adapters through narrow contracts. Writes, moves, replacements, and deletions must use narrow, explicit,
root-confined destinations. Symlinks, traversal, broad roots, and ambiguous
relative destinations must not expand the scope of an operation. Cleanup is
permitted only through explicit operator action or configured post-publish
gates, and it must preserve durable cache unless cache removal is explicitly
requested.
## Configuration Philosophy Physical layout, retention, and operational lifecycle belong in
[Operations](../operations.md). Logical external formats and durable integration
contracts belong under [Integrations](../integrations/).
Configuration should be strict, explicit, and operator-friendly. ## Publish Commit Boundary
Principles: Publish has one explicit remote commit boundary. A remote run becomes current
only after Narratio has successfully uploaded the run record, required published
outputs, `current/manifest.json`, and finally `current/run_id.txt`.
- YAML decoding should reject unknown fields. `current/run_id.txt` is the commit marker and must be written last. Failed,
- Defaults should be centralized and testable. incomplete, skipped, or uncommitted publish attempts must not be presented as
- Empty configured values should not silently override meaningful defaults. current remote state. Publish locks remain authoritative and are not bypassed by
- Session templating should remain narrow and deterministic. a forced run.
- Template support should serve operator convenience, not become a general configuration language.
Narratio should not become a secondary configuration system for downstream tools. Seriatim, Audita, and Scriptorium should own their runtime defaults wherever practical. Narratio should pass required stage-contract paths and explicit operator overrides. Automatic local cleanup is permitted only after a successful publish commit,
only when explicitly configured, and only through the path-safety guardrails.
## Path and Storage Discipline ## Security, Privacy, And Diagnostics
Local and remote paths are part of Narratios application contract. Narratio handles private campaign material. Transcripts, prompts, generated
artifacts, reports, logs, manifests, and diagnostic files are potentially
sensitive.
Code should use centralized path helpers for workspace, spool, session, run, artifact, log, config, and publish/current paths. Stages should avoid reconstructing canonical paths through scattered string concatenation. Raw secrets must not be stored in pipeline, campaign, or session YAML or written
to manifests, logs, generated configuration, reports, publish metadata,
documentation, or examples. Secrets enter through configured environment
variable names or secret-file references. Diagnostics should avoid transcript
and prompt content unless a deliberate, bounded inspection mechanism requires
it.
Storage backends should receive explicit bucket-relative keys. Storage implementations should not infer campaign, session, run, or root-prefix semantics. Logs, reports, generated invocation files, generated configuration, and render
debug files are diagnostics, not canonical pipeline products. They should be
durable and discoverable where configured, and manifest references must preserve
the distinction between diagnostics and artifacts.
## Publish Invariants Documentation security rules belong in the
[Documentation Policy](documentation.md). Credential supply belongs in
[Configuration](../config.md), while permissions, sensitive runtime-artifact
handling, and recovery belong in [Operations](../operations.md).
Publish behavior must preserve a clear commit boundary. ## Determinism And Testability
A remote run is current only after the publish stage has successfully uploaded the run record, required published outputs, `current/manifest.json`, and finally `current/run_id.txt`. Narratio prefers deterministic behavior where practical, including stable local
and remote layouts, sorted operation order, predictable generated
configuration, repeatable command construction, deterministic artifact
resolution, and reproducible planning.
`current/run_id.txt` is the final remote commit marker and must be written last. Run IDs and timestamps may be intentionally variable, but surrounding behavior
must remain controllable in tests. Core behavior should be testable without live
external services; expensive, nondeterministic, destructive, or external
boundaries should be replaceable with focused test doubles. General testing
philosophy and sufficiency rules belong in the [Testing Policy](testing.md).
Failed, incomplete, skipped, or uncommitted publish attempts must not be presented as current remote state. Local cleanup is permitted only after successful publish commit and only when explicitly configured. ## Documentation And Decision Records
## Security and Privacy Documentation follows the [Documentation Policy](documentation.md). Current
behavior belongs in its canonical user, operator, integration, architecture, or
internal owner. Proposed behavior and implementation status belong under
`docs/roadmap/`.
Narratio handles private campaign material. Significant architectural decisions may be recorded under `docs/adr/` using the
format and lifecycle defined by the documentation policy. ADR acceptance does
not establish that a decision has been implemented.
Rules: ## Architectural Non-Goals
- Do not store raw secrets in pipeline or session YAML. Narratio does not aim to provide:
- Use environment variable names or secret-file references for secret handling.
- Do not write raw secret values to manifests, logs, generated configs, or publish metadata.
- Treat transcripts, generated artifacts, prompts, reports, and logs as potentially sensitive.
- Avoid logging transcript or prompt content unless there is a deliberate diagnostic reason.
## Diagnostics
Diagnostics should be durable and discoverable, but distinct from canonical outputs.
Logs, reports, generated invocation/config files, and render-debug files support debugging. Transcript tiers and configured artifacts are pipeline products.
Manifest refs should preserve that distinction.
## Determinism
Where practical, Narratio should prefer deterministic behavior:
- stable local path layout;
- stable remote key layout;
- sorted upload order;
- predictable generated config files;
- repeatable command construction;
- tests that do not depend on live external services.
Run IDs and timestamps may be intentionally variable, but surrounding behavior should remain testable.
## Testing Expectations
Core behavior should be testable without live external services.
Tests should cover:
- config loading, defaults, and validation;
- CLI parsing and command construction;
- path helpers;
- manifest transitions;
- stage success, failure, skip, and resume behavior;
- adapter command construction;
- fake storage behavior;
- publish commit ordering;
- example config validity where practical.
Live S3, WhisperX, LLM, or subprocess integration tests should be explicit integration tests, not required for ordinary unit test runs.
## Documentation Expectations
Documentation must follow `docs/documentation/policy.md`.
Current behavior belongs in user-facing docs and `docs/internal/`. Future, planned, aspirational, experimental, or unimplemented work belongs only under `docs/roadmap/`.
`docs/architecture.md` should remain concise and principle-focused. It should not duplicate the full config reference, CLI reference, operations guide, or internal stage documentation.
## Non-Goals
Narratio is not:
- a generic DAG or workflow engine; - a generic DAG or workflow engine;
- a replacement configuration layer for Seriatim, Audita, or Scriptorium; - a replacement configuration layer for WhisperX, Seriatim, Audita,
- a storage backend abstraction beyond the needs of this pipeline; Scriptorium, or other downstream tools;
- a place to embed raw secrets; - a storage abstraction broader than the needs of this pipeline;
- a place for stage logic to depend directly on AWS SDK types or downstream tool internals; - stage logic coupled directly to cloud SDKs, transports, subprocess details,
or downstream implementation internals;
- raw-secret persistence;
- implicit cross-stage behavior that bypasses manifest and artifact contracts;
or
- a prompt-authoring system. - a prompt-authoring system.

View File

@@ -1,94 +0,0 @@
# Development Guide
## Purpose
Canonical contributor workflow and engineering conventions for implemented Narratio behavior.
## Repository layout
- `cmd/narratio/`: CLI entrypoint.
- `internal/app/`: command handlers, run/stage orchestration, cleanup gates, secrets loading.
- `internal/config/`: strict YAML loading, defaults, and validation.
- `internal/stage/`: stage implementations and stage registry/order.
- `internal/adapters/`: external boundary adapters (WhisperX, Seriatim, Audita, Scriptorium, storage, notify).
- `internal/manifest/`: session/run manifest types and persistence.
- `internal/artifacts/`: canonical local/remote path helpers and local artifact store.
- `docs/`: canonical documentation set.
- `examples/`: maintained config examples used by tests.
## Build and test commands
- Run focused CLI behavior checks:
```bash
go test ./internal/app -run TestExecute -v
```
- Run config example load/validate checks:
```bash
go test ./internal/config -run TestExamplesLoadAndValidate -v
```
- Run full test suite:
```bash
go test ./...
```
## Coding conventions
- Keep orchestration explicit and stage-driven; do not introduce generic workflow/DAG abstractions.
- Keep external-system details inside adapter packages; stages should consume Narratio-level contracts only.
- Use centralized path helpers from `internal/artifacts` rather than ad hoc path concatenation.
- Preserve manifest-driven state transitions (`running`, `succeeded`, `failed`, `skipped`, `stale`) as the source of run progress.
- Keep user/operator docs implementation-accurate; planned work belongs only under `docs/roadmap/`.
For design principles and invariants, see [docs/architecture.md](./architecture.md). For stage/adapter contracts, see [docs/internal/README.md](./internal/README.md).
## Dependency policy
- Prefer Go standard library where practical.
- Add third-party dependencies only when they provide clear value for required behavior.
- Keep dependency additions narrow to the boundary package that needs them.
## Change playbooks
### Add config fields
1. Add fields to config structs in `internal/config`.
2. Set defaults in `internal/config/defaults.go` when appropriate.
3. Add validation rules in `internal/config/validate.go`.
4. Add or update load/validate tests in `internal/config/*_test.go`.
5. Update canonical config docs and examples:
- [docs/config.md](./config.md)
- relevant files under `examples/`
### Add CLI flags or commands
1. Update command parsing and behavior in `internal/app`.
2. Add or update command tests (`TestExecute` and command-specific tests).
3. Update [docs/cli.md](./cli.md) and, if operator workflow changes, [docs/operations.md](./operations.md).
Remote-storage commands must obtain object storage through the app-level command object-store helper. Do not call `storage.NewObjectStoreFromConfig` directly from command handlers; the helper loads configured filesystem secrets before constructing the storage adapter.
### Add or modify stages/adapters
1. Implement stage behavior in `internal/stage` with clear input/output boundaries.
2. Keep external transport/subprocess details in `internal/adapters`.
3. Preserve manifest and publish-output semantics expected by runner and publish logic.
4. Add/update stage and adapter tests.
5. Update internal component contracts in `docs/internal/`.
### Update examples
1. Keep canonical examples only in `examples/`.
2. Ensure examples load and validate through runtime config paths.
3. Update `internal/config/load_validate_test.go` as needed.
4. Update links in `docs/config.md` if example filenames change.
### Update docs and roadmap
1. Keep implemented behavior in canonical docs (`README`, `docs/*.md`, `docs/internal/`).
2. Keep planned/unimplemented behavior only in `docs/roadmap/`.
3. After completing roadmap items, remove or mark them complete in `docs/roadmap/documentation.md`.
4. Run a link/path sweep before finalizing changes.

View File

@@ -1,356 +1,148 @@
# Go Project Documentation Policy # Documentation Policy
## Purpose ## Purpose
Project documentation must help four audiences: This policy assigns each documentation topic to one canonical owner. Its goal is
to keep Narratio documentation accurate, concise, discoverable, and resistant
1. users who need to run the application; to drift for users, operators, developers, integrators, and LLM coding agents.
2. administrators/operators who need to configure and operate it;
3. developers who need to understand and change it safely;
4. LLM coding agents that need clear scope, boundaries, and invariants.
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
## Core Rules ## Core Rules
### 1. Keep docs concise ### One Canonical Owner
Each authoritative fact belongs in one document. A non-owning document may give
a short, stable summary for orientation, but it must link to the canonical owner
instead of repeating volatile details.
Volatile details include commands, flags, configuration fields and defaults,
stage or integration keys, schemas, file names, paths, status codes, retry
behavior, and runtime guarantees. If readers could reasonably treat a statement
as a contract, maintain it only in the owning document.
Each document should cover a defined scope and only the essentials for that scope. ### Current And Future Behavior
Outside `docs/roadmap/`, documentation describes implemented behavior only.
Partial features may be described only to their implemented boundary.
ADRs are the narrow exception: an ADR may record an accepted architectural
decision before implementation, but acceptance must not be presented as proof
that the behavior exists. The roadmap owns implementation status and sequencing
until the decision is implemented. Current architecture, user, operator,
integration, and internal documentation are updated when the behavior lands.
### Audience And Detail
Write for the document's stated audience and include only the detail needed for
its owned topic. User and operator docs should not expose implementation detail.
Developer docs should link to user-facing and external contracts rather than
restate them.
### Examples
Avoid: Complete copyable files belong in `examples/`. Documentation may use the
- long background explanations; smallest illustrative snippet needed to explain its owned topic, but should link
- repeated reference material; to maintained examples instead of embedding a second complete copy.
- implementation detail in user-facing docs;
- aspirational language outside roadmap docs; Examples must be valid, secret-free, and tested where practical. Commands and
- verbose examples where one minimal example is clearer. configuration used in documentation should match the application.
### 2. Document only implemented behavior outside roadmap files ### Security And Privacy
Unimplemented, planned, aspirational, experimental, or future work may be described only under: Documentation and examples must not contain real credentials, private keys,
private environment dumps, sensitive source material, or private infrastructure
- `docs/roadmap/` details unless intentionally public. Document secret-handling mechanisms, not
secret values.
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
## Canonical Ownership
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
| Topic | Canonical owner | Owned content | Content owned elsewhere |
### 3. Use canonical homes | --- | --- | --- | --- |
| Product orientation and minimal end-to-end quickstart | `README.md` | What Narratio is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
Each type of information should have one canonical location. | Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
Canonical homes: | Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
- project purpose and quickstart: `README.md` | CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, output conventions, and exit behavior. | End-to-end operating procedures, configuration field definitions, runtime filesystem layout, stage implementation details. |
- development principles: `docs/architecture.md` | Configuration contract | `docs/config.md` | Discovery and precedence, file schemas, fields, defaults, environment overrides, validation rules, and user-selectable stage or integration settings. | Complete example files, CLI syntax, runtime state lifecycle, implementation details. |
- configuration reference: `docs/config.md` | Operations | `docs/operations.md` | Runtime workflows, physical filesystem and remote-state layout, output and diagnostic handling, resume, cleanup, permissions, recovery, and operational limits. | CLI flag syntax, configuration field definitions, logical artifact schemas, implementation mechanics. |
- CLI reference: `docs/cli.md` | Troubleshooting | `docs/troubleshooting.md` | Symptom-driven diagnosis, likely causes, safe inspection steps and remedies, and links to relevant contracts. | CLI syntax, configuration definitions, operational procedures, integration contracts, implementation mechanics. |
- operations and recovery: `docs/operations.md` | Public HTTP contract, if introduced | `docs/api.md` | Routes, authentication, media types, request and response schemas, status codes, pagination, caching, idempotency, rate limits, and HTTP retry semantics. | Client walkthroughs, upstream or downstream integration internals, implementation detail. |
- troubleshooting: `docs/troubleshooting.md` | Consumer guidance, if a public package or API is introduced | `docs/consumers/` | Task-oriented use of the public interface, minimal client examples, and consumer responsibilities. | HTTP wire semantics, external protocol contracts, internal implementation detail. |
- implemented internals: `docs/internal/` | External and durable integration contracts | `docs/integrations/` | External file formats and protocols, upstream and downstream contracts, logical artifact paths and schemas, media types, and compatibility behavior. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, configuration defaults. |
- future work: `docs/roadmap/` | Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
- contributor workflow: `docs/development.md` | Internal component behavior | Other files under `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
- copyable examples: `examples/` | Architectural decision history | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
| Future work and implementation status | `docs/roadmap/` | Proposed, accepted, deferred, or rejected work; implementation status; sequencing; and task breakdowns. | Implemented behavior reference and architectural decision rationale. |
Other files should summarize briefly and link to the canonical source. | Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other files intended to be copied or run. | Field-by-field reference, command reference, prose explanation. |
### 4. Keep examples real Documents that do not exist are required only when the corresponding interface
or responsibility exists. Do not create placeholder API, consumer, integration,
Examples should be valid, maintained, and free of secrets. or operations documents for behavior the application does not have.
Where practical: ## Boundary Rules
- example configs should load successfully;
- example commands should match real CLI syntax; ### Orientation
- important examples should be covered by tests.
The README owns product orientation. The developer guide routes contributors.
## Documentation Profiles Architecture owns normative structure. Internal overview owns the current
concrete component map. These documents may link to one another but should not
All projects require: maintain parallel package or behavior descriptions.
- `README.md` ### Commands, Configuration, Operations, And Troubleshooting
- `docs/architecture.md`
CLI documentation answers how to invoke the application. Configuration
Additional docs depend on the project. documentation answers what settings mean. Operations answers what happens to
runtime state and how to operate or recover the application. Troubleshooting
### Small library starts from observable symptoms and links readers to the owning command,
configuration, operational, or integration contract. When a workflow crosses
Recommended: these topics, choose the document that owns the task and link to the other
- `docs/development.md`, if contributor conventions are non-obvious contracts.
### Simple CLI ### Contracts And Implementation
Required: Integration and API documents define externally observable shapes and
- `docs/cli.md` semantics. Internal documents explain how Narratio implements or consumes those
contracts. Internal docs may name a field, file, or protocol to identify a
Recommended: dependency, but must link to its canonical contract for the definition.
- `docs/development.md`
### Security Topics
### Config-driven CLI
This policy owns what documentation and examples may contain. Architecture owns
Required: application security invariants. Configuration owns credential-supply
- `docs/cli.md` mechanisms. Operations owns permissions and handling of sensitive runtime
- `docs/config.md` artifacts. Troubleshooting owns safe diagnostic and remediation guidance.
Internal docs own implementation mechanisms only.
Recommended:
- `examples/` ## Architecture Decision Records
- `docs/development.md`
Use sequentially numbered ADR filenames such as
### Stateful or operator-facing application `0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
Required: 1. title;
- `docs/cli.md`, if CLI-based 2. status;
- `docs/config.md`, if config-driven 3. date;
- `docs/operations.md` 4. context;
5. decision;
Recommended: 6. alternatives considered;
- `docs/troubleshooting.md` 7. consequences.
- `examples/`
- `docs/development.md` Treat the decision content of an accepted ADR as immutable. When a decision
changes, create a new ADR and update the earlier ADR's status to superseded.
### Modular, staged, service-oriented, or orchestration application Rejected architectural alternatives belong in the ADR; rejected product ideas
belong in the roadmap.
Required:
- `docs/cli.md`, if CLI-based ## Maintenance
- `docs/config.md`, if config-driven
- `docs/operations.md` When behavior changes, update its canonical owner in the same change. If
- `docs/internal/` ownership moves, remove the old definition and replace it with a link where
- `docs/development.md` navigation remains useful.
Recommended: Before completing documentation work:
- `docs/troubleshooting.md`
- validated examples under `examples/` - verify affected behavior and examples;
- check commands, flags, fields, defaults, schemas, and paths against their
## Required Documents implementation;
- keep unimplemented behavior in the roadmap, subject to the ADR exception;
### README.md - remove stale references and validate links;
- confirm that non-owning documents summarize and link rather than redefine;
**Audience:** users, administrators, operators - confirm that no secrets or sensitive private data were added.
The README is the outward-facing project orientation page.
It should include, in order:
1. concise description;
2. elevator pitch;
3. shortest useful command or usage example;
4. links to targeted docs.
The README should be short. It is not a manual.
The “shortest useful command” means the simplest command that performs the projects core use case. (It does not mean `app --help`.)
### docs/architecture.md
**Audience:** developers, LLM coding agents
`docs/architecture.md` is required for every project.
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
It should include:
- project shape;
- core design principles;
- package and boundary philosophy;
- state/persistence philosophy, if applicable;
- external integration philosophy, if applicable;
- error-handling and logging principles;
- testing expectations;
- documentation expectations;
- architectural invariants;
- explicit non-goals, if useful.
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
### docs/config.md
**Audience:** administrators, operators, advanced users
Required for applications with configuration files.
It should include, in order:
1. config file locations and discovery precedence;
2. minimal working config;
3. production-oriented config;
4. full configuration reference;
5. secrets handling, if applicable;
6. links to maintained examples.
The full configuration reference should be canonical.
### docs/cli.md
**Audience:** users, administrators, operators
Required for CLI applications.
It should include, in order:
1. shortest useful command;
2. command overview;
3. complete flag reference;
4. common workflows;
5. diagnostic or recovery commands, if applicable.
Explain when commands are useful, not just their syntax.
### docs/operations.md
**Audience:** administrators, operators
Required for applications that maintain state, support resume behavior, run multiple stages, write durable artifacts, use remote storage, or require recovery procedures.
It should cover:
- normal workflow;
- filesystem layout;
- remote storage layout, if applicable;
- logs and manifests;
- resume/retry behavior;
- cleanup behavior;
- archive/backup behavior;
- safe recovery procedures;
- operational caveats.
### docs/troubleshooting.md
**Audience:** administrators, operators
Recommended once recurring failure modes exist.
Each entry should include:
- symptom;
- likely cause;
- diagnostic command or inspection step;
- safe fix;
- relevant links.
### docs/development.md
**Audience:** developers, LLM coding agents
Required for projects maintained by humans and LLM coding agents.
It should include:
- repository layout;
- build/test commands;
- coding conventions;
- dependency policy;
- how to add config fields;
- how to add CLI flags;
- how to add stages/modules/adapters, if applicable;
- how to update examples;
- documentation update expectations.
### docs/internal/
**Audience:** developers, LLM coding agents
Required for modular, staged, service-oriented, or orchestration projects.
This directory describes implemented internal components. It is not the roadmap.
Use one file per major component where useful.
Each component doc should include:
1. purpose;
2. inputs and outputs;
3. boundaries;
4. config fields used;
5. external adapters used;
6. state or manifest behavior, if applicable;
7. skip/resume behavior, if applicable;
8. failure behavior;
9. tests to inspect before changing;
10. architectural invariants.
### docs/roadmap/
**Audience:** maintainers, developers, LLM coding agents
This is the only place for planned, future, aspirational, experimental, or unimplemented work.
Roadmap docs should clearly distinguish:
- proposed work;
- accepted plans;
- deferred ideas;
- rejected ideas;
- implementation prompts or task breakdowns, if useful.
Roadmap docs should not be confused with current behavior.
### docs/integrations/
**Audience:** developers, LLM coding agents
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses.
Use one file per integration where useful.
## Examples Directory
Projects with non-trivial configuration or workflows should include `examples/`.
Useful examples include:
- minimal working config;
- production-oriented config;
- full annotated config;
- local development config;
- remote/object-storage config;
- minimal session/input file.
Examples should be valid, maintained, tested when practical, and linked from relevant docs.
## Security and Privacy
Docs and examples must not include:
- real API keys;
- tokens;
- passwords;
- private keys;
- private environment dumps;
- sensitive user data;
- raw private transcripts;
- private infrastructure details unless intentionally public.
Document secret-handling mechanisms, not actual secret values.
## Maintenance Rules
When docs change, verify the affected behavior.
Where practical:
- load example config files in tests;
- test CLI examples or command parser behavior;
- validate documented flags against real flags;
- remove stale references;
- update links after renames;
- keep roadmap content out of non-roadmap docs.
If documentation and code disagree, fix the documentation and/or open a roadmap item; do not leave aspirational behavior in current-behavior docs.
Documentation is complete only when it matches the current code.
## Documentation Change Checklist
Before merging documentation changes, verify:
- README is concise and orientation-focused.
- `docs/architecture.md` describes development principles.
- Future work appears only under `docs/roadmap/`.
- User-facing docs avoid unnecessary internals.
- Developer-facing docs preserve boundaries and invariants.
- Config examples match the schema.
- CLI examples match real commands and flags.
- Defaults appear in the canonical config reference.
- No secrets or private data are included.
- Links are accurate.

296
docs/policy/testing.md Normal file
View File

@@ -0,0 +1,296 @@
# Testing Policy
## Purpose
Our tests exist to make **incorrect changes expensive and correct changes cheap**.
We do not optimize for test count, line coverage, exhaustive isolation, or the fewest possible tests. We optimize for sufficient confidence in important behavior while imposing as little unnecessary friction as possible on future development.
## Every test has a cost
Testing is not an unqualified good. Every test imposes both an immediate cost and a continuing lifetime cost.
A test must be:
- written and reviewed;
- understood by future maintainers and coding agents;
- executed in local and CI workflows;
- diagnosed when it fails;
- updated when legitimate behavior changes;
- maintained as fixtures, APIs, and dependencies evolve; and
- removed or rewritten when it becomes redundant, brittle, misleading, or obsolete.
Tests also create cognitive and architectural friction. They can constrain refactoring, duplicate policy, slow feedback loops, add noise to failures, and cause harmless implementation changes to require unrelated edits across the suite.
A test is warranted only when the confidence it provides justifies these costs.
Apply this cost-benefit analysis at two levels:
1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost?
2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would?
The preferred test suite is a **lean suite that provides sufficient confidence in the risks that matter, without redundant or low-value tests**. We seek sufficient confidence with the least unnecessary testing friction, not the fewest possible tests.
Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. They should not make ordinary internal changes needlessly expensive.
The cost of a test is not a reason to omit testing by default. Do not cite maintenance cost abstractly. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
## Default testing style
Use a **classical/Detroit-style** approach:
- Test observable behavior, resulting state, contracts, and invariants.
- Use real internal collaborators when they are fast and deterministic.
- Use fakes, stubs, or mocks primarily at expensive, nondeterministic, destructive, or external boundaries.
- Prefer package-level behavioral tests over tests coupled to private helpers or internal call sequences.
- Treat exact collaborator interactions as testable behavior only when the interaction itself is a requirement.
Examples of appropriate seams include clocks, randomness, subprocesses, remote APIs, object storage, email, and paid LLM calls.
## Test execution requirements
Tests in the default suite must be deterministic, offline, and independent of real credentials. They must not invoke paid APIs or depend on mutable external services. Tests that require live infrastructure must be explicitly opt-in and clearly separated from the default suite.
Control clocks, randomness, environment variables, and other process-global or machine-specific state when they affect behavior. Tests should be safe to run repeatedly and alongside other tests without depending on execution order or state left by an earlier test.
## What deserves tests
Prioritize tests for:
1. Public and package-level contracts.
2. Domain rules and important invariants.
3. Boundary conditions and malformed input.
4. Failure handling, cancellation, retries, recovery, and partial success.
5. Serialization, schemas, compatibility, and round trips.
6. Previously observed or plausible regressions.
7. Representative integration and end-to-end workflows.
A package-level contract is behavior relied upon by another package or major collaborator, not every observable detail of a package implementation.
For behavior involving **data integrity, destructive operations, compatibility, security, concurrency, idempotency, or recovery**, presume that durable tests are required unless the behavior is already credibly protected at another layer.
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
## Choose the right test boundary
Test through the narrowest stable boundary that expresses the behavior clearly.
This is often the package API, but it may instead be:
- a smaller pure function when dense domain logic is most clearly isolated there;
- a package-level operation when several internal collaborators jointly produce the behavior; or
- a larger integration boundary when correctness emerges from interaction with a real dependency.
Do not force all behavior through oversized end-to-end tests. Do not test every private helper merely because it exists. Choose the boundary that gives durable confidence with the least incidental coupling.
## Test behavior, not implementation
A test should protect a decision, contract, or invariant—not memorialize the current implementation.
Before adding or retaining a test, ask:
> What realistic defect would this test catch?
A test is suspect when its main purpose is to detect that someone:
- changed an internal constant;
- renamed or split a private helper;
- reordered equivalent internal operations;
- changed incidental formatting;
- replaced one correct algorithm with another; or
- refactored internal object structure without changing behavior.
Refactoring should normally require no test edits unless the refactored structure is itself part of the contract.
A test can be factually correct and still have negative value. Accurately describing current behavior is not enough; the protected behavior must be important enough to justify the future friction.
## Expected effects of different changes
Use the following expectations when evaluating test failures and test maintenance:
| Change | Expected effect on tests |
|---|---|
| Internal refactor that preserves behavior | Existing tests should normally remain unchanged and continue to pass. |
| Change to an internal default with no contractual significance | Behavioral tests should normally remain unchanged; tests should derive expectations from configuration or relationships rather than duplicate the old value. |
| Intentional change to public behavior, policy, schema, or compatibility guarantees | The relevant tests should be reviewed and changed deliberately. |
| Accidental violation of a contract or invariant | Tests should fail; fix the production code rather than rewriting the tests to accept the defect. |
A test failing is not the same as a test needing to be edited. Many tests may correctly fail because of one production defect. The maintenance smell is a correct internal change that requires unrelated expectation updates throughout the suite.
## Separate mechanism from policy
Configurable thresholds and defaults must not be duplicated throughout the test suite.
For example, do not encode an internal concurrency limit indirectly:
```go
// Production policy:
const maxConcurrency = 4
// Brittle test:
err := startProcesses(5)
require.Error(t, err)
```
Instead, test the mechanism relationally:
```go
const limit = 2
runner := NewRunner(limit)
require.NoError(t, runner.Start(limit))
require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency)
```
The test should prove:
- the configured limit is accepted; and
- one beyond the configured limit is rejected.
The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement.
Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior, not duplicated literals.
For concurrency limits, test both kinds of behavior when relevant:
1. **Configuration enforcement:** invalid or excessive requested values are handled correctly.
2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit.
Use a test-controlled limit and measure the behavior relative to that limit. Do not merely assert today's default value.
## Avoid semantic duplication across layers
Each behavior should have a clear test owner.
- Parser tests own parsing cases.
- Validator tests own validation rules.
- Domain tests own transformations and invariants.
- Adapter tests own external integration behavior.
- Orchestrator tests own coordination and failure propagation.
- CLI tests own argument and configuration mapping.
- End-to-end tests prove that representative assembled workflows work.
Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files.
Tests that are individually reasonable may still be collectively redundant. Evaluate the marginal value of each additional test in light of the protection already provided by the rest of the suite.
## Use test doubles deliberately
Choose the least elaborate test double that provides the required control or observation.
As a default:
1. Prefer real collaborators when they are fast and deterministic.
2. Use small in-memory fakes when realistic stateful behavior is helpful.
3. Use stubs when a dependency only needs to provide controlled responses.
4. Use mocks when the interaction itself is contractual.
Mocks are appropriate when the contract includes facts such as:
- a notification is sent exactly once;
- a transaction is committed only after successful writes;
- cancellation reaches a subprocess;
- an expensive API is called no more than once; or
- a security audit event is emitted.
Do not use mocks merely to isolate every object or reproduce the implementation's call graph.
## Go-specific guidance
Use:
- table-driven tests for meaningful behavioral categories and boundaries;
- `t.TempDir()` for real filesystem behavior;
- `httptest.Server` for realistic HTTP interactions;
- fuzz tests for parsers, normalization, path handling, and broad input spaces;
- golden files only when the complete output is intentionally stable;
- integration tests where correctness depends on component interaction; and
- a small number of representative end-to-end tests.
Avoid exact error-string assertions unless the wording is itself contractual. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields.
At CLI boundaries, prefer exit classifications, structured output, and the smallest stable semantic fragment needed to identify the error. Do not snapshot complete diagnostic wording unless it is contractual.
Golden-file updates must require an explicit local flag. CI must not update golden files automatically, and reviewers must inspect the semantic diff before accepting an update.
Keep tests readable and direct. Test helpers and fixture frameworks must earn their own maintenance cost; do not build elaborate test infrastructure for small or isolated needs.
## Coverage
Coverage is a diagnostic, not a target.
Use it to find untested critical branches and unexpectedly weak packages. Do not write low-value tests solely to increase a percentage, and do not infer test quality from coverage alone.
Pure domain logic will often warrant higher coverage than CLI wiring or external adapters. Uneven coverage is acceptable when it reflects risk.
Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost.
## Regression tests
A bug fix should normally include a regression test that fails before the fix and passes afterward.
Retain the test when the defect could realistically recur and its consequences justify the ongoing cost. Prefer the narrowest durable test of the violated contract or invariant; do not preserve accidental implementation details from the original bug.
Not every historical bug requires a permanent test. If the underlying design has made recurrence impossible, the test has become redundant, or a stronger invariant test now subsumes it, remove or consolidate it.
## Deleting or rewriting tests
Tests are maintained code, not permanent historical artifacts.
Delete or rewrite a test when its maintenance cost exceeds the confidence it provides.
Strong candidates include tests that:
- require updates after harmless internal changes;
- directly assert private constants without protecting a real contract;
- duplicate the same policy across several layers;
- verify mock choreography rather than outcomes;
- snapshot large amounts of incidental output;
- test trivial private helpers already exercised through stable package behavior;
- protect risks already covered more effectively elsewhere;
- are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or
- no longer correspond to a plausible failure mode.
Several brittle tests may encode one genuine requirement. Replace them with one durable behavior-level or invariant test rather than preserving all of them.
Deleting a low-value test can improve the quality of the suite by reducing noise, maintenance burden, and friction around legitimate change.
## Reviewing a proposed test
Use the following questions when the value, boundary, or durability of a proposed test is not self-evident. Significant test additions should be reviewable against them, but written answers are not required for every routine test.
1. What realistic defect would it catch?
2. How likely is that defect?
3. How consequential would it be?
4. Is the behavior already protected elsewhere?
5. At which layer should this behavior be owned?
6. Does the test assert a durable contract or an incidental implementation detail?
7. Could the implementation be refactored without changing the behavior and without editing this test?
8. What should cause this test to fail?
9. What legitimate changes should not cause this test to fail?
10. What ongoing maintenance, execution, and diagnostic cost will the test impose?
11. Is there a smaller or more direct test that protects the same risk?
Do not add the test when its expected lifetime cost exceeds its expected protective value.
When deciding not to test plausible behavior, record or be able to explain why the risk is low, already protected, obvious, reversible, or cheaper to detect elsewhere.
## Definition of sufficient
A test suite is sufficient when:
- important contracts and invariants are protected;
- meaningful boundaries and failure modes are exercised;
- realistic and consequential regressions are credibly protected against silent recurrence;
- behavior involving data integrity, destructive operations, compatibility, security, concurrency, idempotency, and recovery is credibly protected;
- important external boundaries have realistic integration coverage;
- representative complete workflows are tested;
- failures provide useful signal rather than redundant noise;
- legitimate internal changes usually do not require test edits; and
- additional tests would mostly repeat existing protection or preserve inconsequential implementation details.
Sufficiency is a risk judgment, not a coverage percentage or test count. Reassess it as the application, its users, and the consequences of failure evolve.
The governing rule is:
> Test heavily where failure is consequential, subtle, or difficult to detect after the fact. Test lightly where failure is obvious, reversible, and inexpensive—and retain no test whose lifetime cost exceeds the confidence it provides.

View File

@@ -0,0 +1,379 @@
# Notarius Extraction Stage
## Status
Proposed.
## Purpose
Add a first-class Narratio `extract` stage that runs Notarius against the
session's final trimmed transcript, validates and collects the resulting
structured D&D artifacts, and registers those artifacts for later use by the
`analyze` and `publish` stages.
This feature should integrate Notarius through Narratio's existing stage,
adapter, manifest, workspace, and artifact-catalog boundaries. It must not turn
Narratio into a generic workflow engine or a second configuration language for
Notarius pipelines.
## User Outcome
An operator can enable one configured Notarius pipeline for a Narratio
campaign. During a normal run, Narratio will:
1. finish producing the session transcript tiers;
2. invoke Notarius once with the final trimmed Seriatim JSON transcript;
3. collect and validate the configured structured artifact lanes;
4. record their exact files and provenance in the Narratio manifest; and
5. make those artifacts selectable as inputs to Scriptorium artifacts in the
later `analyze` stage.
The maintained D&D example should demonstrate all ten lanes emitted by
Notarius's complete `dnd-session` pipeline.
## Target Stage Architecture
### Canonical Order
The canonical stage order becomes:
```text
prepare -> transcribe -> merge -> polish -> normalize -> trim -> render
-> extract -> analyze -> publish -> notify
```
`extract` is deliberately after all transcript-producing stages and before
analysis. Its source document is the manifest-resolved
`narratio.transcript.final_trimmed` artifact, normally
`transcripts/final.trimmed.json`. It does not consume rendered Markdown.
Adding the stage must update full-plan construction, explicit stage selection,
downstream invalidation, prerequisite checks, resume behavior, run manifests,
CLI stage validation and help, and every canonical-stage inventory. Forcing an
upstream transcript stage must stale a previously successful `extract` stage
and its downstream stages. Forcing `extract` must stale `analyze`, `publish`,
and `notify` according to existing rules.
### Stage Boundary
The stage owns Narratio policy and state transitions:
- resolve the final trimmed transcript through the runtime artifact catalog;
- build a Narratio-level Notarius request from validated configuration and
run-local paths;
- call a narrow Notarius adapter;
- apply the configured required-output policy;
- materialize the validated bundle into its canonical session location;
- return manifest-ready artifact references and bounded metadata; and
- fail without marking the stage successful when any required contract or
materialization step fails.
The stage must not construct subprocess arguments, infer Notarius output
filenames, parse provider logs, or decode individual D&D payload bodies.
### Adapter Boundary
Add a dedicated Notarius adapter package with a small interface, production
subprocess implementation, and test fake. Its request should contain only the
resolved Notarius binary, configuration path, pipeline ID, transcript path,
output root, working directory, timeout, and process-log destinations needed
for one run.
The adapter owns:
- optional `notarius config validate` preflight for the configured pipeline;
- exact `notarius run ... --json` argument construction;
- stdout and stderr separation;
- context cancellation and timeout propagation through Narratio's shared
subprocess boundary;
- exit-status handling;
- decoding the `notarius.run-result.v1` success receipt;
- receipt and index path-confinement checks;
- decoding `index.json` and resolving descriptor paths safely beneath the
reported output directory; and
- returning a transport-neutral result containing the bundle location,
receipt summary, lane descriptors, pipeline-wide descriptors, warnings and
rejection locations, and diagnostic log paths.
Only exit status zero permits receipt decoding. Receipt, index, or descriptor
paths that are absolute where a logical relative path is required, or that
escape their owning root, are integration failures. Unknown fields in a
supported receipt or index schema should be tolerated. Unsupported schema
versions and incompatible descriptor metadata should fail clearly.
The adapter must not write Narratio manifests, choose required lanes, decide
analysis inputs, or contain D&D domain logic.
## Configuration Contract
Add a strict optional `pipeline.notarius` configuration section. Omission or
`enabled: false` keeps the current workflow usable and causes `extract` to
self-skip without outputs.
The section should provide:
- `enabled`: explicit opt-in;
- `binary`: Notarius executable, defaulting to `notarius`;
- `config_path`: required when enabled;
- `pipeline_id`: required when enabled;
- `timeout`: a positive stage timeout with a documented default;
- `working_directory`: optional explicit subprocess working directory,
defaulting to the directory containing `config_path`; and
- an `outputs` map defining the Notarius lane artifacts Narratio promises to
collect.
Each output-map key is a stable Narratio extraction key. Each value must define:
- the exact Notarius `lane_id`;
- the expected `media_type`;
- the expected `schema_id`;
- the expected `schema_version`; and
- optionally an expected `module_key` when the operator needs to constrain the
producing module as part of compatibility.
Narratio derives the downstream source ID
`narratio.extraction.<output-key>` from the map key. Keys and lane IDs must be
non-empty, unique after normalization, path-safe under the existing artifact
policy, and collision-free with built-in and configured artifact identities.
Every configured output is required: a successful Notarius process that omits
one, rejects it, or reports incompatible descriptor metadata fails the
`extract` stage.
This explicit map keeps Narratio's consumer contract stable when a Notarius
lane ID or schema changes and avoids hard-coding the current D&D family into a
generic adapter. It also replaces a separate `required_lanes` list, which would
duplicate configuration.
Narratio should not reproduce Notarius lane selection, references, LLM
profiles, model settings, retries, concurrency, or prompt configuration. Those
remain in the referenced Notarius configuration. Narratio should not expose a
runtime lane-selection flag for `extract`; one stage invocation runs the
configured Notarius pipeline as a unit.
All configured paths should become absolute during Narratio configuration
resolution. The deterministic default working directory allows a Notarius
profile path relative to that directory, but operator documentation should
still recommend absolute deployment paths where practical. Notarius reference
paths continue to follow Notarius's own configuration-relative rules.
## Output And Artifact Model
### Canonical Bundle
Run Notarius against a run-local output root. After all configured descriptors
are validated, materialize the contents of the exact run-specific Notarius
bundle into a fixed canonical session directory:
```text
artifacts/notarius/
```
Preserve its relative layout, including `index.json`, `manifest.json`,
`rejected.json`, `warnings.json`, `lanes/`, and any indexed `chunk-map.json` or
`evidence-context.json`. Materialize the complete directory as one narrow,
transactional replacement so a failed or interrupted rerun cannot mix files
from different Notarius runs.
The raw subprocess receipt and stderr log belong in the run-local `extract`
report and log directories. The raw receipt identifies the original run-local
Notarius bundle and must not be rewritten to pretend that the canonical copy
was its original `output_directory`. Narratio's manifest is the durable ledger
for the canonical materialized paths.
### Registered Artifact Sources
For each configured output, locate the lane through the canonical copy of
`index.json` and record a manifest artifact with:
- source ID `narratio.extraction.<output-key>`;
- canonical lane-file path discovered from the index;
- producer stage and Narratio run ID;
- checksum;
- Notarius lane ID; and
- descriptor media type, schema identity/version, and module key when present.
If the current manifest model cannot carry descriptor compatibility metadata,
extend its artifact metadata in a backward-tolerant way rather than encoding
that information in filenames or source IDs.
Also record the canonical Notarius index as a stage output or stage metadata so
operators can discover the complete bundle, including non-lane artifacts. The
configured lane sources are the stable interface for analysis; the index and
bundle remain the provenance and inspection interface.
## Analysis And Publish Integration
Extend the runtime artifact catalog and configured Scriptorium input validation
so an enabled analysis artifact can declare, for example:
```yaml
inputs:
npc_registry:
source: narratio.extraction.npc_registry
```
Resolution must remain manifest-first and verify that the recorded artifact
was produced by a successful current `extract` stage. A required extraction
source that is unavailable must fail analysis with guidance to configure or
rerun `extract`; an optional source may be omitted according to the existing
Scriptorium input contract.
Publish source resolution should accept configured
`narratio.extraction.<output-key>` sources through the same artifact catalog so
operators may publish selected structured artifacts without manually copying
paths. The existing `--artifacts` flag remains scoped to Scriptorium artifact
selection and must not partially execute the Notarius pipeline.
No current-session analysis artifact should consume an incidental file from a
failed, stale, skipped, or superseded extraction run.
## Failure, Skip, Resume, And Diagnostics
- Missing or invalid enabled Notarius configuration fails configuration
validation before stage execution where statically discoverable.
- A disabled or absent Notarius configuration makes `extract` skip with clear
stage metadata and no new outputs.
- A missing or invalid final trimmed transcript fails `extract` before starting
Notarius.
- Preflight failure, nonzero Notarius exit, cancellation, timeout, malformed or
unsupported receipt/index data, unsafe paths, incompatible descriptors,
rejected required outputs, or missing configured lanes fails the entire
stage.
- Process success does not override Narratio's required-output policy.
- A failed run retains bounded run-local receipt bytes, stderr, and the
unpublished Notarius bundle for diagnosis, subject to Narratio's existing
sensitive-data and cleanup policies.
- The canonical bundle and manifest artifacts are updated only after complete
validation and materialization.
- Resume skips a succeeded, non-stale `extract` stage only when its
manifest-recorded canonical index and configured lane outputs still validate.
- Force and staleness behavior follows the ordinary stage contract; it must not
depend on merely finding `artifacts/notarius/` on disk.
Transcripts, Notarius outputs, evidence context, manifests, receipts, and logs
are private campaign material. Subprocess arguments and manifest metadata must
not contain secrets. Credentials remain in the environment or in mechanisms
owned by Notarius and PromptKit.
## Maintained D&D Example
Add or update a Narratio example that enables Notarius's complete
`dnd-session` pipeline and maps these ten required lanes to stable extraction
keys:
| Output key | Notarius lane ID |
| --- | --- |
| `item_registry` | `item-registry` |
| `npc_registry` | `npc-registry` |
| `location_registry` | `location-registry` |
| `scene_descriptions` | `scene-descriptions` |
| `item_occurrences` | `item-occurrences` |
| `spells` | `spells` |
| `combat_turns` | `combat-turns` |
| `npc_occurrences` | `npc-occurrences` |
| `location_occurrences` | `location-occurrences` |
| `enemy_events` | `enemy-events` |
The example must include each lane's current media type and schema identity
from Notarius's published contracts. It should also demonstrate at least one
Scriptorium analysis artifact consuming one or more
`narratio.extraction.*` sources. The example must use placeholders and relative
paths suitable for the example tree, contain no credentials, and pass the
repository's configuration validation tests.
## Compatibility Policy
The initial integration baseline is the public subprocess contract available
in Notarius v0.3.0:
- successful JSON receipt schema `notarius.run-result.v1`;
- production JSON bundle discovery through `index.json`; and
- the schema IDs and versions explicitly configured for required lanes.
Runtime compatibility should be decided from those published contracts, not
from textual parsing of `notarius --version`. New optional receipt or index
fields must not break Narratio. An unsupported receipt version or lane schema
must fail before the artifact is registered for analysis.
## Documentation Deliverables When Implemented
Update current-behavior documentation in the same change that implements the
feature:
- add `docs/integrations/notarius.md` for the external CLI, receipt, bundle,
and adapter contract, linking to Notarius's canonical documentation;
- add `docs/internal/stage-extract.md` for stage inputs, outputs, collaborators,
state transitions, failures, and focused tests;
- update `docs/internal/adapters.md`, `docs/internal/artifacts.md`,
`docs/internal/manifest.md`, and the internal stage inventory;
- update `docs/policy/architecture.md` to list Notarius among isolated external
systems and preserve the adapter/stage boundary;
- update `docs/config.md`, `docs/cli.md`, `docs/operations.md`,
`docs/troubleshooting.md`, `README.md`, and maintained examples only to the
extent their canonical scopes require; and
- update `docs/development.md` only to the extent its canonical contributor
routing scope requires.
Outside this roadmap, do not describe `extract`, Notarius configuration, or
`narratio.extraction.*` sources as implemented until the code exists.
## Testing And Validation Expectations
Implementation should provide focused tests for:
- strict configuration decoding, defaults, required fields, path resolution,
output-map validation, normalized-key collisions, and example loading;
- exact stage order, selection, downstream staleness, resume, force, and
prerequisite behavior;
- adapter command construction, deterministic working directory, environment
inheritance, stdout/stderr separation, cancellation, timeout, and nonzero
exits;
- supported and unsupported receipt versions, unknown optional fields,
malformed receipts, index decoding, and path escapes at every boundary;
- descriptor lookup by lane ID rather than filename, expected metadata checks,
missing/rejected configured lanes, and tolerated unconfigured lanes;
- run-local execution, transactional canonical-bundle replacement, checksums,
failed-run preservation, and manifest recording;
- artifact-catalog resolution from `narratio.extraction.*` into analysis and
publish, including required, optional, missing, stale, and skipped cases; and
- end-to-end stage execution with a fake Notarius adapter, without live LLM or
external subprocess requirements in the ordinary test suite.
Run the repository-wide Go tests, vet, build, and maintained example validation
after focused tests pass.
## Acceptance Criteria
- `extract` is a first-class transactional stage between `render` and
`analyze` everywhere Narratio models stage order or state.
- Narratio invokes Notarius only through a narrow, tested adapter.
- The stage consumes the manifest-resolved final trimmed Seriatim transcript.
- The Notarius configuration remains owned by Notarius; Narratio configures
only invocation and its downstream consumer contract.
- Every configured output is discovered through the receipt and `index.json`,
contract-checked, materialized transactionally, and recorded with a stable
`narratio.extraction.*` source ID.
- The complete D&D example maps all ten current lanes and passes strict config
validation.
- Analysis can consume extraction sources through the existing artifact input
model, and publish can select them through the artifact catalog.
- Failed, partial, rejected, unsafe, stale, or incompatible output never becomes
a current analysis input.
- Resume and force behavior remains manifest-driven.
- Documentation accurately describes the implemented stage, adapter,
configuration, operations, and compatibility boundary without duplicating
Notarius's canonical schemas.
## Non-Goals
- Reimplementing Notarius extraction, prompts, schemas, references, retries,
profiles, or lane orchestration in Narratio.
- Allowing one Narratio run to invoke arbitrary extractor programs or multiple
Notarius pipelines.
- Making `extract` a configurable DAG or folding it into the Scriptorium
`analyze` stage.
- Partially selecting Notarius lanes through Narratio's `--artifacts` flag.
- Decoding D&D payload bodies in the generic Notarius adapter.
- Supporting previous-session extraction artifacts in the initial feature.
- Requiring live Notarius, PromptKit, an LLM provider, or external services in
the ordinary unit test suite.

View File

@@ -24,6 +24,8 @@ Safe fix:
- pass explicit `--config`, `--campaign` or `--campaign-file`, and `--session`. - pass explicit `--config`, `--campaign` or `--campaign-file`, and `--session`.
Relevant reference: [Configuration discovery](./config.md#discovery-and-selection).
## Session template placeholders rejected ## Session template placeholders rejected
Symptom: Symptom:
@@ -44,6 +46,8 @@ Safe fix:
- generate concrete session YAML with `narratio session init`. - generate concrete session YAML with `narratio session init`.
Relevant reference: [Operations: Session Initialization](./operations.md#session-initialization).
## Strict decode or schema validation failure ## Strict decode or schema validation failure
Symptom: Symptom:
@@ -62,7 +66,10 @@ narratio session plan 2026-04-04 --config /path/pipeline.yml --campaign-file /pa
Safe fix: Safe fix:
- align config with [docs/config.md](./config.md) and maintained files under `examples/`. - align config with [Configuration](./config.md) and the
[maintained examples](../examples/README.md).
Relevant reference: [Configuration](./config.md).
## Audio mode conflict ## Audio mode conflict
@@ -74,10 +81,18 @@ Likely cause:
- configured both local and S3 session audio inputs. - configured both local and S3 session audio inputs.
Diagnostics:
```bash
narratio session validate 2026-04-04
```
Safe fix: Safe fix:
- use local mode (`audio_dir` or `audio_files`) or S3 mode (`audio_s3.prefix`), not both. - use local mode (`audio_dir` or `audio_files`) or S3 mode (`audio_s3.prefix`), not both.
Relevant reference: [Session configuration](./config.md#session).
## `--artifacts` selection error ## `--artifacts` selection error
Symptom: Symptom:
@@ -90,10 +105,18 @@ Likely causes:
- empty list entry (for example trailing comma); - empty list entry (for example trailing comma);
- `run-stage` used with non-`analyze`/`publish` target. - `run-stage` used with non-`analyze`/`publish` target.
Diagnostics:
```bash
narratio session artifacts 2026-04-04
```
Safe fix: Safe fix:
- provide only configured keys and use `--artifacts` with supported commands/stages. - provide only configured keys and use `--artifacts` with supported commands/stages.
Relevant reference: [CLI artifact selection](./cli.md).
## Previous-session artifact input missing ## Previous-session artifact input missing
Symptom: Symptom:
@@ -124,6 +147,8 @@ or rerun prepare after correcting session config:
narratio run-stage prepare 2026-04-04 --force narratio run-stage prepare 2026-04-04 --force
``` ```
Relevant reference: [Operations: Restore Workflow](./operations.md#restore-workflow).
## Session lock conflict (`.lock`) ## Session lock conflict (`.lock`)
Symptom: Symptom:
@@ -147,6 +172,8 @@ Safe fix:
- wait for active process completion; - wait for active process completion;
- remove stale lock only after confirming no live process owns it. - remove stale lock only after confirming no live process owns it.
Relevant reference: [Operations: Local State Layout](./operations.md#local-state-layout).
## Restore conflict without `--force` ## Restore conflict without `--force`
Symptom: Symptom:
@@ -168,6 +195,8 @@ Safe fix:
- review conflicts; - review conflicts;
- rerun with `--force` only when remote state should overwrite local. - rerun with `--force` only when remote state should overwrite local.
Relevant reference: [Operations: Restore Workflow](./operations.md#restore-workflow).
## Restore current-state discovery failure ## Restore current-state discovery failure
Symptom: Symptom:
@@ -191,6 +220,8 @@ Safe fix:
- resolve storage/auth issue; - resolve storage/auth issue;
- republish from healthy local state if current pointer is missing. - republish from healthy local state if current pointer is missing.
Relevant reference: [Operations: Publish Workflow](./operations.md#publish-workflow).
## Publish output failure ## Publish output failure
Symptom: Symptom:
@@ -217,6 +248,8 @@ Safe fix:
- correct publish source/destination rules; - correct publish source/destination rules;
- retry after storage failure is resolved. - retry after storage failure is resolved.
Relevant reference: [Publish configuration](./config.md#publish-configuration-summary).
## Render markdown source missing ## Render markdown source missing
Symptom: Symptom:
@@ -232,7 +265,6 @@ Diagnostics:
```bash ```bash
narratio session status 2026-04-04 narratio session status 2026-04-04
narratio run-stage render 2026-04-04 --force
``` ```
Safe fix: Safe fix:
@@ -244,6 +276,8 @@ narratio run-stage render 2026-04-04 --force
narratio run-stage analyze 2026-04-04 --force narratio run-stage analyze 2026-04-04 --force
``` ```
Relevant reference: [Operations: Stage Execution](./operations.md#stage-execution-and-continuation-behavior).
## Secrets or storage credential failure ## Secrets or storage credential failure
Symptom: Symptom:
@@ -269,6 +303,8 @@ Safe fix:
- provide required env vars; - provide required env vars;
- keep secret values out of YAML. - keep secret values out of YAML.
Relevant reference: [Secrets](./config.md#secrets-handling).
## S3 audio prepare failure ## S3 audio prepare failure
Symptom: Symptom:
@@ -284,7 +320,7 @@ Likely causes:
Diagnostics: Diagnostics:
```bash ```bash
narratio run-stage prepare 2026-04-04 --force narratio session validate 2026-04-04
``` ```
Safe fix: Safe fix:
@@ -292,6 +328,8 @@ Safe fix:
- verify prefix contents and storage access; - verify prefix contents and storage access;
- keep session audio mode consistent. - keep session audio mode consistent.
Relevant reference: [Operations](./operations.md).
## References ## References
- [docs/cli.md](./cli.md) - [docs/cli.md](./cli.md)

46
examples/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Maintained Examples
These files are safe, copyable starting points for Narratio configuration and
input structure. Replace placeholder identifiers, storage names, integration
URLs, and paths for the target environment. Field meanings and defaults belong
in the [configuration reference](../docs/config.md).
## Pipeline Configuration
- [Minimal pipeline](pipeline.minimal.yml): campaign discovery plus the required
WhisperX URL.
- [Production-shaped pipeline](pipeline.production.yml): S3 storage, publish,
external tools, and configured Scriptorium artifacts.
- [Full annotated pipeline](pipeline.full.annotated.yml): every implemented
pipeline section with explanatory comments.
The existing `internal/config` example test loads and validates each pipeline
with the sample campaign and a compatible local- or S3-audio session.
## Campaign And Session Configuration
- [Sample campaign](campaigns/sample-campaign/campaign.yml), its
[session template](campaigns/sample-campaign/session.template.yml), and its
adjacent stable inputs provide a complete campaign directory shape.
- [Local-audio session](session.local-audio.yml) and
[S3-audio session](session.s3-audio.yml) are concrete session files.
- [Session template](session.template.yml) and the campaign-local equivalent
demonstrate the narrow placeholder syntax consumed by `session init`; they
are templates, not runtime session files.
## Input Fixtures
- [Speakers](speakers.yml), [autocorrect](autocorrect.yml), and
[glossary](glossary.yml) show the standalone input shapes.
- The sample campaign references its local
[speakers](campaigns/sample-campaign/speakers.yml),
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
[glossary](campaigns/sample-campaign/glossary.yml),
[players](campaigns/sample-campaign/players.yml), and
[party](campaigns/sample-campaign/party.yml) fixtures.
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
reserves the expected filename and directory shape. Replace it with a real
FLAC file before running transcription.
The examples contain environment-variable names but no credential values. They
use fictional campaign content and reserved example domains.

View File

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

View File

@@ -0,0 +1,2 @@
- name: Example Hero
type: pc

View File

@@ -0,0 +1,2 @@
- name: Example Player
role: player

View File

@@ -1,5 +1,5 @@
match: match:
- speaker: "Eric Rakestraw" - speaker: "Example Speaker"
match: match:
- "Eric_Rakestraw" - "Example_Speaker"
- "Eric" - "Example"

View File

@@ -110,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
@@ -144,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

View File

@@ -71,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
@@ -93,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

View File

@@ -1,5 +1,5 @@
match: match:
- speaker: "Eric Rakestraw" - speaker: "Example Speaker"
match: match:
- "Eric_Rakestraw" - "Example_Speaker"
- "Eric" - "Example"

View File

@@ -577,9 +577,9 @@ func buildRenderArgs(req RenderRequest, format string) []string {
"--input-file", req.InputTranscriptPath, "--input-file", req.InputTranscriptPath,
"--output-file", req.OutputRenderedPath, "--output-file", req.OutputRenderedPath,
"--format", format, "--format", format,
"--include-timestamps", strconv.FormatBool(req.IncludeTimestamps), "--include-timestamps=" + strconv.FormatBool(req.IncludeTimestamps),
"--include-segment-ids", strconv.FormatBool(req.IncludeSegmentIDs), "--include-segment-ids=" + strconv.FormatBool(req.IncludeSegmentIDs),
"--include-metadata", strconv.FormatBool(req.IncludeMetadata), "--include-metadata=" + strconv.FormatBool(req.IncludeMetadata),
} }
if strings.TrimSpace(req.Title) != "" { if strings.TrimSpace(req.Title) != "" {
args = append(args, "--title", req.Title) args = append(args, "--title", req.Title)

View File

@@ -628,9 +628,9 @@ func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
"--input-file", req.InputTranscriptPath, "--input-file", req.InputTranscriptPath,
"--output-file", req.OutputRenderedPath, "--output-file", req.OutputRenderedPath,
"--format", req.Format, "--format", req.Format,
"--include-timestamps", "true", "--include-timestamps=true",
"--include-segment-ids", "false", "--include-segment-ids=true",
"--include-metadata", "true", "--include-metadata=false",
"--title", req.Title, "--title", req.Title,
} }
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") { if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
@@ -946,8 +946,8 @@ func renderReqForTest(t *testing.T) RenderRequest {
Format: "markdown", Format: "markdown",
Title: "Session 42", Title: "Session 42",
IncludeTimestamps: true, IncludeTimestamps: true,
IncludeSegmentIDs: false, IncludeSegmentIDs: true,
IncludeMetadata: true, IncludeMetadata: false,
GeneratedConfigPath: filepath.Join(dir, "seriatim.render.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "seriatim.render.generated.yml"),
StdoutLogPath: filepath.Join(dir, "seriatim.render.stdout.log"), StdoutLogPath: filepath.Join(dir, "seriatim.render.stdout.log"),
StderrLogPath: filepath.Join(dir, "seriatim.render.stderr.log"), StderrLogPath: filepath.Join(dir, "seriatim.render.stderr.log"),

View File

@@ -51,8 +51,3 @@ func Execute(args []string, stdout, stderr io.Writer) int {
func printUsage(w io.Writer) { func printUsage(w io.Writer) {
fmt.Fprintf(w, "Usage: narratio <%s>\n", strings.Join(supportedCommands, "|")) fmt.Fprintf(w, "Usage: narratio <%s>\n", strings.Join(supportedCommands, "|"))
} }
func placeholder(out io.Writer, command string) error {
_, err := fmt.Fprintf(out, "narratio %s: not yet implemented\n", command)
return err
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1024,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{
@@ -1053,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",
@@ -1062,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",
}, },
}, },
} }
@@ -1084,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
@@ -1092,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)

View File

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

View File

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

View File

@@ -112,6 +112,9 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
}{ }{
{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: "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},

View File

@@ -1,57 +0,0 @@
package artifacts
import (
"fmt"
"os"
)
// S3Store is a placeholder for future remote artifact persistence support.
type S3Store struct {
Bucket string
Prefix string
}
// SessionPathsFor is not implemented for S3-backed storage.
func (s *S3Store) SessionPathsFor(_, _ string) SessionPaths {
return SessionPaths{}
}
// EnsureLayoutFor returns a not-yet-implemented error in the scaffold.
func (s *S3Store) EnsureLayoutFor(_, _ string) (SessionPaths, error) {
return SessionPaths{}, fmt.Errorf("artifacts s3 ensure layout for campaign/session: not yet implemented")
}
// CopyInputFor returns a not-yet-implemented error in the scaffold.
func (s *S3Store) CopyInputFor(_, _, _, _ string) (Ref, error) {
return Ref{}, fmt.Errorf("artifacts s3 copy input for campaign/session: not yet implemented")
}
// Exists returns a not-yet-implemented error in the scaffold.
func (s *S3Store) Exists(_ string) (bool, error) {
return false, fmt.Errorf("artifacts s3 exists: not yet implemented")
}
// ExistsRef returns a not-yet-implemented error in the scaffold.
func (s *S3Store) ExistsRef(_ Ref) (bool, error) {
return false, fmt.Errorf("artifacts s3 exists ref: not yet implemented")
}
// WriteFileAtomic returns a not-yet-implemented error in the scaffold.
func (s *S3Store) WriteFileAtomic(_ string, _ []byte, _ os.FileMode) error {
return fmt.Errorf("artifacts s3 write file atomic: not yet implemented")
}
// Checksum returns a not-yet-implemented error in the scaffold.
func (s *S3Store) Checksum(_ string) (string, error) {
return "", fmt.Errorf("artifacts s3 checksum: not yet implemented")
}
// AcquireSessionLockFor returns a not-yet-implemented error in the scaffold.
func (s *S3Store) AcquireSessionLockFor(_, _ string) (*LockHandle, error) {
return nil, fmt.Errorf("artifacts s3 acquire lock for campaign/session: not yet implemented")
}
// ReleaseSessionLock returns a not-yet-implemented error in the scaffold.
func (s *S3Store) ReleaseSessionLock(_ *LockHandle) error {
return fmt.Errorf("artifacts s3 release lock: not yet implemented")
}

View File

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

View File

@@ -51,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.
@@ -188,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"`
@@ -216,7 +218,7 @@ type RenderConfig struct {
Format string `yaml:"format"` Format string `yaml:"format"`
Title string `yaml:"title"` Title string `yaml:"title"`
IncludeTimestamps *bool `yaml:"include_timestamps"` IncludeTimestamps *bool `yaml:"include_timestamps"`
IncludeSegmentIDs bool `yaml:"include_segment_ids"` IncludeSegmentIDs *bool `yaml:"include_segment_ids"`
IncludeMetadata bool `yaml:"include_metadata"` IncludeMetadata bool `yaml:"include_metadata"`
} }
@@ -265,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.
@@ -278,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.

View File

@@ -34,18 +34,25 @@ const (
DefaultAuditaTimeout = "3h" DefaultAuditaTimeout = "3h"
DefaultAuditaReport = true DefaultAuditaReport = true
DefaultScriptoriumBinary = "scriptorium" DefaultScriptoriumBinary = "scriptorium"
DefaultScriptoriumTimeout = "10m" DefaultScriptoriumTimeout = "10m"
DefaultScriptoriumArtifactOutputRoot = "artifacts" DefaultScriptoriumArtifactOutputRoot = "artifacts"
DefaultScriptoriumStickySessionVarName = "session_id"
DefaultScriptoriumStickySessionVarPrefix = "narratio-session-"
DefaultTrimBoundsTimeout = "10m" DefaultTrimEnabled = true
DefaultTrimSeriatimReport = false DefaultTrimOutputPath = artifactmodel.TranscriptPathFinalTrimmed
DefaultRenderEnabled = true DefaultTrimBoundsPromptID = "dnd.session_bounds"
DefaultRenderFormat = "markdown" DefaultTrimBoundsTranscriptInputName = "transcript"
DefaultRenderTitle = "" DefaultTrimBoundsOutputPath = "artifacts/session_bounds.json"
DefaultRenderTimestamps = true DefaultTrimBoundsTimeout = "10m"
DefaultRenderSegmentIDs = false DefaultTrimSeriatimReport = false
DefaultRenderMetadata = false DefaultRenderEnabled = true
DefaultRenderFormat = "markdown"
DefaultRenderTitle = ""
DefaultRenderTimestamps = true
DefaultRenderSegmentIDs = true
DefaultRenderMetadata = false
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
DefaultNormalizeOutputSchema = "seriatim-intermediate" DefaultNormalizeOutputSchema = "seriatim-intermediate"

View File

@@ -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,13 @@ 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) applyRenderDefaults(&cfg.Render)
applyScriptoriumDefaults(cfg.Scriptorium) applyScriptoriumDefaults(cfg.Scriptorium)
} }
@@ -500,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
} }
@@ -508,6 +543,10 @@ func applyTrimDefaults(cfg *TrimConfig) {
} }
} }
func trimEnabled(cfg *TrimConfig) bool {
return cfg != nil && cfg.Enabled != nil && *cfg.Enabled
}
func applyRenderDefaults(cfg **RenderConfig) { func applyRenderDefaults(cfg **RenderConfig) {
if cfg == nil { if cfg == nil {
return return
@@ -527,6 +566,9 @@ func applyRenderDefaults(cfg **RenderConfig) {
if (*cfg).IncludeTimestamps == nil { if (*cfg).IncludeTimestamps == nil {
(*cfg).IncludeTimestamps = boolPtr(DefaultRenderTimestamps) (*cfg).IncludeTimestamps = boolPtr(DefaultRenderTimestamps)
} }
if (*cfg).IncludeSegmentIDs == nil {
(*cfg).IncludeSegmentIDs = boolPtr(DefaultRenderSegmentIDs)
}
} }
func applyNormalizeDefaults(cfg *NormalizeConfig) { func applyNormalizeDefaults(cfg *NormalizeConfig) {

View File

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

View File

@@ -30,8 +30,8 @@ func TestRenderLoadAndValidate(t *testing.T) {
if cfg.Pipeline.Render.IncludeTimestamps == nil || !*cfg.Pipeline.Render.IncludeTimestamps { if cfg.Pipeline.Render.IncludeTimestamps == nil || !*cfg.Pipeline.Render.IncludeTimestamps {
t.Fatalf("render.include_timestamps = %#v, want true", cfg.Pipeline.Render.IncludeTimestamps) t.Fatalf("render.include_timestamps = %#v, want true", cfg.Pipeline.Render.IncludeTimestamps)
} }
if cfg.Pipeline.Render.IncludeSegmentIDs { if cfg.Pipeline.Render.IncludeSegmentIDs == nil || !*cfg.Pipeline.Render.IncludeSegmentIDs {
t.Fatalf("render.include_segment_ids = true, want false") t.Fatalf("render.include_segment_ids = %#v, want true", cfg.Pipeline.Render.IncludeSegmentIDs)
} }
if cfg.Pipeline.Render.IncludeMetadata { if cfg.Pipeline.Render.IncludeMetadata {
t.Fatalf("render.include_metadata = true, want false") t.Fatalf("render.include_metadata = true, want false")
@@ -59,14 +59,26 @@ func TestRenderLoadAndValidate(t *testing.T) {
if cfg.Pipeline.Render.IncludeTimestamps == nil || *cfg.Pipeline.Render.IncludeTimestamps { if cfg.Pipeline.Render.IncludeTimestamps == nil || *cfg.Pipeline.Render.IncludeTimestamps {
t.Fatalf("render.include_timestamps = %#v, want false", cfg.Pipeline.Render.IncludeTimestamps) t.Fatalf("render.include_timestamps = %#v, want false", cfg.Pipeline.Render.IncludeTimestamps)
} }
if !cfg.Pipeline.Render.IncludeSegmentIDs { if cfg.Pipeline.Render.IncludeSegmentIDs == nil || !*cfg.Pipeline.Render.IncludeSegmentIDs {
t.Fatalf("render.include_segment_ids = false, want true") t.Fatalf("render.include_segment_ids = %#v, want true", cfg.Pipeline.Render.IncludeSegmentIDs)
} }
if !cfg.Pipeline.Render.IncludeMetadata { if !cfg.Pipeline.Render.IncludeMetadata {
t.Fatalf("render.include_metadata = false, want true") 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", name: "invalid render format fails",
renderYAML: `render: renderYAML: `render:

View File

@@ -215,6 +215,27 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
transcript_markdown: transcript_markdown:
source: narratio.transcript.final_markdown source: narratio.transcript.final_markdown
required: true 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
`, `,
}, },
{ {
@@ -575,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
` `

View File

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

View File

@@ -238,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:
@@ -426,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 + `
@@ -579,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
`, `,
}, },
{ {
@@ -591,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",
}, },
@@ -604,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",
}, },
@@ -618,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",
}, },
@@ -664,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)

View File

@@ -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,67 +57,25 @@ 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: "missing prompt id fails when enabled", name: "explicit disabled remains disabled",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: false
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", assert: func(t *testing.T, cfg *Config) {
}, t.Helper()
{ if cfg.Pipeline.Trim == nil || cfg.Pipeline.Trim.Enabled == nil || *cfg.Pipeline.Trim.Enabled {
name: "missing transcript input name fails when enabled", t.Fatalf("trim.enabled = %#v, want false", cfg.Pipeline.Trim)
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",
@@ -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)
}
}

View File

@@ -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
} }
@@ -276,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
} }
@@ -312,6 +330,9 @@ func validateRender(cfg *RenderConfig) error {
if cfg.IncludeTimestamps == nil { if cfg.IncludeTimestamps == nil {
return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)") 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) format := strings.TrimSpace(cfg.Format)
if format != "markdown" { if format != "markdown" {
return fmt.Errorf("pipeline.render.format must be markdown") return fmt.Errorf("pipeline.render.format must be markdown")
@@ -589,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

View File

@@ -1,7 +0,0 @@
package contracts
// ArtifactResult is a placeholder generated artifact contract.
type ArtifactResult struct {
Schema string `json:"schema"`
Path string `json:"path"`
}

View File

@@ -1,7 +0,0 @@
package contracts
// SessionManifest is a placeholder durable session-run contract.
type SessionManifest struct {
Schema string `json:"schema"`
SessionID string `json:"session_id"`
}

View File

@@ -1,19 +0,0 @@
package contracts
// SpeakerTranscript is a placeholder transcription artifact contract.
type SpeakerTranscript struct {
Schema string `json:"schema"`
SessionID string `json:"session_id"`
}
// CanonicalTranscript is a placeholder merged transcript contract.
type CanonicalTranscript struct {
Schema string `json:"schema"`
SessionID string `json:"session_id"`
}
// ProcessedTranscript is a placeholder polished transcript contract.
type ProcessedTranscript struct {
Schema string `json:"schema"`
SessionID string `json:"session_id"`
}

View File

@@ -347,6 +347,7 @@ func executeAnalyzeArtifact(
if err != nil { if err != nil {
return nil, fmt.Errorf("analyze: resolve vars for artifact %q: %w", artifactName, err) return nil, fmt.Errorf("analyze: resolve vars for artifact %q: %w", artifactName, err)
} }
vars = withScriptoriumStickySessionVar(vars, sessionID)
canonicalOutputPath, err := resolveScriptoriumOutputPath(paths, artifactCfg.OutputPath) canonicalOutputPath, err := resolveScriptoriumOutputPath(paths, artifactCfg.OutputPath)
if err != nil { if err != nil {
@@ -632,6 +633,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 {
@@ -684,6 +695,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,

View File

@@ -54,6 +54,9 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) {
if req.Timeout != 2*time.Minute { if req.Timeout != 2*time.Minute {
t.Fatalf("timeout = %s, want 2m", req.Timeout) t.Fatalf("timeout = %s, want 2m", req.Timeout)
} }
if req.Vars["session_id"] != "narratio-session-2026-05-03" {
t.Fatalf("session_id var = %q, want sticky narratio session id", req.Vars["session_id"])
}
if len(result.Outputs) != 1 || result.Outputs[0].Kind != "session_recap" { if len(result.Outputs) != 1 || result.Outputs[0].Kind != "session_recap" {
t.Fatalf("outputs = %#v, want one session_recap output", result.Outputs) t.Fatalf("outputs = %#v, want one session_recap output", result.Outputs)
@@ -69,6 +72,34 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) {
} }
} }
func TestAnalyzeStickySessionVarOverridesConfiguredAndPreservesArbitraryVars(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.Vars = map[string]any{
"session_id": "configured-session",
"character_name": "Hrank",
"character_class": "Fighter",
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
if _, err := (analyzeStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("Run() error = %v", err)
}
req := fake.RunRequests[0]
if req.Vars["session_id"] != "narratio-session-2026-05-03" {
t.Fatalf("session_id var = %q, want sticky value", req.Vars["session_id"])
}
if req.Vars["character_name"] != "Hrank" {
t.Fatalf("character_name var = %q, want Hrank", req.Vars["character_name"])
}
if req.Vars["character_class"] != "Fighter" {
t.Fatalf("character_class var = %q, want Fighter", req.Vars["character_class"])
}
}
func TestAnalyzeRenderDebugFalseDoesNotCallRenderArtifact(t *testing.T) { func TestAnalyzeRenderDebugFalseDoesNotCallRenderArtifact(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
@@ -142,6 +173,12 @@ func TestAnalyzeRenderOutputPathIsRecorded(t *testing.T) {
if len(fake.RenderRequests) != 1 { if len(fake.RenderRequests) != 1 {
t.Fatalf("render requests = %d, want 1", len(fake.RenderRequests)) t.Fatalf("render requests = %d, want 1", len(fake.RenderRequests))
} }
if fake.RenderRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" {
t.Fatalf("render session_id var = %q, want sticky narratio session id", fake.RenderRequests[0].Vars["session_id"])
}
if fake.RunRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" {
t.Fatalf("run session_id var = %q, want sticky narratio session id", fake.RunRequests[0].Vars["session_id"])
}
if result.Metadata["render_output_path"] != filepath.Join(paths.ArtifactsDir, "session_recap.render.json") { if result.Metadata["render_output_path"] != filepath.Join(paths.ArtifactsDir, "session_recap.render.json") {
t.Fatalf("render_output_path = %#v, want session_recap.render.json path", result.Metadata["render_output_path"]) t.Fatalf("render_output_path = %#v, want session_recap.render.json path", result.Metadata["render_output_path"])
} }
@@ -986,6 +1023,96 @@ func TestAnalyzeSupportsRenderedMarkdownTranscriptSourceWhenConfigured(t *testin
} }
} }
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)

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,10 @@ func (renderStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St
} }
title := resolveRenderTitle(renderCfg, env.Config.Session) title := resolveRenderTitle(renderCfg, env.Config.Session)
includeTimestamps := renderCfg.IncludeTimestamps == nil || *renderCfg.IncludeTimestamps includeTimestamps := renderCfg.IncludeTimestamps == nil || *renderCfg.IncludeTimestamps
includeSegmentIDs := renderCfg.IncludeSegmentIDs includeSegmentIDs := config.DefaultRenderSegmentIDs
if renderCfg.IncludeSegmentIDs != nil {
includeSegmentIDs = *renderCfg.IncludeSegmentIDs
}
includeMetadata := renderCfg.IncludeMetadata includeMetadata := renderCfg.IncludeMetadata
meta := map[string]any{ meta := map[string]any{
@@ -237,11 +240,12 @@ func renderConfigOrDefault(cfg *config.RenderConfig) *config.RenderConfig {
} }
enabled := true enabled := true
includeTimestamps := true includeTimestamps := true
includeSegmentIDs := config.DefaultRenderSegmentIDs
return &config.RenderConfig{ return &config.RenderConfig{
Enabled: &enabled, Enabled: &enabled,
Format: config.DefaultRenderFormat, Format: config.DefaultRenderFormat,
IncludeTimestamps: &includeTimestamps, IncludeTimestamps: &includeTimestamps,
IncludeSegmentIDs: config.DefaultRenderSegmentIDs, IncludeSegmentIDs: &includeSegmentIDs,
IncludeMetadata: config.DefaultRenderMetadata, IncludeMetadata: config.DefaultRenderMetadata,
} }
} }

View File

@@ -165,6 +165,7 @@ func setupRenderEnv(t *testing.T) (*Env, *manifest.Manifest, *seriatim.FakeRunne
enabled := true enabled := true
includeTimestamps := true includeTimestamps := true
includeSegmentIDs := false
seriatimReport := false seriatimReport := false
cfg := &config.Config{ cfg := &config.Config{
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
@@ -183,7 +184,7 @@ func setupRenderEnv(t *testing.T) (*Env, *manifest.Manifest, *seriatim.FakeRunne
Format: "markdown", Format: "markdown",
Title: "Pipeline Title", Title: "Pipeline Title",
IncludeTimestamps: &includeTimestamps, IncludeTimestamps: &includeTimestamps,
IncludeSegmentIDs: false, IncludeSegmentIDs: &includeSegmentIDs,
IncludeMetadata: false, IncludeMetadata: false,
}, },
}, },

View File

@@ -0,0 +1,21 @@
package stage
import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func withScriptoriumStickySessionVar(vars map[string]string, sessionID string) map[string]string {
out := make(map[string]string, len(vars)+1)
for k, v := range vars {
out[k] = v
}
if trimmedSessionID := strings.TrimSpace(sessionID); trimmedSessionID != "" {
out[config.DefaultScriptoriumStickySessionVarName] = config.DefaultScriptoriumStickySessionVarPrefix + trimmedSessionID
}
if len(out) == 0 {
return nil
}
return out
}

View File

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

View File

@@ -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 {
@@ -150,7 +150,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag
inputPaths := map[string]string{ inputPaths := map[string]string{
boundsCfg.TranscriptInputName: normalizedPath, boundsCfg.TranscriptInputName: normalizedPath,
} }
vars := map[string]string{} vars := withScriptoriumStickySessionVar(nil, sessionID)
metadata["bounds_prompt_id"] = boundsCfg.PromptID metadata["bounds_prompt_id"] = boundsCfg.PromptID
metadata["bounds_profile_id"] = boundsCfg.ProfileID metadata["bounds_profile_id"] = boundsCfg.ProfileID

View File

@@ -34,6 +34,9 @@ func TestTrimStageConsumesNormalizedAndProducesTrimmedTranscript(t *testing.T) {
if len(scr.RunRequests) != 1 { if len(scr.RunRequests) != 1 {
t.Fatalf("scriptorium run requests = %d, want 1", len(scr.RunRequests)) t.Fatalf("scriptorium run requests = %d, want 1", len(scr.RunRequests))
} }
if scr.RunRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" {
t.Fatalf("session_id var = %q, want sticky narratio session id", scr.RunRequests[0].Vars["session_id"])
}
if len(ser.TrimRequests) != 1 { if len(ser.TrimRequests) != 1 {
t.Fatalf("seriatim trim requests = %d, want 1", len(ser.TrimRequests)) t.Fatalf("seriatim trim requests = %d, want 1", len(ser.TrimRequests))
} }
@@ -129,6 +132,12 @@ func TestTrimStageRenderDebugDiagnosticsAreNotStageOutputs(t *testing.T) {
if len(scr.RenderRequests) != 1 { if len(scr.RenderRequests) != 1 {
t.Fatalf("scriptorium render requests = %d, want 1", len(scr.RenderRequests)) t.Fatalf("scriptorium render requests = %d, want 1", len(scr.RenderRequests))
} }
if scr.RenderRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" {
t.Fatalf("render session_id var = %q, want sticky narratio session id", scr.RenderRequests[0].Vars["session_id"])
}
if scr.RunRequests[0].Vars["session_id"] != "narratio-session-2026-05-03" {
t.Fatalf("run session_id var = %q, want sticky narratio session id", scr.RunRequests[0].Vars["session_id"])
}
for _, out := range result.Outputs { for _, out := range result.Outputs {
if out.Kind == "session_bounds_render" { if out.Kind == "session_bounds_render" {
t.Fatalf("render diagnostics should not be stage outputs: %#v", result.Outputs) t.Fatalf("render diagnostics should not be stage outputs: %#v", result.Outputs)
@@ -275,7 +284,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 +434,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 +448,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",