Active development has moved to the consolidated Narrator Toolbox monorepo:
This repository is retained as source history/provenance for the imported module.
A local, multi-pass manuscript analysis stack for fiction manuscripts using Ollama and custom Modelfiles.
This project is designed for audiobook preparation and editorial review. It processes long-form fiction conservatively and structurally, with a focus on:
- chapter and part extraction
- narrative and dialogue detection
- entity extraction
- character dossier generation
The stack is built to run locally on your own hardware, using Ollama, Qwen, custom Modelfiles, PDF-to-text preprocessing, chunking, and a Python orchestration pipeline.
The currently supported pipeline entry points are:
python manuscriptprep_ingest.pypython manuscriptprep_orchestrator_tui_refactored.pypython manuscriptprep_merger.pypython manuscriptprep_resolver.pypython manuscriptprep_pdf_report.pypython manuscriptprep_gateway_api.pypython manuscriptprep_worker.py
The canonical orchestrator is:
manuscriptprep_orchestrator_tui_refactored.py
Notes:
manuscriptprep_orchestrator_tui.pyis a legacy orchestrator kept for reference and comparison.scripts/manuscriptprep_orchestrator_tui_configured.pyis a config wiring scaffold, not the supported production runner.manuscriptprep_gateway_api.pyis the API control plane. It creates jobs, requeues jobs, and exposes persisted job artifacts and status through either a file-backed store or PostgreSQL.manuscriptprep_worker.pyis the execution worker. It claims queued jobs and runs them outside the gateway process.manuscriptprep_orchestrator_tui_refactored.pycan now also run in gateway-client mode with--gateway-urlwhile preserving the existing direct local orchestration mode.
Operational endpoints:
GET /healthGET /readyGET /v1/system/status
Authentication:
GET /healthandGET /readystay unauthenticated/v1/*routes can require bearer-token auth withAuthorization: Bearer <token>orX-API-Token- the gateway now also exposes user-facing auth endpoints:
POST /v1/auth/registerPOST /v1/auth/loginGET /v1/auth/me
- the gateway also exposes first-run auth bootstrap endpoints:
GET /v1/auth/setup-statePOST /v1/auth/bootstrap-admin
- the compose stack still boots a default admin token for development via
MANUSCRIPTPREP_BOOTSTRAP_ADMIN_TOKEN
Managed records:
POST /v1/manuscriptsandGET /v1/manuscriptsPOST /v1/config-profilesandGET /v1/config-profiles- jobs can now reference
manuscript_idandconfig_profile_idinstead of repeating source/config paths on every request
Artifact management:
GET /v1/jobs/{job_id}/artifactsreturns the persisted artifact index for a job- artifacts produced by workers are now enriched with
sha256,bytes, andstorage_backendmetadata
Web UI:
GET /uinow serves a user-facing pipeline studio with a login/register landing page- on first run, the UI shows an out-of-box setup step that asks you to set the admin password before normal user registration is enabled
- authenticated users land in a manuscript-first workspace with profile controls in the top-right header
- admin users still land in the normal user workspace first, and can open an admin interface from the profile menu when they need platform-level visibility
- the workspace now supports manuscript upload, managed manuscript registration, config-profile selection, stage-by-stage triggering, full-pipeline runs, and live job/artifact status
- the workspace is now split into three user-facing pages:
ManuscriptsforYour Manuscripts, the active manuscript overview, runtime profile, andWorkspace StatusPipelinefor the stage workflow panelsJobsforJob TimelineandSelected Job
- the
Your Manuscriptslist now shows a document-type pill for each manuscript, and thePipelinepage has its own manuscript selector so users can switch context without leaving the workflow view - the
Upload Manuscriptpanel now lives on theManuscriptspage aboveYour Manuscripts, so new users can start from the manuscript workspace while advanced users can jump straight to the pipeline page - the pipeline stages are now numbered
1-5, since upload is handled on theManuscriptspage rather than inside the pipeline stage flow - the pipeline workspace is intentionally simplified so the stage cards are the only stage-summary surface; there is no separate description-card layer above them
- the pipeline workflow now behaves like an accordion, and manual expand/collapse choices are preserved across background refreshes instead of snapping back
- the main workspace layout is now arranged as:
- top row:
Your Manuscriptsbeside the selected manuscript overview - middle row:
Pipeline Workspacein a wider left column, withWorkspace StatusandJob Timelinein the right rail - bottom row: a full-width
Selected Jobpanel
- top row:
- manuscript save/remove controls now live inline in the
Your Manuscriptslist, with save disabled until a title or slug is changed and remove protected by a confirmation prompt - manuscript actions now include
Archiveexport/import support plus two delete modes: record-only removal and full manuscript-data deletion with stronger confirmation - the workflow steps are now explicitly numbered
1-5so progress through the pipeline is easier to scan - the main dashboard panels now summarize config, manuscript, system, job, and artifact state in human-readable text instead of raw JSON dumps
- manuscripts now show latest ingest status and ingest completion time, and can be renamed or removed from the UI
- the layout is now organized around multiple managed manuscripts, with stage controls and job history scoped to the selected manuscript
- successful ingest runs now expose a dedicated manuscript-scoped results page for classification, extraction metadata, the full raw text, the full cleaned text, and the full chunk list, preloaded from the current UI session for reliable display
- the ingest results page now uses a tabbed detail view for classification, chunk summary, extraction metadata, cleaning metadata, chunk index, raw text, and clean text, with compact artifact download buttons above it
- the selected-job panel now exposes direct downloads for later-stage artifacts such as orchestrator logs, merged/resolved JSON, and the final report PDF
- the pipeline stage cards for
Merge,Resolution, andReportnow expose direct stage-scoped detail/download actions so users can inspect merged analysis, resolved analysis, and the final report without leaving the workflow page - running categorisation and analysis jobs now expose live chunk progress in the UI, including current chunk, pass, model, step, retry/timeout state, and recent progress events
- the categorisation and analysis stage card now also shows live throughput in
tok/swhen the orchestrator reports it - stage cards now expose a single stateful
Run/Pauseprimary control, plusStopandDetail, so the active action stays compact and easier to scan - categorisation and analysis jobs now expose a
Detailviewer that opens processed chunks in a modal and summarizes structure, dialogue, entities, dossiers, and per-chunk timing without dropping users into raw JSON - categorisation and analysis runs now resume after interruption by skipping chunks that already have complete per-chunk outputs on disk and continuing from the next unfinished chunk
- live chunk numbering now follows the actual chunk id, so
chunk_007is shown asChunk 8/...rather than being inferred from completed-count math - jobs now show their full Job ID in the timeline and selected-job panel, and queued/running jobs can be cancelled from the UI while the worker continuously performs stale-job housekeeping between polls
- the job timeline now uses colored status pills so queued, running, paused, cancelled, and failed jobs are easier to scan at a glance
- the
Ingeststage now shows the currently selected manuscript and includes aChoose Manuscriptpopup so users can switch the active manuscript from inside the workflow - manuscript upload now auto-detects and accepts
PDF,DOCX,EPUB,ODT,MOBI,AZW,AZW3, andTXTfiles during upload - ingest now extracts text directly from
TXT,DOCX,EPUB,ODT,MOBI,AZW, andAZW3sources instead of assuming every manuscript is a PDF - MOBI-style ebook ingest now decodes PalmDOC text records directly before converting HTML to text, which avoids the binary/container corruption that the earlier heuristic extractor could leak into raw and clean text
- stage cards now prefer active work first, then the latest successful run, and otherwise fall back to
not-started; cancelled and failed jobs still remain visible in the timeline for auditability - stage cards show pipeline substeps and the configured model names where applicable
- the compose stack now mounts a shared runtime volume for uploaded manuscripts and pipeline scratch data so gateway and worker can both access user uploads
- the compose stack now runs a dedicated
ingest-workeralongside the general worker so ingest jobs can run in parallel with longer analysis jobs instead of waiting behind them in the same queue lane - the compose images now include the PDF/OCR toolchain (
pdftotext,pdfinfo,ocrmypdf,tesseract,ghostscript) needed for ingest
Before merging code changes, run the full test matrix:
bash scripts/run_test_matrix.shTest suites currently required for code changes:
unitregressionsmokeintegration
Additional testing guidance lives in docs/development/testing.md.
Release/UAT guidance lives in docs/development/uat_checklist.md.
Release notes for the current baseline live in docs/releases/v1.0.0.md.
ManuscriptPrep turns a source manuscript PDF into structured JSON outputs through a staged pipeline:
- Extract text from PDF
- Clean the raw text
- Split the text into chunks
- Run a multi-pass Ollama pipeline
- Save per-chunk structured JSON outputs
The key design decision is to split the task into multiple smaller passes instead of asking one model to do everything in one go. This improves:
- grounding
- consistency
- JSON reliability
- resistance to hallucination
- easier downstream merging
Ollama runs local language models and lets you create custom models with Modelfile.
In this project, Ollama is used to host several custom models:
manuscriptprep-structuremanuscriptprep-dialoguemanuscriptprep-entitiesmanuscriptprep-dossiers
These are all based on the same foundation model, but each has a different system prompt and task specialization.
Ollama's inference backend depends on the host platform and hardware:
- NVIDIA GPUs: CUDA on Linux and Windows
- AMD GPUs: ROCm on supported Linux and Windows builds, with Vulkan as an additional path
- Apple Silicon: Metal on macOS
- Intel GPUs: Vulkan where the driver stack supports it
- CPU-only fallback: available on hosts without supported acceleration, but slower
Ollama documents Vulkan as an experimental path, so treat it as a compatibility option rather than the primary production target.
For the supported host and GPU combinations, see the system requirements page.
The current recommended base model is:
qwen3:8b-q4_K_M
Why this model?
- fast enough for local chunk-by-chunk processing
- strong instruction following
- good structured output behavior
- performs well on 12 GB class GPUs
- more stable than larger reasoning-heavy models for this workflow
A Modelfile in Ollama defines:
- the base model
- sampling parameters
- the
SYSTEMprompt
In this project, each pass has its own Modelfile so that each model performs one narrow task only.
That is much more reliable than a single monolithic prompt.
Open WebUI is optional but useful for interactive experimentation.
It gives you:
- a browser UI for chatting with your local Ollama models
- easy testing of prompts and model behavior
- a way to compare model variants before automating them
Open WebUI is not required for the pipeline scripts, but it is very useful during development and debugging.
LLMs do not work well on raw PDF artifacts such as:
- page headers
- page footers
- repeated titles
- pagination markers
- OCR noise
So the manuscript is first transformed through these stages:
pdf2txtextraction- raw text inspection
- cleaning
- chunking
This preprocessing step is critical. A well-grounded model will refuse to invent missing content if fed poor text.
Key paths in the current repository:
.
├── manuscriptprep_ingest.py
├── manuscriptprep_orchestrator_tui_refactored.py
├── manuscriptprep_merger.py
├── manuscriptprep_resolver.py
├── manuscriptprep_pdf_report.py
├── manuscriptprep/
├── modelfiles/
├── config/
├── docs/
├── work/
├── out/
├── merged/
├── resolved/
└── reports/
Typical runtime flow:
source PDF
-> work/extracted/<book_slug>/
-> work/cleaned/<book_slug>/
-> work/chunks/<book_slug>/
-> out/<book_slug>/<chunk_id>/
-> merged/<book_slug>/
-> resolved/<book_slug>/
-> reports/<book_slug>_report.pdf
See docs/getting-started/system-requirements.md for the minimum and recommended OS/hardware combinations.
Install Ollama on your system.
On Linux, follow the standard Ollama installation instructions. Once installed, verify:
ollama --versionPython 3.10+ is recommended.
Check your version:
python --versionCreate a virtual environment:
The repository now includes a root compose.yaml that starts:
postgresgatewayworker
The compose stack configures the gateway and worker to use PostgreSQL by default:
docker compose up --buildThe gateway will be available on http://127.0.0.1:8765 and PostgreSQL will be published on host port 5433 by default. The container-to-container database port remains 5432.
For the user-facing flow:
- Open
http://127.0.0.1:8765/ui - If this is the first run, complete the one-time admin password setup
- Log in as admin or register a normal user account
- Upload one or more manuscript PDFs
- Select a manuscript from the workspace sidebar
- Choose a config profile
- Trigger the full pipeline or individual stages and watch live status updates
For compose-based analysis, the container-safe config uses http://host.docker.internal:11434 for Ollama, and both the canonical orchestrator and the resolver now use that configured host instead of requiring a local ollama run subprocess inside the container. On Linux, compose.yaml maps that hostname to the Docker host automatically.
If those host ports conflict with your machine, override them when starting the stack:
MANUSCRIPTPREP_POSTGRES_PORT=55432 MANUSCRIPTPREP_GATEWAY_PORT=18765 docker compose up --buildThe stack will use:
- PostgreSQL database:
manuscriptprep - PostgreSQL schema:
gateway - API auth: enabled for
/v1/* - default self-service user registration through the gateway auth endpoints
- default development admin token:
dev-admin-tokenunlessMANUSCRIPTPREP_BOOTSTRAP_ADMIN_TOKENis overridden - environment-driven database credentials and admin token, with examples in
.env.example - non-root
manuscriptprepuser inside the gateway and worker containers
For local non-container development, the gateway can still run with the file-backed store by default.
python -m venv .venv
source .venv/bin/activateInstall the Python dependencies:
pip install richDepending on which PDF preprocessing path you use, you may also want:
pip install pymupdf pdfplumberIf you use only shell tools like pdftotext, then rich may be the only required Python dependency for the orchestrator TUI.
Option A: pdftotext
Recommended for text-based PDFs.
Fedora:
sudo dnf install poppler-utilsUbuntu/Debian:
sudo apt install poppler-utilsVerify:
pdftotext -vOptionB: OCR Tools Only needed if the PDF is image-based or scanned.
Fedora:
sudo dnf install tesseract ocrmypdfUbuntu/Debian:
sudo apt install tesseract-ocr ocrmypdfIf you want an interactive browser UI for Ollama:
docker run -d \
-p 3000:8080 \
--add-host=host.docker.internal:host-gateway \
-e OLLAMA_BASE_URL=http://host.docker.internal:11434 \
-v open-webui:/app/backend/data \
--name open-webui \
--restart always \
ghcr.io/open-webui/open-webui:mainThen open:
http://localhost:3000
If Open WebUI is running on a different machine from Ollama, point OLLAMA_BASE_URL to your Ollama host.
This project uses four separate models, each with a focused responsibility.
manuscriptprep-structure
Purpose:
- extract chapter titles
- extract part titles
- identify real scene breaks
- ignore page numbers and metadata
Expected output:
{
"chapters": [],
"parts": [],
"scene_breaks": [],
"status": ""
}manuscriptprep-dialogue
Purpose:
- determine POV
- determine whether dialogue is present
- determine whether internal thought is present
- extract explicitly attributed speakers
- identify whether unattributed dialogue exists
Expected output:
{
"pov": "",
"dialogue": false,
"internal_thought": false,
"explicitly_attributed_speakers": [],
"unattributed_dialogue_present": false
}manuscriptprep-entities
Purpose:
- extract literal characters
- extract literal places
- extract literal objects
- capture identity uncertainty notes
Expected output:
{ "characters": [], "places": [], "objects": [], "identity_notes": [] }
manuscriptprep-dossiers
Purpose:
- build conservative character dossiers
- use only the excerpt plus prior extraction data
- avoid broad story knowledge
- avoid future plot references
Expected output:
{
"character_dossiers": [
{
"name": "",
"aliases": [],
"role": "unknown",
"biography": "",
"personality_traits": [],
"vocal_notes": "unknown",
"accent": "not specified in excerpt",
"spoken_dialogue": false,
"identity_status": "confirmed"
}
]
}A single-pass prompt tends to mix together:
- structure parsing
- speaker attribution
- entity extraction
- role inference
- biography writing
That causes:
- identity drift
- schema inconsistency
- role hallucination
- poor grounding
- worse JSON reliability
By splitting the work into four narrow passes, each model has a smaller and clearer job.
This gives much more stable results.
All four custom models currently use:
FROM qwen3:8b-q4_K_M
PARAMETER temperature 0.1
PARAMETER top_p 0.9
PARAMETER repeat_penalty 1.1
PARAMETER num_ctx 16384
These settings bias the model toward:
- deterministic behavior
- conservative extraction
- lower hallucination risk
- stable JSON formatting
Create one Modelfile per pass.
Example structure model creation:
ollama create manuscriptprep-structure -f structure.ModelfileDialogue:
ollama create manuscriptprep-dialogue -f dialogue.ModelfileEntities:
ollama create manuscriptprep-entities -f entities.ModelfileDossiers:
ollama create manuscriptprep-dossiers -f dossiers.ModelfileList installed models:
ollama listExample end-to-end run:
python manuscriptprep_ingest.py \
--input source/TREASURE-ISLAND-by-Robert-Louis-Stevenson.pdf \
--workdir work \
--title "Treasure Island"Or, with shared defaults from config:
python manuscriptprep_ingest.py \
--config config/manuscriptprep.example.yaml \
--input source/TREASURE-ISLAND-by-Robert-Louis-Stevenson.pdf \
--title "Treasure Island"python manuscriptprep_orchestrator_tui_refactored.py \
--input-dir work/chunks/treasure_island \
--output-dir out/treasure_islandpython manuscriptprep_merger.py \
--input-dir out/treasure_island \
--output-dir merged/treasure_island \
--chunk-manifest work/manifests/treasure_island/chunk_manifest.jsonOr, with shared defaults from config:
python manuscriptprep_merger.py \
--config config/manuscriptprep.example.yaml \
--book-slug treasure_islandpython manuscriptprep_resolver.py \
--input-dir merged/treasure_island \
--output-dir resolved/treasure_island \
--model manuscriptprep-resolverOr, with shared defaults from config:
python manuscriptprep_resolver.py \
--config config/manuscriptprep.example.yaml \
--book-slug treasure_islandpython manuscriptprep_pdf_report.py \
--input-dir resolved/treasure_island \
--output reports/treasure_island_report.pdf \
--title "Treasure Island"Or, with shared defaults from config:
python manuscriptprep_pdf_report.py \
--config config/manuscriptprep.example.yaml \
--book-slug treasure_islandIn config-derived mode, the report generator prefers resolved/<book_slug> when
book_resolved.json exists and falls back to merged/<book_slug> otherwise.
The generated PDF now also states whether it was built from merged or resolved
data.
Using pdftotext:
pdftotext -layout input/manuscript.pdf input/raw.txtIf the PDF is scanned, run OCR first:
ocrmypdf input/manuscript.pdf input/manuscript_ocr.pdf
pdftotext -layout input/manuscript_ocr.pdf input/raw.txtCheck the first few lines:
head -n 50 input/raw.txtLook for problems such as:
- repeated book title on every page
- page numbers as standalone lines
- missing paragraphs
- broken OCR
- chapter headings not preserved
A cleaning script should remove:
- blank page markers
- repeated titles
- isolated page numbers
- obvious header/footer noise
Example goals of the cleaner:
- preserve real prose
- preserve chapter titles
- preserve dialogue punctuation
- preserve paragraph breaks where possible
``bash python ManuscriptTXTCleaner.py input/raw.txt input/clean.txt
Typical output:
```text
input/raw.txt -> input/clean.txt
The chunker should split the manuscript into manageable excerpt files.
Recommended chunk size:
- around 1500–3000 words
- or based on paragraph boundaries
- ideally not splitting in the middle of a scene if avoidable
Typical output directory:
input/chunks/
chunk_0.txt
chunk_1.txt
chunk_2.txt
...
The orchestrator is the script that runs all four passes over each chunk.
It performs this sequence:
1 structure pass 1 dialogue pass 1 entities pass 1 dossier pass
The dossier pass consumes:
- the original excerpt
- character list from entities pass
- dialogue output from dialogue pass
python manuscriptprep_orchestrator.py --input-dir input/chunks --output-dir outpython manuscriptprep_orchestrator_tui.py --input-dir input/chunks --output-dir outThe TUI version is useful because it can show:
- current chunk
- current pass
- orchestrator actions
- visible model stdout
- visible model stderr
A typical output directory for one chunk looks like:
out/chunk_0/
structure_raw.txt
structure.json
dialogue_raw.txt
dialogue.json
entities_raw.txt
entities.json
dossier_input.txt
dossiers_raw.txt
dossiers.json
These contain the model’s original stdout and are useful for debugging malformed JSON or prompt drift.
These are the parsed outputs suitable for downstream merging and analysis.
dossier_input.txt
This shows exactly what was passed into the dossier model.
Current core dependencies:
rich
Optional preprocessing dependencies:
pymupdf
pdfplumber
Optional OCR/system tooling:
- pdftotext
- ocrmypdf
- tesseract
pdftotext -layout input/manuscript.pdf input/raw.txtpython scripts/clean_text.py input/raw.txt input/clean.txtpython scripts/chunk_text.py input/clean.txt input/chunksollama create manuscriptprep-structure -f Modelfiles/structure.Modelfile
ollama create manuscriptprep-dialogue -f Modelfiles/dialogue.Modelfile
ollama create manuscriptprep-entities -f Modelfiles/entities.Modelfile
ollama create manuscriptprep-dossiers -f Modelfiles/dossiers.Modelfilepython scripts/manuscriptprep_orchestrator_tui.py --input-dir input/chunks --output-dir outManuscriptPrep is built around a few strict principles.
The models should use only the excerpt they are given.
The system should not rely on the model already knowing the novel.
Each pass should emit machine-usable structured data.
Each pass should do one thing well rather than many things badly.
All intermediate results should be inspectable.
Some local models may still emit visible Thinking... text before the JSON. The orchestrator can often recover by extracting the JSON object, but this is still undesirable.
Even with strict prompts, some models may still over-infer roles such as:
- pirate
- magistrate
- innkeeper
- narrator
If chunks contain table-of-contents material, the structure model may extract chapter lists that are not local to the narrative body.
Poor OCR can still degrade the downstream quality of extraction.
This means the model is emitting visible reasoning text. The orchestrator may still succeed if the JSON can be extracted from the raw output.
Check:
- *_raw.txt
- error.txt if present
Usually this means one pass emitted invalid JSON or Ollama returned an error.
Check:
- structure_raw.txt
- dialogue_raw.txt
- entities_raw.txt
- dossiers_raw.txt
- error.txt
This usually means the UI is not refreshing often enough, not that the pipeline has stopped. Check whether files are appearing in out/.
This usually means:
- the prompt is too permissive
- the chunk contains too much metadata
- the text cleaner has not removed enough noise
- the task is too broad for a single pass
This stack is well suited to:
- audiobook prep
- editorial review
- dialogue extraction
- character dossier preparation
- narrative structure analysis
- book-to-JSON pipelines
It is not intended as a creative writing assistant. It is intended as a forensic manuscript analysis tool.
The latest version of the ManuscriptPrep TUI orchestrator adds several operational and observability features that make long manuscript runs much easier to monitor and troubleshoot.
The orchestrator includes a live terminal UI that shows the current state of the pipeline while it runs.
It displays:
- current chunk
- current pass
- current status
- current step
- elapsed time
- progress across all chunks
This gives you a live view of what the pipeline is doing without needing to inspect the output directory constantly.
The TUI updates not only between chunks, but also during active pass execution.
Typical live states include:
- starting structure
- streaming model stdout
- waiting for model output
- writing raw output
- parsing JSON
- writing parsed JSON
- completed pass
This makes it much easier to understand whether the system is actively generating output, waiting on Ollama, parsing JSON, or writing files.
The orchestrator writes a global structured log in JSON Lines format.
Default location:
out/orchestrator.log.jsonl
Each log line is a valid JSON object, which makes it suitable for ingestion into observability and log analysis tools such as:
- Loki
- Elasticsearch
- Splunk
- Datadog
- Vector
- Fluent Bit
Typical events include:
- run start
- chunk start
- pass start
- raw output written
- parsed JSON written
- pass success
- pass retry
- pass failure
- chunk failure
- run complete
Example log line:
{
"timestamp": "2026-03-21T12:34:56.789012+00:00",
"level": "INFO",
"event_type": "pass_start",
"message": "Starting pass",
"run_id": "uuid-here",
"chunk": "chunk_0",
"pass": "dialogue",
"step": "starting dialogue",
"model": "manuscriptprep-dialogue",
"attempt": 1,
"pid": 12345
}If a chunk fails, the orchestrator writes a plain-text error file inside that chunk’s output directory.
Example:
out/chunk_7/error.txt
This gives you a simple human-readable record of what failed without needing to inspect the global log.
The orchestrator supports automatic retries for failed passes.
For example, if the dialogue pass returns invalid JSON or Ollama exits with an error, the orchestrator can retry the pass before declaring the chunk failed.
This is useful for transient problems such as:
- malformed model output
- temporary Ollama failures
- empty stdout
- JSON extraction problems
Recommended usage:
python manuscriptprep_orchestrator_tui.py --input-dir chunks --output-dir out --retries 1When a chunk fails after retries, the orchestrator can be configured to either:
- skip the failed chunk and continue with the rest of the manuscript s- top the entire run immediately
Default behavior is usually best set to skip, which is more practical for long manuscripts.
Examples:
Skip failed chunks:
python manuscriptprep_orchestrator_tui.py --input-dir chunks --output-dir out --on-failure skipStop on first failed chunk:
python manuscriptprep_orchestrator_tui.py --input-dir chunks --output-dir out --on-failure stopFailure Summary
At the end of the run, the orchestrator produces a summary of failures.
This includes:
- number of chunks completed
- number of chunks failed
- list of failed chunks
- error messages for each failed chunk
This makes it easier to see whether the run was mostly successful or whether specific chunks need to be rerun.
The TUI Pipeline Status window includes a token speed field.
This is useful for getting a rough sense of model throughput during processing.
The orchestrator can show:
- reported token speed, if Ollama or the model emits a real token/sec value
- estimated token speed, based on visible streamed stdout
The token speed display helps distinguish between:
- a model that is actively producing output
- a model that is generating slowly
- a pass that may be stalled or looping
Ollama does not reliably expose the true backend evaluation tokens/sec through normal streamed stdout.
Because of that, the orchestrator uses the following logic:
- if a real token/sec value appears in model stdout or stderr, the TUI will display that
- otherwise, it shows a live estimated output token speed based on visible streamed stdout
This means the displayed value is operationally useful, but it may not exactly match the model’s true backend eval rate.
So the token speed shown in the TUI should be treated as:
- good for monitoring
- good for spotting stalls or regressions
- not guaranteed to be a precise benchmark
The TUI orchestrator provides a live terminal dashboard while the pipeline is running.
The Pipeline Status panel shows:
- current chunk
- current pass
- current status
- current step
- elapsed time
- overall progress
- retry count
- token speed
- age of last stdout line
- age of last stderr line
This makes it much easier to tell whether the system is:
- actively generating output
- waiting for model output
- parsing JSON
- writing files
- retrying after failure
- stalled long enough to trigger a timeout
The orchestrator updates the TUI during active processing, not just between chunks.
Typical steps shown in the UI include:
- loading excerpt
- launching model
- sending prompt to model
- streaming model stdout
- streaming model stderr
- waiting for model output
- writing raw output
- parsing JSON
- writing parsed JSON
- building dossier input
- completed pass
This gives a live view of what both the orchestrator and the model are doing.
At the end of a run, the orchestrator records a failure summary.
This includes:
- total chunks discovered
- chunks completed
- chunks failed
- failed chunk names
- failure messages
This makes it easier to assess the overall health of the run and identify which chunks need to be rerun or inspected.
One common problem with local model pipelines is that a subprocess can appear to hang indefinitely without actually exiting.
The updated orchestrator detects this condition using an idle timeout.
If a pass produces no stdout or stderr for a configurable number of seconds, the orchestrator treats the pass as stalled:
- the Ollama subprocess is terminated
- the event is logged
- the pass may be retried
- if retries are exhausted, the chunk is marked failed
Example:
python manuscriptprep_orchestrator_tui.py \
--input-dir chunks \
--output-dir out \
--idle-timeout 90This tells the orchestrator to treat 90 seconds of silence as a stall.
In addition to idle timeout detection, the orchestrator also supports a hard timeout for each pass.
If a pass runs longer than the configured maximum wall-clock time, the orchestrator will:
- terminate the subprocess
- log the timeout
- retry if configured
- fail the chunk if retries are exhausted
Example:
python manuscriptprep_orchestrator_tui.py \
--input-dir chunks \
--output-dir out \
--hard-timeout 600This caps any single pass at 10 minutes.
When either an idle timeout or hard timeout occurs, the orchestrator does not simply wait forever.
Instead it:
- detects the timeout
- kills the running Ollama subprocess
- logs the timeout event
- retries the pass if retries remain
- otherwise fails or skips the chunk according to policy
This is a major improvement over earlier versions that could appear to hang indefinitely.
The TUI also displays:
- age of
last stdoutoutput - age of
last stderroutput
These fields are especially useful when diagnosing stalls.
For example:
- a growing
Last stdoutage usually means the model has gone silent - a very old
Last stderrage with no progress may indicate a silent hang - a constantly resetting
Last stdoutage suggests active streaming
This makes it easier to distinguish between:
- a slow pass
- a quiet pass
- a stalled pass
The orchestrator now sorts chunk files in natural numeric order.
That means:
chunk_1.txt
chunk_2.txt
chunk_3.txt
...
chunk_10.txt
instead of lexicographic ordering like:
chunk_1.txt
chunk_10.txt
chunk_11.txt
chunk_2.txt
This makes progress reporting easier to understand and keeps the run aligned with the manuscript’s actual order.
The orchestrator now supports adaptive idle-timeout backoff for retries.
This feature is designed to reduce failures on chunks or passes that are slow to begin emitting visible output but are not truly broken.
Some local model passes can be slow to produce their first visible stdout, especially when:
- the chunk is large or noisy
- the dossier pass is especially heavy
- the model spends time in visible reasoning before returning JSON
- system load varies between chunks
A fixed idle timeout can therefore be too strict.
If the timeout is too low, the orchestrator may kill a pass that would have succeeded if given a little more time.
The orchestrator starts each pass with a base idle timeout.
If a pass fails specifically because of an idle timeout, the next retry increases the idle timeout by a configurable multiplier.
For example, with:
- base idle timeout =
180 - backoff multiplier =
1.5 - retries =
2
the retry sequence becomes:
- attempt 1 →
180s - attempt 2 →
270s - attempt 3 →
405s
This gives slow-starting passes a better chance of succeeding without forcing every pass to use an excessively large timeout from the beginning.
Idle-timeout backoff is applied only when the previous failure was caused by an idle timeout.
It is not applied for other failure types such as:
- invalid JSON
- empty model output
- non-zero Ollama exit
- hard timeout
- parsing failure
This keeps retries targeted and avoids unnecessarily extending timeouts for unrelated problems.
To prevent the idle timeout from growing without bound, the orchestrator also supports a maximum cap.
For example:
- base idle timeout =
180 - backoff multiplier =
1.5 - max idle timeout =
600
This ensures that retries remain reasonable even if a pass stalls multiple times.
The TUI now shows the current idle-timeout state for the active pass.
The Pipeline Status panel includes:
- current effective idle timeout
- number of idle-timeout backoffs applied for the current pass
This makes it easier to see whether a retry is simply being rerun with the same configuration or whether the timeout has been expanded because of a previous idle-timeout failure.
The structured JSONL log also records the effective idle timeout used for each attempt.
This makes it possible to trace:
- which retry used which timeout
- whether a retry was triggered by an idle-timeout failure
- whether the timeout increased between attempts
Example fields written into the log include:
idle_timeout_sidle_timeout_failures_for_passprevious_idle_timeout_snext_idle_timeout_s
This is useful for observability and later tuning.
A good default configuration for long manuscript runs is:
python manuscriptprep_orchestrator_tui.py \
--input-dir chunks/treasure_island \
--output-dir out/treasure_island \
--retries 2 \
--on-failure skip \
--idle-timeout 180 \
--idle-timeout-backoff 1.5 \
--max-idle-timeout 600 \
--hard-timeout 900This gives you:
- live TUI monitoring
- structured JSONL logs
- retry protection
- per-chunk error files
- idle stall detection
- hard timeout protection
- resilient long-run behavior
- idle-timeout-backoff
Local LLM pipelines often fail in subtle ways:
- visible reasoning loops
- partial JSON output
- long silent stalls
- no clear indication of whether the model is still active
- awkward recovery when one chunk fails partway through a book
The updated orchestrator addresses these practical runtime problems directly.
It turns the pipeline from a basic automation script into something much closer to a production processing tool.
Once per-chunk outputs are stable, the next major improvement is a book-level merger that can:
- combine structure across chunks
- merge entity references conservatively
- aggregate dossier facts
- track recurring characters across excerpts
This project is licensed under the Apache License, Version 2.0. See the LICENSE file for details.
Created by Robert Balm.
This project was developed with AI-assisted tooling, including OpenAI Codex and related orchestration/tooling.
If you fork or redistribute this project, please retain existing copyright, license, and notice text.
