From 902e6bc9949fd56e3ea40faf2fa92079a8fc54fb Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 2 May 2026 10:37:38 -0500 Subject: [PATCH] Initialize narratio Go module and CLI scaffold --- README.md | 29 +- architecture.md | 753 +++++++++++++++++++++++++++ cmd/narratio/main.go | 11 + go.mod | 3 + internal/adapters/analyzer/runner.go | 20 + internal/adapters/audita/runner.go | 20 + internal/adapters/notify/sender.go | 15 + internal/adapters/seriatim/runner.go | 20 + internal/adapters/whisperx/client.go | 20 + internal/app/app.go | 26 + internal/app/commands.go | 56 ++ internal/app/commands_test.go | 75 +++ internal/app/doc.go | 3 + internal/app/plan.go | 11 + internal/app/resume.go | 11 + internal/app/run.go | 11 + internal/app/run_stage.go | 11 + internal/app/status.go | 11 + internal/artifacts/checksum.go | 24 + internal/artifacts/doc.go | 2 + internal/artifacts/local.go | 31 ++ internal/artifacts/paths.go | 8 + internal/artifacts/s3.go | 32 ++ internal/artifacts/store.go | 19 + internal/config/config.go | 15 + internal/config/doc.go | 2 + internal/config/load.go | 8 + internal/config/validate.go | 8 + internal/contracts/artifact.go | 7 + internal/contracts/doc.go | 2 + internal/contracts/session.go | 7 + internal/contracts/transcript.go | 19 + internal/logging/doc.go | 2 + internal/logging/logging.go | 17 + internal/logging/logging_test.go | 31 ++ internal/manifest/doc.go | 2 + internal/manifest/manifest.go | 18 + internal/manifest/status.go | 14 + internal/manifest/store.go | 27 + internal/stage/doc.go | 2 + internal/stage/placeholders.go | 63 +++ internal/stage/stage.go | 21 + 42 files changed, 1486 insertions(+), 1 deletion(-) create mode 100644 architecture.md create mode 100644 cmd/narratio/main.go create mode 100644 go.mod create mode 100644 internal/adapters/analyzer/runner.go create mode 100644 internal/adapters/audita/runner.go create mode 100644 internal/adapters/notify/sender.go create mode 100644 internal/adapters/seriatim/runner.go create mode 100644 internal/adapters/whisperx/client.go create mode 100644 internal/app/app.go create mode 100644 internal/app/commands.go create mode 100644 internal/app/commands_test.go create mode 100644 internal/app/doc.go create mode 100644 internal/app/plan.go create mode 100644 internal/app/resume.go create mode 100644 internal/app/run.go create mode 100644 internal/app/run_stage.go create mode 100644 internal/app/status.go create mode 100644 internal/artifacts/checksum.go create mode 100644 internal/artifacts/doc.go create mode 100644 internal/artifacts/local.go create mode 100644 internal/artifacts/paths.go create mode 100644 internal/artifacts/s3.go create mode 100644 internal/artifacts/store.go create mode 100644 internal/config/config.go create mode 100644 internal/config/doc.go create mode 100644 internal/config/load.go create mode 100644 internal/config/validate.go create mode 100644 internal/contracts/artifact.go create mode 100644 internal/contracts/doc.go create mode 100644 internal/contracts/session.go create mode 100644 internal/contracts/transcript.go create mode 100644 internal/logging/doc.go create mode 100644 internal/logging/logging.go create mode 100644 internal/logging/logging_test.go create mode 100644 internal/manifest/doc.go create mode 100644 internal/manifest/manifest.go create mode 100644 internal/manifest/status.go create mode 100644 internal/manifest/store.go create mode 100644 internal/stage/doc.go create mode 100644 internal/stage/placeholders.go create mode 100644 internal/stage/stage.go diff --git a/README.md b/README.md index 9ece444..f8b4910 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ # narratio -Narratio is a pipeline orchestration tool that turns D&D session audio into useful artifacts like transcripts, campaign logs, and analysis. \ No newline at end of file +`narratio` is a Go-based orchestration application for processing D&D session audio into transcripts and downstream artifacts. + +This repository is currently an **initial scaffold** that establishes: + +- Go module and package boundaries +- Placeholder CLI commands: `run`, `plan`, `status`, `resume`, `run-stage` +- Basic structured logging setup via `log/slog` +- Stub adapter, stage, config, manifest, artifact, and contract packages + +## Current State + +The CLI commands are intentionally placeholders and print `not yet implemented` messages. + +Example: + +```bash +go run ./cmd/narratio plan +``` + +## Non-Goals (Current Pass) + +This scaffold does not yet implement: + +- real config loading/validation behavior +- manifest persistence behavior +- stage execution behavior +- real WhisperX/seriatim/audita/analyzer integrations +- real S3 or notification integrations diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..33a1585 --- /dev/null +++ b/architecture.md @@ -0,0 +1,753 @@ +# D&D Session Orchestrator Architecture + +## Purpose + +This repository implements a Go-based orchestration application called `narratio` for processing recorded Dungeons & Dragons session audio into durable transcripts and downstream analysis artifacts. + +The orchestrator coordinates an existing pipeline consisting of: + +1. Per-speaker FLAC audio recordings from Mumble. +2. A self-hosted WhisperX HTTP transcription service. +3. `seriatim`, a deterministic transcript merger. +4. `audita`, an LLM-backed transcript polisher. +5. A future (not yet implemented, name subject to change) `dnd-session-analyzer`, which generates final D&D session artifacts such as session logs, event logs, meta-analysis, and table-read reports. +6. Long-term storage (which may be local filesystem, S3-compatible object storage, SFTP endpoint, or similar) for all inputs, intermediate outputs, logs, generated configs, and final artifacts. + +The orchestrator is not responsible for implementing transcription, transcript merging, transcript polishing, or artifact generation. Its job is to coordinate those components reliably, maintain durable state, validate stage boundaries, manage local and remote artifacts, and make the pipeline resumable. + +## Design Goals + +The application should be: + +- **Modular**: Each stage and external component should be isolated behind a narrow interface. +- **Composable**: Stages should consume and produce declared artifacts. +- **Resumable**: Long-running workflows should be restartable without repeating completed work. +- **Idempotent**: Re-running the same pipeline should not corrupt or duplicate outputs. +- **Observable**: The orchestrator should produce structured logs, captured subprocess logs, and a durable run manifest. +- **Config-driven**: Most user-adjustable behavior should live in configuration files, not hardcoded logic. +- **Strict at boundaries**: Inputs and outputs should be validated at every major stage boundary. +- **Boring and reliable**: Prefer explicit, maintainable code over clever workflow abstractions. + +The orchestrator should be written in Go. + +## Non-Goals + +The orchestrator should not: + +- Reimplement WhisperX. +- Reimplement `seriatim`. +- Reimplement `audita`. +- Contain D&D-specific prompt logic directly in the core orchestration layer. +- Parse or modify LLM outputs except for high-level validation of expected files or schemas. +- Become a general-purpose distributed workflow engine. +- Require a database for v1. +- Hide important run state only in logs. + +## High-Level Pipeline + +The intended pipeline is: + +```text +prepare + ↓ +transcribe speaker tracks in parallel + ↓ +normalize speaker transcripts + ↓ +merge transcripts with seriatim + ↓ +polish transcript with audita + ↓ +generate D&D artifacts with dnd-session-analyzer + ↓ +archive outputs + ↓ +notify user +``` + +Some stages may be implemented in the initial scaffold only as interfaces or placeholders. + +The initial implementation should create the application framework and contracts without implementing the full real behavior of all stages. + +## Primary Concepts +### Configuration + +Configuration is user-authored and describes how the pipeline should run. + +There should be two primary configuration files: + +pipeline.yml +session.yml + +pipeline.yml contains durable/default pipeline configuration: + +```text +workspace root +S3 bucket/prefix +WhisperX service settings +seriatim binary path and options +audita binary path and options +analyzer binary path and artifact settings +notification settings +concurrency and timeout defaults +``` + +session.yml contains per-session inputs and metadata: + +```text +session ID +campaign ID +session date/title +audio input directory or explicit audio files +speakers.yml +autocorrect.yml +glossary.yml +references to previous-session context +``` + +The application should strictly decode config files and reject unknown fields. Config validation should happen before any stage is run. + +Environment variables may be used for secrets and deployment-specific credentials, but ordinary pipeline behavior should live in config files. + +### Manifest + +The manifest is machine-authored durable run state. + +Each session run should have a manifest.json in the local work directory and eventually mirrored to S3. + +The manifest should track: + +```text +session ID +pipeline version +created/updated timestamps +resolved input files +checksums for important inputs/configs +stage statuses +stage timestamps +stage outputs +captured logs +generated config files +component versions where available +errors +artifact locations +S3 object keys +``` + +Stage statuses should be explicit: + +```text +pending +running +succeeded +failed +skipped +stale +interrupted +``` + +The manifest is the source of truth for resume/skip decisions. + +### Stages + +A stage is a pipeline unit that consumes declared inputs and produces declared outputs. + +Examples: + +```text +prepare +transcribe +normalize +merge +polish +analyze +archive +notify +``` + +Stages should be organized around the lifecycle of a session, not around executable names. + +For example, the merge stage may call seriatim, but the stage should be named for the pipeline operation, not the tool. + +Every stage should: + +- Determine required inputs. +- Check whether expected outputs already exist. +- Decide whether to run, skip, or fail. +- Execute through an adapter or local logic. +- Validate expected outputs. +- Write outputs to the local work directory. +- Record stage status and metadata in the manifest. +- Allow safe rerun with `--force`. + +### Adapters + +Adapters isolate interactions with external components. + +The orchestrator should have thin adapters for: + +- WhisperX HTTP API +- seriatim +- audita +- future dnd-session-analyzer +- storage layer (local filesystem, S3-compatible, SFTP, etc.) +- optional email/notification backend + +Adapters should hide details such as: + +- CLI argument construction +- generated config file format +- HTTP request/response details +- stdout/stderr capture +- process exit handling +- timeout and cancellation behavior + +The stage layer should call adapter methods and should not know implementation details of the underlying external tool. + +### Artifact Store + +The orchestrator should use a local work directory as the primary working area. + +Each stage writes outputs locally first. The orchestrator then validates and archives those outputs to S3. + +The storage layer should expose an abstraction for artifact references and common operations: + +write local artifact +read local artifact +calculate checksum +upload to S3 +record local path and S3 key +validate existence + +For v1, local filesystem + S3 is enough. A database is not required. + +#### Expected Local Work Directory Layout + +Each session should have an isolated local work directory. + +Example: + +```text +work/{session_id}/ + inputs/ + speakers.yml + autocorrect.yml + glossary.yml + session.yml + pipeline.resolved.yml + + audio/ + adam.flac + eric.flac + other-speaker.flac + + transcripts/ + raw/ + adam.json + eric.json + other-speaker.json + normalized/ + adam.json + eric.json + other-speaker.json + merged.json + processed.json + + artifacts/ + event-log.json + session-log.md + meta-analysis.md + table-read.md + + config/ + seriatim.generated.yml + audita.generated.yml + analyzer.event-log.generated.yml + analyzer.session-log.generated.yml + + logs/ + whisperx.adam.log + whisperx.eric.log + seriatim.stdout.log + seriatim.stderr.log + audita.stdout.log + audita.stderr.log + analyzer.session-log.stdout.log + analyzer.session-log.stderr.log + + manifest.json + .lock +``` + +The exact layout may evolve, but the distinction between inputs, audio, transcripts, artifacts, generated config, logs, and manifest should remain. + +#### Expected S3 Layout + +S3 should mirror the conceptual local layout. + +Example: + +```text +sessions/{session_id}/ + inputs/ + audio/ + transcripts/ + raw/ + normalized/ + merged.json + processed.json + artifacts/ + config/ + logs/ + manifest.json +``` + +The orchestrator should be able to upload outputs after each successful stage, not only at the end of the full workflow. + +## Suggested Go Package Layout + +Initial scaffold should use a package layout *similar* to: + +```text +cmd/narratio/ + main.go + +internal/app/ + app.go + plan.go + run.go + commands.go + +internal/config/ + config.go + load.go + validate.go + +internal/manifest/ + manifest.go + store.go + status.go + +internal/stage/ + stage.go + prepare.go + transcribe.go + normalize.go + merge.go + polish.go + analyze.go + archive.go + notify.go + +internal/adapters/ + whisperx/ + client.go + seriatim/ + runner.go + audita/ + runner.go + analyzer/ + runner.go + notify/ + sender.go + +internal/artifacts/ + store.go + local.go + s3.go + paths.go + checksum.go + +internal/contracts/ + transcript.go + session.go + artifact.go + +internal/logging/ + logging.go +``` + +The initial build step should create these packages and core interfaces, but should not fully implement all real external behavior. + +## CLI Shape + +The CLI should eventually support commands like: + +```bash +dnd-orchestrator run --config pipeline.yml --session session.yml +dnd-orchestrator plan --config pipeline.yml --session session.yml +dnd-orchestrator status --session-id 2026-05-03 +dnd-orchestrator resume --session-id 2026-05-03 +dnd-orchestrator run-stage polish --session-id 2026-05-03 --force +``` + +For the initial scaffold, it is acceptable to implement only partial command behavior, but the command structure should anticipate these operations. + +``` +run +``` + +Loads config, validates inputs, creates or loads a manifest, builds a plan, and executes the pipeline. + +``` +plan +``` + +Prints the stages that would run and which stages would be skipped. + +``` +status +``` + +Reads a manifest and prints stage status. + +``` +resume +``` +Continues a previous run from the latest valid manifest state. + +``` +run-stage +``` + +Runs one stage explicitly, with optional --force. + +## Core Stage Interface + +The application should define a narrow stage interface. + +Example shape: + +```go +type Stage interface { + Name() string + Run(ctx context.Context, env *Env, manifest *manifest.Manifest) (*StageResult, error) +} +``` + +The exact types may vary, but the idea should remain: +- a stage has a stable name +- a stage runs with context +- a stage receives shared dependencies through an environment object +- a stage may inspect/update manifest state through controlled methods +- a stage returns declared outputs and metadata + +Suggested supporting type: + +```go +type StageResult struct { + Outputs []artifacts.Ref + Metadata map[string]any +} +``` + +## Application Environment + +The application should pass shared dependencies through an explicit environment object rather than globals. + +Example shape: + +```go +type Env struct { + Config *config.Config + ArtifactStore artifacts.Store + Logger *slog.Logger + + WhisperX whisperx.Client + Seriatim seriatim.Runner + Audita audita.Runner + Analyzer analyzer.Runner + Notifier notify.Sender +} +``` + +Avoid package-level mutable state. + +## Main Loop + +The main application loop should be a simple state machine. + +Conceptual behavior: + +```text +load configuration +validate configuration +create/load manifest +acquire session lock +resolve input files +build execution plan +for each stage: + decide whether stage should run + mark stage running + run stage + validate outputs + archive outputs as appropriate + mark stage succeeded or failed +release lock +``` + +The implementation should prefer clear control flow over a generic workflow engine. + +## Long-Running Stage Handling + +`audita` can run for more than an hour. `seriatim` is very fast (typically less than a second). Each `whisperx` file transcription job can take anywhere from 5 to 30 minutes. + +The orchestrator should therefore treat long-running stages as normal and expected. + +Long-running subprocess stages should: +- receive a generous configurable timeout +- use context.Context for cancellation +- capture stdout and stderr continuously +- write logs to the work directory +- update manifest status before and after execution +- optionally update heartbeat timestamps while running +- preserve temporary work directories on failure +- avoid treating partial output as success + +A stage should write to a temporary output path first, then promote to the final output path only after validation. + +Example: + +```text +processed.json.tmp +processed.json +``` +## Idempotency and Resume Behavior + +Every stage should be designed around the question: + +```text +If this stage runs twice, what happens? +``` + +Preferred behavior: +- If expected output exists and validates, skip the stage. +- If expected output exists but input/config checksum changed, mark stale. +- If `--force` is supplied, rerun and replace output atomically. +- If a previous run failed, preserve logs and temporary files for inspection. +- If rerunning, do not delete unrelated successful outputs. + +The manifest should allow the application to resume after interruption without rerunning completed expensive stages. + +## Locking + +The orchestrator should prevent two processes from operating on the same session work directory at the same time. + +For v1, a lock file in the work directory is acceptable: + +```text +work/{session_id}/.lock +``` + +The lock should be acquired before mutating manifest or stage outputs. + +## Logging + +The orchestrator should use structured logs, preferably Go's log/slog. + +Subprocess logs should be captured separately: + +```text +orchestrator structured log +stdout log per external tool invocation +stderr log per external tool invocation +``` + +Do not combine all component logs into one global text file. + +## External Component Boundaries +### WhisperX + +The `whisperx` adapter should handle: + +- HTTP submission of audio files +- polling or waiting for completion, depending on API shape +- timeout and retry policy +- returning raw transcript JSON +- per-speaker parallelism with bounded concurrency + +The orchestrator should not assume WhisperX internals beyond the adapter contract. + +### Seriatim + +`seriatim` is a deterministic transcript merger. + +The `seriatim` adapter should handle: + +- generating Seriatim config +- invoking the binary +- passing input transcript paths +- passing output path +- capturing logs +- returning merged transcript path + +The orchestrator should not implement `seriatim` merge logic. + +### Audita + +`audita` is an LLM-backed transcript polisher. + +The `audita` adapter should handle: + +- generating `audita` config +- invoking the binary or service +- passing merged transcript path +- passing glossary/autocorrect paths +- capturing logs +- preserving Audita work/checkpoint files +- returning processed transcript path + +The orchestrator should not implement LLM correction logic. + +### D&D Session Analyzer + +The analyzer is a future component. + +The initial orchestrator should include interfaces/placeholders for it, but should not need a complete implementation. + +The analyzer adapter should eventually handle: + +- artifact type selection +- generated analyzer config +- prompt template references +- processed transcript path +- prior session context references +- output artifact paths +- structured validation where appropriate + +The orchestrator should not contain prompt-specific D&D logic. The analyzer should own artifact-specific prompt behavior. + +## Contracts and Schema Versioning + +Core data artifacts should include schema versions where practical. + +Important contracts include: + +- SpeakerTranscript (`whisperx` output) +- CanonicalTranscript (`seriatim` output) +- ProcessedTranscript (`autida` output) +- ArtifactResult +- SessionManifest + +The orchestrator may not need to deeply understand every field in every artifact, but it should validate enough to know that expected outputs were produced and are parseable. + +Schema versions should appear in JSON artifacts where possible: + +```json +{ + "schema": "processed_transcript.v1", + "session_id": "2026-05-03", + "segments": [] +} +``` + +### Validation Boundaries + +Validate at each boundary: +```text +input config is valid +audio files exist +speakers.yml maps expected filenames +WhisperX output exists and is parseable +normalized speaker transcripts exist +Seriatim merged transcript exists and is parseable +Audita processed transcript exists and is parseable +analyzer artifacts exist in expected formats +``` + +For the initial scaffold, validation methods may be stubs or simple parse/existence checks, but the architecture should make validation a first-class concern. + +## Testing Strategy + +The orchestrator should be testable without running `whisperx`, `seriatim`, `audita`, or LLMs. + +Use fake adapters for tests: +- fake `whisperx` returns canned transcript JSON +- fake `seriatim` writes a known merged transcript +- fake `audita` writes a known processed transcript +- fake `analyzer` writes fixed artifacts +- fake artifact store writes to a temp directory + +Tests should cover: +- config loading and validation +- plan generation +- manifest creation/update +- stage skip behavior +- force rerun behavior +- failure handling +- resume behavior +- subprocess adapter construction where practical + +### Testing Non-Goals +Tests must **not** do any of the following: +- include hard-coded assertions of specific default configuration values (although a test may validate that a default value is defined and has the correct type, it may not require it to equal any particualar value) + +## Initial Implementation Scope + +The first implementation pass should create the application framework and interfaces, not the full working pipeline. + +Implement: +``` +Go module setup +CLI skeleton +config structs and loader +manifest structs and local manifest store +artifact reference/store interfaces +stage interface +app environment object +plan/main-loop skeleton +placeholder stages +adapter interfaces +fake/no-op adapter implementations where helpful +basic structured logging +basic tests for config/manifest/stage planning +``` + +Do not yet implement: +``` +real WhisperX HTTP API behavior +real Seriatim subprocess execution +real Audita subprocess execution +real S3 upload/download +real email notification +full transcript schema validation +full analyzer behavior +D&D prompt logic +``` + +The initial code should compile, include clear TODOs, and establish durable package boundaries. + +## Engineering Preferences + +Use idiomatic Go. + +Prefer: +``` +context.Context +log/slog +explicit interfaces +small packages +table-driven tests +strict config decoding +clear errors with context +local filesystem abstractions where useful +standard library where sufficient +``` + +Avoid: +``` +global mutable state +reflection-heavy frameworks unless justified +premature generic DAG engines +hidden side effects in constructors +mixing orchestration logic with adapter-specific implementation details +writing business logic directly in main.go +``` + +### Architectural Invariant + +The most important invariant of the application is: + +Every stage consumes declared artifacts, produces declared artifacts, validates them, records provenance, and can be safely skipped or rerun. + +All design and implementation choices should support that invariant. \ No newline at end of file diff --git a/cmd/narratio/main.go b/cmd/narratio/main.go new file mode 100644 index 0000000..90769cd --- /dev/null +++ b/cmd/narratio/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "gitea.maximumdirect.net/eric/narratio/internal/app" +) + +func main() { + os.Exit(app.Execute(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d4ba092 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.maximumdirect.net/eric/narratio + +go 1.25.0 diff --git a/internal/adapters/analyzer/runner.go b/internal/adapters/analyzer/runner.go new file mode 100644 index 0000000..a8a1fc7 --- /dev/null +++ b/internal/adapters/analyzer/runner.go @@ -0,0 +1,20 @@ +// Package analyzer declares the adapter contract for artifact analysis generation. +package analyzer + +import "context" + +// Runner is a placeholder adapter interface for session artifact generation. +type Runner interface { + Run(ctx context.Context, req AnalysisRequest) (AnalysisResult, error) +} + +// AnalysisRequest is a placeholder analyzer input. +type AnalysisRequest struct { + ProcessedTranscriptPath string + OutputDir string +} + +// AnalysisResult is a placeholder analyzer output. +type AnalysisResult struct { + ArtifactPaths []string +} diff --git a/internal/adapters/audita/runner.go b/internal/adapters/audita/runner.go new file mode 100644 index 0000000..e6e3f97 --- /dev/null +++ b/internal/adapters/audita/runner.go @@ -0,0 +1,20 @@ +// Package audita declares the adapter contract for transcript polishing. +package audita + +import "context" + +// Runner is a placeholder adapter interface for audita invocation. +type Runner interface { + Run(ctx context.Context, req PolishRequest) (PolishResult, error) +} + +// PolishRequest is a placeholder polish input. +type PolishRequest struct { + MergedTranscriptPath string + OutputPath string +} + +// PolishResult is a placeholder polish output. +type PolishResult struct { + ProcessedPath string +} diff --git a/internal/adapters/notify/sender.go b/internal/adapters/notify/sender.go new file mode 100644 index 0000000..89d28e2 --- /dev/null +++ b/internal/adapters/notify/sender.go @@ -0,0 +1,15 @@ +// Package notify declares the adapter contract for run notifications. +package notify + +import "context" + +// Sender is a placeholder adapter interface for notifications. +type Sender interface { + Send(ctx context.Context, msg Message) error +} + +// Message is a placeholder notification payload. +type Message struct { + Subject string + Body string +} diff --git a/internal/adapters/seriatim/runner.go b/internal/adapters/seriatim/runner.go new file mode 100644 index 0000000..92883a5 --- /dev/null +++ b/internal/adapters/seriatim/runner.go @@ -0,0 +1,20 @@ +// Package seriatim declares the adapter contract for transcript merge execution. +package seriatim + +import "context" + +// Runner is a placeholder adapter interface for seriatim invocation. +type Runner interface { + Run(ctx context.Context, req MergeRequest) (MergeResult, error) +} + +// MergeRequest is a placeholder merge input. +type MergeRequest struct { + TranscriptPaths []string + OutputPath string +} + +// MergeResult is a placeholder merge output. +type MergeResult struct { + MergedPath string +} diff --git a/internal/adapters/whisperx/client.go b/internal/adapters/whisperx/client.go new file mode 100644 index 0000000..8916911 --- /dev/null +++ b/internal/adapters/whisperx/client.go @@ -0,0 +1,20 @@ +// Package whisperx declares the adapter contract for WhisperX transcription. +package whisperx + +import "context" + +// Client is a placeholder adapter interface for WhisperX interactions. +type Client interface { + Transcribe(ctx context.Context, req TranscriptionRequest) (TranscriptionResult, error) +} + +// TranscriptionRequest is a placeholder transcription input. +type TranscriptionRequest struct { + Speaker string + AudioPath string +} + +// TranscriptionResult is a placeholder transcription output. +type TranscriptionResult struct { + TranscriptPath string +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..239b9d8 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,26 @@ +package app + +import ( + "log/slog" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/audita" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/notify" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +// Env is the shared dependency container passed to orchestrator components. +type Env struct { + Config *config.Config + ArtifactStore artifacts.Store + Logger *slog.Logger + + WhisperX whisperx.Client + Seriatim seriatim.Runner + Audita audita.Runner + Analyzer analyzer.Runner + Notifier notify.Sender +} diff --git a/internal/app/commands.go b/internal/app/commands.go new file mode 100644 index 0000000..f0bb98a --- /dev/null +++ b/internal/app/commands.go @@ -0,0 +1,56 @@ +package app + +import ( + "context" + "fmt" + "io" + "strings" +) + +var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage"} + +// Execute dispatches CLI commands and returns a process exit code. +func Execute(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stderr) + return 1 + } + + ctx := context.Background() + cmd := args[0] + cmdArgs := args[1:] + + var err error + switch cmd { + case "run": + err = Run(ctx, cmdArgs, stdout) + case "plan": + err = Plan(ctx, cmdArgs, stdout) + case "status": + err = Status(ctx, cmdArgs, stdout) + case "resume": + err = Resume(ctx, cmdArgs, stdout) + case "run-stage": + err = RunStage(ctx, cmdArgs, stdout) + default: + fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd) + printUsage(stderr) + return 1 + } + + if err != nil { + fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + + return 0 +} + +func printUsage(w io.Writer) { + 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 +} diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go new file mode 100644 index 0000000..0e9417b --- /dev/null +++ b/internal/app/commands_test.go @@ -0,0 +1,75 @@ +package app + +import ( + "bytes" + "strings" + "testing" +) + +func TestExecuteValidCommands(t *testing.T) { + cases := []struct { + name string + args []string + wantOut string + }{ + {name: "run", args: []string{"run"}, wantOut: "narratio run: not yet implemented"}, + {name: "plan", args: []string{"plan"}, wantOut: "narratio plan: not yet implemented"}, + {name: "status", args: []string{"status"}, wantOut: "narratio status: not yet implemented"}, + {name: "resume", args: []string{"resume"}, wantOut: "narratio resume: not yet implemented"}, + {name: "run-stage", args: []string{"run-stage", "polish"}, wantOut: "narratio run-stage: not yet implemented"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Execute(tc.args, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0", code) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + if !strings.Contains(stdout.String(), tc.wantOut) { + t.Fatalf("stdout = %q, want to contain %q", stdout.String(), tc.wantOut) + } + }) + } +} + +func TestExecuteInvalidCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Execute([]string{"bogus"}, &stdout, &stderr) + if code == 0 { + t.Fatalf("exit code = 0, want non-zero") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + out := stderr.String() + if !strings.Contains(out, "unknown command") { + t.Fatalf("stderr = %q, want unknown command message", out) + } + if !strings.Contains(out, "Usage: narratio") { + t.Fatalf("stderr = %q, want usage message", out) + } +} + +func TestExecuteMissingCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Execute(nil, &stdout, &stderr) + if code == 0 { + t.Fatalf("exit code = 0, want non-zero") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "Usage: narratio") { + t.Fatalf("stderr = %q, want usage message", stderr.String()) + } +} diff --git a/internal/app/doc.go b/internal/app/doc.go new file mode 100644 index 0000000..812757f --- /dev/null +++ b/internal/app/doc.go @@ -0,0 +1,3 @@ +// Package app contains CLI command wiring and top-level application orchestration +// primitives for narratio. +package app diff --git a/internal/app/plan.go b/internal/app/plan.go new file mode 100644 index 0000000..37909fe --- /dev/null +++ b/internal/app/plan.go @@ -0,0 +1,11 @@ +package app + +import ( + "context" + "io" +) + +// Plan is a placeholder for future stage planning behavior. +func Plan(_ context.Context, _ []string, out io.Writer) error { + return placeholder(out, "plan") +} diff --git a/internal/app/resume.go b/internal/app/resume.go new file mode 100644 index 0000000..27ae85a --- /dev/null +++ b/internal/app/resume.go @@ -0,0 +1,11 @@ +package app + +import ( + "context" + "io" +) + +// Resume is a placeholder for future resume-from-manifest behavior. +func Resume(_ context.Context, _ []string, out io.Writer) error { + return placeholder(out, "resume") +} diff --git a/internal/app/run.go b/internal/app/run.go new file mode 100644 index 0000000..99175a5 --- /dev/null +++ b/internal/app/run.go @@ -0,0 +1,11 @@ +package app + +import ( + "context" + "io" +) + +// Run is a placeholder for the future end-to-end pipeline execution command. +func Run(_ context.Context, _ []string, out io.Writer) error { + return placeholder(out, "run") +} diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go new file mode 100644 index 0000000..9235ecd --- /dev/null +++ b/internal/app/run_stage.go @@ -0,0 +1,11 @@ +package app + +import ( + "context" + "io" +) + +// RunStage is a placeholder for future single-stage execution behavior. +func RunStage(_ context.Context, _ []string, out io.Writer) error { + return placeholder(out, "run-stage") +} diff --git a/internal/app/status.go b/internal/app/status.go new file mode 100644 index 0000000..3308f79 --- /dev/null +++ b/internal/app/status.go @@ -0,0 +1,11 @@ +package app + +import ( + "context" + "io" +) + +// Status is a placeholder for future manifest status inspection behavior. +func Status(_ context.Context, _ []string, out io.Writer) error { + return placeholder(out, "status") +} diff --git a/internal/artifacts/checksum.go b/internal/artifacts/checksum.go new file mode 100644 index 0000000..9e479b2 --- /dev/null +++ b/internal/artifacts/checksum.go @@ -0,0 +1,24 @@ +package artifacts + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "os" +) + +// SHA256File returns the SHA-256 hex digest of a file. +func SHA256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/artifacts/doc.go b/internal/artifacts/doc.go new file mode 100644 index 0000000..e79b9bd --- /dev/null +++ b/internal/artifacts/doc.go @@ -0,0 +1,2 @@ +// Package artifacts defines artifact references and storage contracts. +package artifacts diff --git a/internal/artifacts/local.go b/internal/artifacts/local.go new file mode 100644 index 0000000..76dcf63 --- /dev/null +++ b/internal/artifacts/local.go @@ -0,0 +1,31 @@ +package artifacts + +import ( + "context" + "fmt" +) + +// LocalStore is a placeholder local filesystem artifact store. +type LocalStore struct { + RootDir string +} + +// WriteLocal returns a not-yet-implemented error in the scaffold. +func (s *LocalStore) WriteLocal(_ context.Context, _ Ref, _ []byte) error { + return fmt.Errorf("artifacts local write: not yet implemented") +} + +// ReadLocal returns a not-yet-implemented error in the scaffold. +func (s *LocalStore) ReadLocal(_ context.Context, _ Ref) ([]byte, error) { + return nil, fmt.Errorf("artifacts local read: not yet implemented") +} + +// ExistsLocal returns a not-yet-implemented error in the scaffold. +func (s *LocalStore) ExistsLocal(_ context.Context, _ Ref) (bool, error) { + return false, fmt.Errorf("artifacts local exists: not yet implemented") +} + +// Upload returns a not-yet-implemented error in the scaffold. +func (s *LocalStore) Upload(_ context.Context, _ Ref) (string, error) { + return "", fmt.Errorf("artifacts local upload: not yet implemented") +} diff --git a/internal/artifacts/paths.go b/internal/artifacts/paths.go new file mode 100644 index 0000000..e575440 --- /dev/null +++ b/internal/artifacts/paths.go @@ -0,0 +1,8 @@ +package artifacts + +import "path/filepath" + +// SessionWorkDir returns the work directory for one session. +func SessionWorkDir(rootDir, sessionID string) string { + return filepath.Join(rootDir, "work", sessionID) +} diff --git a/internal/artifacts/s3.go b/internal/artifacts/s3.go new file mode 100644 index 0000000..8353596 --- /dev/null +++ b/internal/artifacts/s3.go @@ -0,0 +1,32 @@ +package artifacts + +import ( + "context" + "fmt" +) + +// S3Store is a placeholder S3-compatible artifact store. +type S3Store struct { + Bucket string + Prefix string +} + +// WriteLocal returns a not-yet-implemented error in the scaffold. +func (s *S3Store) WriteLocal(_ context.Context, _ Ref, _ []byte) error { + return fmt.Errorf("artifacts s3 write local: not yet implemented") +} + +// ReadLocal returns a not-yet-implemented error in the scaffold. +func (s *S3Store) ReadLocal(_ context.Context, _ Ref) ([]byte, error) { + return nil, fmt.Errorf("artifacts s3 read local: not yet implemented") +} + +// ExistsLocal returns a not-yet-implemented error in the scaffold. +func (s *S3Store) ExistsLocal(_ context.Context, _ Ref) (bool, error) { + return false, fmt.Errorf("artifacts s3 exists local: not yet implemented") +} + +// Upload returns a not-yet-implemented error in the scaffold. +func (s *S3Store) Upload(_ context.Context, _ Ref) (string, error) { + return "", fmt.Errorf("artifacts s3 upload: not yet implemented") +} diff --git a/internal/artifacts/store.go b/internal/artifacts/store.go new file mode 100644 index 0000000..c5396d3 --- /dev/null +++ b/internal/artifacts/store.go @@ -0,0 +1,19 @@ +package artifacts + +import "context" + +// Ref identifies a pipeline artifact and optional remote location. +type Ref struct { + Kind string + LocalPath string + RemoteKey string + Checksum string +} + +// Store is a placeholder artifact storage abstraction. +type Store interface { + WriteLocal(ctx context.Context, ref Ref, data []byte) error + ReadLocal(ctx context.Context, ref Ref) ([]byte, error) + ExistsLocal(ctx context.Context, ref Ref) (bool, error) + Upload(ctx context.Context, ref Ref) (string, error) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5a356c5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,15 @@ +package config + +// Config contains loaded pipeline and session configuration. +type Config struct { + Pipeline *Pipeline + Session *Session +} + +// Pipeline represents durable pipeline-level settings. +type Pipeline struct{} + +// Session represents per-session inputs and metadata. +type Session struct { + SessionID string +} diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..cc0eac1 --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,2 @@ +// Package config contains pipeline and session configuration contracts. +package config diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..0e64d21 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,8 @@ +package config + +import "fmt" + +// Load is a placeholder for strict pipeline/session config decoding. +func Load(_ string, _ string) (*Config, error) { + return nil, fmt.Errorf("config load: not yet implemented") +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..9bbb567 --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,8 @@ +package config + +import "fmt" + +// Validate is a placeholder for configuration validation logic. +func Validate(_ *Config) error { + return fmt.Errorf("config validation: not yet implemented") +} diff --git a/internal/contracts/artifact.go b/internal/contracts/artifact.go new file mode 100644 index 0000000..a2966b6 --- /dev/null +++ b/internal/contracts/artifact.go @@ -0,0 +1,7 @@ +package contracts + +// ArtifactResult is a placeholder generated artifact contract. +type ArtifactResult struct { + Schema string `json:"schema"` + Path string `json:"path"` +} diff --git a/internal/contracts/doc.go b/internal/contracts/doc.go new file mode 100644 index 0000000..53f58f8 --- /dev/null +++ b/internal/contracts/doc.go @@ -0,0 +1,2 @@ +// Package contracts defines shared high-level artifact contracts. +package contracts diff --git a/internal/contracts/session.go b/internal/contracts/session.go new file mode 100644 index 0000000..5c3bb46 --- /dev/null +++ b/internal/contracts/session.go @@ -0,0 +1,7 @@ +package contracts + +// SessionManifest is a placeholder durable session-run contract. +type SessionManifest struct { + Schema string `json:"schema"` + SessionID string `json:"session_id"` +} diff --git a/internal/contracts/transcript.go b/internal/contracts/transcript.go new file mode 100644 index 0000000..9c33036 --- /dev/null +++ b/internal/contracts/transcript.go @@ -0,0 +1,19 @@ +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"` +} diff --git a/internal/logging/doc.go b/internal/logging/doc.go new file mode 100644 index 0000000..e808195 --- /dev/null +++ b/internal/logging/doc.go @@ -0,0 +1,2 @@ +// Package logging provides structured logger construction for narratio. +package logging diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..47dfd6e --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,17 @@ +package logging + +import ( + "io" + "log/slog" + "os" +) + +// NewLogger creates a text slog logger. +func NewLogger(out io.Writer, level slog.Level) *slog.Logger { + if out == nil { + out = os.Stderr + } + + handler := slog.NewTextHandler(out, &slog.HandlerOptions{Level: level}) + return slog.New(handler) +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..2e3163c --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,31 @@ +package logging + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestNewLoggerWritesOutput(t *testing.T) { + var buf bytes.Buffer + logger := NewLogger(&buf, slog.LevelInfo) + + if logger == nil { + t.Fatal("logger is nil") + } + + logger.Info("hello", "component", "test") + out := buf.String() + if !strings.Contains(out, "hello") { + t.Fatalf("output = %q, want to contain message", out) + } +} + +func TestNewLoggerNilWriter(t *testing.T) { + logger := NewLogger(nil, slog.LevelInfo) + if logger == nil { + t.Fatal("logger is nil") + } + logger.Info("should not panic") +} diff --git a/internal/manifest/doc.go b/internal/manifest/doc.go new file mode 100644 index 0000000..8d9c613 --- /dev/null +++ b/internal/manifest/doc.go @@ -0,0 +1,2 @@ +// Package manifest defines durable run-state tracking for narratio sessions. +package manifest diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..35c6f83 --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,18 @@ +package manifest + +import "time" + +// StageState tracks status and metadata for one stage execution. +type StageState struct { + Status StageStatus + UpdatedAt time.Time + Error string +} + +// Manifest is the durable state record for a session run. +type Manifest struct { + SessionID string + CreatedAt time.Time + UpdatedAt time.Time + Stages map[string]StageState +} diff --git a/internal/manifest/status.go b/internal/manifest/status.go new file mode 100644 index 0000000..1404fd2 --- /dev/null +++ b/internal/manifest/status.go @@ -0,0 +1,14 @@ +package manifest + +// StageStatus is the lifecycle state of a pipeline stage. +type StageStatus string + +const ( + StatusPending StageStatus = "pending" + StatusRunning StageStatus = "running" + StatusSucceeded StageStatus = "succeeded" + StatusFailed StageStatus = "failed" + StatusSkipped StageStatus = "skipped" + StatusStale StageStatus = "stale" + StatusInterrupted StageStatus = "interrupted" +) diff --git a/internal/manifest/store.go b/internal/manifest/store.go new file mode 100644 index 0000000..d09af51 --- /dev/null +++ b/internal/manifest/store.go @@ -0,0 +1,27 @@ +package manifest + +import ( + "context" + "fmt" +) + +// Store is a placeholder interface for manifest persistence. +type Store interface { + Load(ctx context.Context, sessionID string) (*Manifest, error) + Save(ctx context.Context, m *Manifest) error +} + +// LocalStore is a placeholder local-filesystem manifest store. +type LocalStore struct { + RootDir string +} + +// Load returns a not-yet-implemented error in the scaffold. +func (s *LocalStore) Load(_ context.Context, _ string) (*Manifest, error) { + return nil, fmt.Errorf("manifest local load: not yet implemented") +} + +// Save returns a not-yet-implemented error in the scaffold. +func (s *LocalStore) Save(_ context.Context, _ *Manifest) error { + return fmt.Errorf("manifest local save: not yet implemented") +} diff --git a/internal/stage/doc.go b/internal/stage/doc.go new file mode 100644 index 0000000..032b709 --- /dev/null +++ b/internal/stage/doc.go @@ -0,0 +1,2 @@ +// Package stage defines pipeline stage contracts and placeholder stage types. +package stage diff --git a/internal/stage/placeholders.go b/internal/stage/placeholders.go new file mode 100644 index 0000000..3c64a44 --- /dev/null +++ b/internal/stage/placeholders.go @@ -0,0 +1,63 @@ +package stage + +import ( + "context" + "fmt" + + "gitea.maximumdirect.net/eric/narratio/internal/app" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func notImplemented(name string) error { + return fmt.Errorf("stage %q: not yet implemented", name) +} + +type Prepare struct{} + +type Transcribe struct{} + +type Normalize struct{} + +type Merge struct{} + +type Polish struct{} + +type Analyze struct{} + +type Archive struct{} + +type Notify struct{} + +func (Prepare) Name() string { return "prepare" } +func (Transcribe) Name() string { return "transcribe" } +func (Normalize) Name() string { return "normalize" } +func (Merge) Name() string { return "merge" } +func (Polish) Name() string { return "polish" } +func (Analyze) Name() string { return "analyze" } +func (Archive) Name() string { return "archive" } +func (Notify) Name() string { return "notify" } + +func (s Prepare) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) { + return nil, notImplemented(s.Name()) +} +func (s Transcribe) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) { + return nil, notImplemented(s.Name()) +} +func (s Normalize) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) { + return nil, notImplemented(s.Name()) +} +func (s Merge) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) { + return nil, notImplemented(s.Name()) +} +func (s Polish) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) { + return nil, notImplemented(s.Name()) +} +func (s Analyze) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) { + return nil, notImplemented(s.Name()) +} +func (s Archive) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) { + return nil, notImplemented(s.Name()) +} +func (s Notify) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) { + return nil, notImplemented(s.Name()) +} diff --git a/internal/stage/stage.go b/internal/stage/stage.go new file mode 100644 index 0000000..28baab4 --- /dev/null +++ b/internal/stage/stage.go @@ -0,0 +1,21 @@ +package stage + +import ( + "context" + + "gitea.maximumdirect.net/eric/narratio/internal/app" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +// Stage is the pipeline unit contract. +type Stage interface { + Name() string + Run(ctx context.Context, env *app.Env, m *manifest.Manifest) (*Result, error) +} + +// Result is the declared output of a stage execution. +type Result struct { + Outputs []artifacts.Ref + Metadata map[string]any +}