Skip to content

Commit a7bd370

Browse files
vikast908claude
andcommitted
feat: multi-provider models, smarter TUI, and an output-quality overhaul
Model hosts & routing: - providers.py registry: 17 OpenAI-compatible hosts (OpenRouter default + DeepSeek, OpenAI, Gemini, xAI, Groq, Ollama/LM Studio local, custom, ...) + aliases; /provider switches with lazy credentials. /model list shows a curated popular-model catalog and the completer suggests slugs. New `provider` setting; BOOK_AGENT_PROVIDER env. TUI & input: - /features is an interactive prompt_toolkit toggle grid; /help grouped by category. - Forgiving input everywhere: fuzzy project lookup (excerpts/typos -> numbered picker), slang-tolerant yes/no, fuzzy /theme /mode /model /set /provider /skill. Chat /use guardrail: a hallucinated/junk project id never clobbers the active project. Export & save location: - export takes one format, a list, or 'all' (+ plain-English parsing: "pdf, epub and word"); one failing format never aborts the others. - /path sets where exports are saved (global default + per-project, with a move flow); export_dir resolution keeps base_dir intact so images/diagrams still resolve. Output quality: - polish.py: references end-only, dated, influence-scored (0-100) and ranked high->low; inline [N] stripping (strip_inline_citations); mid-article reference-dump removal; figure de-duplication. Wired into assembly + a `polish` command / repolish_manuscript() that re-fixes an existing manuscript deterministically (no LLM). - ARTICLE_WRITER_SYS forbids model-drawn figures/mermaid/self-numbered Figure/Listing - the producer owns figures. - diagram: `auto` now uses the compact, overlap-free built-in engine (D2 is opt-in, it rendered very wide); comparison edge-labels de-duped so repeated relations don't stack. New settings: provider, strip_inline_citations, rank_references, export_dir. 305 tests pass; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3c33fa3 commit a7bd370

23 files changed

Lines changed: 2120 additions & 189 deletions

plan.md

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,28 @@ deterministic style metrics, and `/praise` are the compensating defenses. Route
473473
`judge`/`verifier` nodes (§15.6) - to any non-DeepSeek slug to restore cross-family independence
474474
where it matters most.)*
475475

476+
### 12.2 Provider selection (the model host)
477+
478+
The pipeline speaks **one** wire format - OpenAI chat-completions (text + JSON-mode structured
479+
output, no tool-calls or thinking-block replay) - so it talks to any OpenAI-compatible host through
480+
a **single transport**. `providers.py` is a small frozen-dataclass registry (`id`, `name`,
481+
`base_url`, key env vars, optional `*_BASE_URL` override, `reports_cost`, extra headers, `local`).
482+
**OpenRouter is the default** (and the only host that reports real USD `usage.cost`); also built in:
483+
DeepSeek, OpenAI, Google Gemini (compat endpoint), xAI, Groq, Mistral, Moonshot/Kimi, Qwen/DashScope,
484+
Zhipu GLM, NVIDIA NIM, Together/Fireworks/DeepInfra aggregators, **Ollama** and **LM Studio** (local,
485+
no key), and a `custom` escape hatch (`BOOK_AGENT_BASE_URL`). Aliases resolve shorthand (`grok→xai`,
486+
`ds→deepseek`, `kimi→moonshot`, …). Adding a provider is **one registry entry**, nothing else.
487+
488+
Switch with **`/provider <id>`** (lists every host with a key/local/no-key marker, persists to
489+
`settings.provider`, rebuilds the client), `/set provider <id>`, or **`BOOK_AGENT_PROVIDER`**.
490+
Credentials are resolved lazily - switching to a key-less host never crashes startup; the clear
491+
"set `XAI_API_KEY`" error only fires on the first real call. Each host reads its own key env var; a
492+
`*_BASE_URL` var points any provider at a proxy/self-hosted gateway. *Deliberately out of scope
493+
(Hermes has them; a writing pipeline doesn't need them): Anthropic-native / Bedrock / Codex-Responses
494+
transports (all reachable via OpenRouter or a compat shim), a `NormalizedResponse` layer (one wire
495+
format ⇒ nothing to normalize), and OAuth-device/AWS-SDK auth. Model **slugs are not auto-translated**
496+
across hosts - set them per host with `/model`.*
497+
476498
---
477499

478500
## 13. CLI design (the UI)
@@ -505,7 +527,7 @@ and canon in any editor):
505527
| `brief` | The goal panel: thesis / premise, audience, target length, intake, voice/watch state |
506528
| `tableread [--as "persona"]` | Skeptical-reader cold read of the finished piece (optional persona) (§15.4) |
507529
| `eval` | Quality report: judged 5-dim rubric + deterministic metrics → `eval_report.md` (§15.5) |
508-
| `export` | Render the manuscript: pdf · epub · html · docx · txt · md |
530+
| `export [fmt ... \| all]` | Render the manuscript: pdf · epub · html · docx · txt · md. Takes one format, a list (`export pdf epub`), or **`all`**; one failing format never aborts the rest (§16.5) |
509531
| `memory` | Inspect canon (characters/timeline) + entity graph |
510532
| `consolidate` · `produce` | Run those passes on demand |
511533
| `skills` · `seed-skills` | List skills + efficacy · install built-in craft skills |
@@ -522,7 +544,8 @@ with semantic status colors; alternates `kazama` (flame, sheared) · `supabase`
522544
`fallout` (CRT amber) · `mimi` (rose pastels) · `astrovista` (mars rust); registry in `ui.THEMES`
523545
incl. `FONT`/`WORDS`/`SHEAR`, persisted via `settings.theme`), `/dashboard [<project>]` (telemetry
524546
rollup - calls/tokens/cost/latency/errors; per-unit breakdown when a project is named; reads the
525-
JSONL call log, §15.1), `/clear`, `/exit`.
547+
JSONL call log, §15.1), `/provider [<id>]` (switch the model host, §12.2), `/features` (interactive
548+
toggle grid), `/path` (where exports are saved, §16.5), `/set <key> <value>`, `/clear`, `/exit`.
526549

527550
Run modes: **interactive** (prompts inline on escalation via the picker, §7), **autonomous**
528551
(`--autonomous` / `/auto on`: never pauses; commits the best draft + auto-repairs contradictions),
@@ -780,6 +803,53 @@ Production does **not** re-judge chapter prose - that's the Critic's job, done p
780803
only prose work is the matter it generates plus light *global* consistency (heading styles,
781804
formatting, front/back-matter coherence). No re-litigating the body.
782805

806+
### 16.5 Save location (where exports land)
807+
808+
The brain working dir (drafts, `manuscript.md` source, run-state) is the source of truth and never
809+
moves. Separately, the **rendered deliverables** an `export` produces - `manuscript.{pdf,epub,html,
810+
docx,txt}` and `manuscript_export.md` - can be written to a folder the writer chooses, while
811+
`base_dir` (image/diagram resolution) stays the brain root. Resolution order (`brain.resolve_export_dir`):
812+
**per-project override** (a `export_dir.txt` sidecar in the project root) → **global default**
813+
(`settings.export_dir`, namespaced by project id) → **the project's brain root** (the original
814+
behaviour; the empty default). An unwritable target silently falls back to the root - an export
815+
never crashes on a bad path.
816+
817+
Driven by **`/path`**: no-arg opens a menu (set the default, or pick a project from the ongoing
818+
list → enter a folder → it offers to **move** that project's existing deliverables to the new home,
819+
source file untouched). Direct forms: `/path default <dir>`, `/path <project> <dir>`, `/path show`,
820+
`/path clear [<project>]`. The move only ever relocates the rendered files in `EXPORT_DELIVERABLES`.
821+
822+
### 16.6 References, citations & figures (deterministic polish, `polish.py`)
823+
824+
The **producer owns** references and figures; the writer must not. `ARTICLE_WRITER_SYS` forbids the
825+
model from drawing diagrams (mermaid/ASCII/charts), self-numbering `Figure N`/`Listing N`, writing
826+
figure captions, or emitting bare `[N] Author…` reference lines - it only places inline `[N]` markers
827+
in prose. At assembly (`_assemble_article`) the deterministic `polish.py` pass then:
828+
829+
- **References, end-only, ranked.** `score_sources` rates each source's *influence* = how often it's
830+
actually cited in the body (weighted) + title overlap with the thesis/headings; `build_references`
831+
emits one `## References` list **sorted most-influential first**, each line `N. **score** · date ·
832+
[title](url)` (0–100). Dates normalized (`n.d.` when unknown). Zero-influence noise is pruned only
833+
when there's signal to rank against. `rank_references` setting (default on).
834+
- **Citations stripped.** `strip_inline_citations` (setting, default on) removes every `[N]` from the
835+
prose *after* scoring, so the body reads clean and all sourcing lives in the end list.
836+
- **Stray dumps removed.** `strip_reference_dumps` pulls writer-emitted reference lists out of the
837+
body (headed blocks *and* bare `[N] …` runs) - references never appear mid-article.
838+
- **Figures de-duped.** `strip_model_figures` (going forward) drops any diagram the model still drew;
839+
`dedupe_figures` (for existing manuscripts) removes the model's `Figure N.N` caption-heading and a
840+
redundant embedded SVG when a diagram is already present, so a figure never appears twice.
841+
842+
**`polish` command / `repolish_manuscript(uid, id, settings)`** re-applies all of the above to an
843+
*existing* manuscript with **no LLM call** (≈0 tokens) and refreshes the exports - the cheap way to
844+
fix an already-generated article.
845+
846+
**Figure engine.** `diagram_engine: auto` (the default) now uses the **built-in** engine - it measures
847+
text and lays out compactly (a ~590px figure with title, lane headers, readable boxes), and the
848+
comparison archetype **de-duplicates repeated relationship labels** (`provides`×3 → ×1) so edge labels
849+
never stack/overlap. **D2+ELK is explicit opt-in** (`diagram_engine: d2`) - it tends to render very
850+
wide (~1700px), hard-to-read figures, so it is no longer auto-selected just because the `d2` binary is
851+
present.
852+
783853
---
784854

785855
## 17. Working process - `resume.md` (session continuity)

resume.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,79 @@
55
66
## Current status
77

8+
- **New (2026-06-13, session 16 - export quality: references/citations/figures overhaul):** root
9+
cause of the bad PDFs = the **writer model authors its own figures (mermaid), figure numbers,
10+
captions, listings, inline `[N]` citations, and bare per-section reference dumps**, which collide
11+
with the pipeline's own SVG figure + final References list (duplicate figures, double captions,
12+
mid-article references, mismatched numbering). Fixes: new **`polish.py`** (pure, deterministic, no
13+
LLM): `strip_inline_citations`, `strip_reference_dumps` (headed blocks + bare `[N]` runs),
14+
`strip_model_figures`/`dedupe_figures`, `score_sources` (influence = body-citation count + thesis/
15+
heading title-overlap) + `build_references` (one end list, `N. **score** · date · [title](url)`,
16+
sorted high→low, dates normalized, zero-influence pruned only when there's signal). Wired into
17+
`_assemble_article` (going forward) gated by two new settings **`strip_inline_citations`** (default
18+
on) + **`rank_references`** (default on), threaded into article run-state. `ARTICLE_WRITER_SYS` now
19+
FORBIDS model-drawn figures/mermaid/`Figure N`/`Listing N`/captions/bare-ref-lines. New
20+
**`repolish_manuscript()`** + **`polish` CLI command** re-fix an EXISTING manuscript with ~0 tokens
21+
and re-export. **Ran it on the voicebot article**: inline `[N]` 29→0, mid-article ref lines 9→0,
22+
figure-heading 1→0, redundant SVG embed removed (figure-twice fixed), References rebuilt = 47 ranked/
23+
dated/scored (DraftKings demoted to #10). Re-exported md/txt/html/epub/docx (pdf was file-locked -
24+
user had it open; `manuscript.md.bak` saved). +7 tests (`test_polish.py`).
25+
**Phase 3 (figure render quality) - DONE for the pipeline engine:** Playwright render of the
26+
section_05 spec proved the **built-in engine ≫ D2** (D2+ELK = 1744px-wide unreadable; built-in =
27+
compact 592px with title/lane-headers/readable boxes). (1) `nodes.generate_svg_diagram`: **`auto`
28+
now = built-in** (was: D2 whenever the d2 binary is installed - exactly why the user's runs looked
29+
bad); D2 is explicit opt-in. (2) built-in **comparison** edge-label **de-dup** (`_edge_label(...,
30+
seen)`): repeated relations (`provides`×3) render ONCE instead of overlapping in the column gap; gap
31+
90→120. Flipped local `settings.yaml` `diagram_engine: d2 → auto`. +1 test (`test_diagram.py`).
32+
**All 305 tests pass; ruff clean.** **Still open (smaller):** the EXISTING voicebot article's figures
33+
are model **mermaid** (the SVG was deduped out; prose references the mermaid), rendered via
34+
mermaid.ink (clipped pie title) - only fixable by regenerating that article's diagrams (small
35+
diagram-node token cost; offered to the user). Going forward every figure is a clean built-in SVG.
36+
37+
38+
- **New (2026-06-13, session 15 - multi-provider model hosts + slash-menu polish):** two
39+
Hermes-inspired asks, each pruned to what a single-wire-format writing pipeline actually needs.
40+
**(A) Multi-provider routing** (plan §12.2): new `providers.py` registry (frozen `Provider`
41+
dataclass; `id`/`name`/`base_url`/key-envs/`*_BASE_URL` override/`reports_cost`/`headers`/`local`)
42+
with 17 OpenAI-compatible hosts (OpenRouter default + cost; DeepSeek, OpenAI, Gemini-compat, xAI,
43+
Groq, Mistral, Moonshot, DashScope, Zhipu, NVIDIA, Together/Fireworks/DeepInfra, Ollama + LM Studio
44+
local, `custom`) and an alias table. `llm.py` rewired: `_get_client` resolves the active provider
45+
(lazy creds - key-less switch never crashes; clear "set XAI_API_KEY" only on first real call),
46+
`configure_provider`/`active_provider` added, cost-ask gated per provider. `settings.provider`
47+
(default `openrouter`) + `BOOK_AGENT_PROVIDER` env; wired at startup (`cli._apply_provider`,
48+
`api._apply_runtime`). Shell: `/provider [id]` (list with key/local markers · switch · did-you-mean),
49+
`/set provider` side-effect, completer + `/help` config group. **Deliberately dropped from the spec:**
50+
the 3 non-OpenAI transports, `NormalizedResponse`, `api_mode` heuristics, OAuth/Bedrock/Codex auth,
51+
cross-host slug translation. +10 tests (`test_providers.py`). **(B) Slash-menu**: `/features` is now
52+
an interactive prompt_toolkit **toggle grid** (`_toggle_grid`; ↑↓ · space · ↵ save · esc cancel;
53+
falls back to the static table off-TTY), and `/help` is **grouped by category** (`_SLASH_HELP` is
54+
now category-keyed). +3 tests (`test_ui.py`). **(C) Configurable save location** (plan §16.5):
55+
exports can now be written wherever the writer wants. New `settings.export_dir` (global default,
56+
`""` = each project's own folder) + per-project override (a `export_dir.txt` sidecar in the project
57+
root). `brain.resolve_export_dir`/`project_root`/`get|set_project_export_dir`/`move_exports` +
58+
`EXPORT_DELIVERABLES`; `orchestrator._export_paths_and_title` now also returns `out_dir` and all 6
59+
exporters write the rendered file there while `base_dir` stays the brain root (so images/diagrams
60+
still resolve). Shell **`/path`**: no-arg menu (default · or pick an ongoing project → enter folder →
61+
offer to **move** existing deliverables; the `manuscript.md` SOURCE never moves), plus `/path
62+
default <dir>`, `/path <project> <dir>`, `/path show`, `/path clear`. Completer + `/help` session
63+
group + dispatch. +8 tests (`test_export_path.py`). **(D) Export to many formats + NL parsing**:
64+
`export` now takes one format, a list (`export pdf epub`), or **`all`**; positional arg + dropped the
65+
argparse `choices` lock; one failing format never aborts the rest (per-format try/except + summary).
66+
`cli._resolve_formats` understands commas/semicolons/·/&/+, connector words ("pdf, epub **and** word"),
67+
and synonyms (word→docx, markdown→md, ebook→epub, everything→all). `write` interview returns a list
68+
too. Centralised the styled prompt in `cmd_export` (removed the duplicate picker in `_execute_cmd`).
69+
+7 tests (`test_export_formats.py`). **(E) Smart, forgiving input everywhere** (the "make it
70+
intelligent" sweep): `brain.match_projects`/`resolve_project` (excerpt/typo/word-order tolerant, with
71+
a clear-leader rule + ambiguous→options) wired into `/use` (numbered picker), `/path`, `/dashboard`,
72+
and `cli._resolve_book`. New `ui.is_affirmative` (slang yes/no: yeah/yep/sure/nah/"do it") applied to
73+
all 4 confirm prompts; `ui.smart_match` (alias→exact→prefix→substring→fuzzy) applied to `/theme`,
74+
`/mode` (+essay/novel synonyms), `/model` agent, `/set` key, `/provider`, `/skill`. **(F) Chat `/use`
75+
guardrail**: the assistant was inventing a `/use <hallucinated-id>[article]` for "export to epub" and
76+
erroring; now `_chat_use_project` strips the `[type]` tag and switches only on a STRONG match, else
77+
keeps the active project silently. Context now lists projects one-per-line (id separated from the
78+
type tag) + prompt rules: no `/use` when a project is active, never invent ids. +9 tests
79+
(`test_smart_input.py`). **All 297 tests pass; ruff clean. Nothing committed yet.** Next: optional
80+
`/theme` list-picker; README/docs-site note; the deferred Hermes-transport tier (user said "wait").
881
- **Phase:** **Production-ready.** Books and articles both live-validated end-to-end. **263 tests
982
pass** (+1 opt-in live skip +1 d2-binary skip); ruff clean on Windows AND Linux (WSL-verified). **CI green on all
1083
12 matrix jobs** since session 10's `svglib<1.6` pin (1.6.0 pulls pycairo, which has no Linux

src/book_agent/api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ def _apply_runtime(self) -> None:
236236
"""Sync process-global LLM knobs to this agent's settings before a call."""
237237
llm.configure_headroom(self.settings.use_headroom)
238238
llm.configure_timeout(self.settings.request_timeout)
239+
try:
240+
llm.configure_provider(self.settings.provider)
241+
except ValueError:
242+
pass # unknown id -> keep the current/default host
239243

240244
def _resolve_approach(self, topic: str, mode: str, approach: Any) -> Any:
241245
"""Return the raw schema object for the chosen direction/angle."""

0 commit comments

Comments
 (0)