diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md index ed399bd2..67fd985f 100644 --- a/docs-developers/architecture/cross-repo-contract.md +++ b/docs-developers/architecture/cross-repo-contract.md @@ -717,17 +717,35 @@ both repos, with a test on each side. `matched: false` with a `work`, and the CC BY-SA `attribution` never covers them. A change to the formats touches audiosilo-meta's model, the Bridge's writer and the server's reader. Detail: [The .audiosilo folder](../bridge/attachments.md). +- **Own-words descriptions (Phase 1c):** a `sidecars.generate` run also writes + `description.json` (audiosilo-meta's `model.Description`, 200-1500 characters, + written from the opening chapters' fact notes only, so spoiler-free, and local + only: never contributed), and `sidecars.describe` writes one alone from the + notes a run kept (params `{}`; `409 generate_open` while a generate job is open + for the book, and a generate job cancels an open describe job). `/meta` serves + it in its **own** additive field, `work.generated_description: {text}`, never in + `community_description` (the shipped 2.0 app shows that one first and credits + the community, so a generated text there would replace real blurbs on old apps), + only when the community work has no `community_description`, marked by + `work.generated.description` (the three booleans are always sent), kept under + `spoilers=hide`. The player orders About itself: the community description, the + book's own, the work's core one, then the generated one with its mark. The + estimate gains `can_describe` and `has.*.description`, the admin book detail + `generated_description`, the attachments rescan answer `description`. Detail: + [The description step](../bridge/generating.md#the-description-step). - **The player (Phase 1a):** its first admin-only UI, gated on `user.role === 'admin'`, `capabilities.bridge`, `bridge.features` holding `sidecars` and `capabilities.metadata`; `Book.generated` makes it ask `/meta` for a book with no - ASIN or ISBN. Its `types.ts` mirrors the job, the estimate and - `BookMetaWork.generated` by hand. + ASIN or ISBN. Its `types.ts` mirrors the job, the estimate, + `BookMetaWork.generated` and (Phase 1c) `BookMetaWork.generated_description` by + hand. **Old clients:** everything is additive. An older server has no Bridge routes (the Bridge's pairing gets a `404` and says the server is too old) and no `bridge` flag; shipped players never see either. A shipped player ignores a `work` beside `matched: false`, and shows a matched book's merged work without the -`generated` mark (the owner kept that as built). The Bridge sends `X-AudioSilo-Client: +`generated` mark (the owner kept that as built); it ignores +`generated_description` entirely. The Bridge sends `X-AudioSilo-Client: AudioSilo Bridge/ ()` on every call. **A change requires:** the server handler and its test, the mirrored type in the diff --git a/docs-developers/bridge/attachments.md b/docs-developers/bridge/attachments.md index 27240925..f5d8b62b 100644 --- a/docs-developers/bridge/attachments.md +++ b/docs-developers/bridge/attachments.md @@ -1,12 +1,12 @@ --- title: The .audiosilo folder -description: "The Bridge's generated files in a book's hidden .audiosilo folder: where they go (and the single-file stem prefix), their formats, how the Bridge writes them, how the server finds, checks and stores them (signatures, the attachments refresh), the hidden-path rule, and how /meta serves them." +description: "The Bridge's generated files in a book's hidden .audiosilo folder: where they go (and the single-file stem prefix), their formats (characters, recaps, the own-words description and the provenance), how the Bridge writes them, how the server finds, checks and stores them (signatures, the attachments refresh), the hidden-path rule, and how /meta serves them." --- -A `sidecars.generate` job leaves its results **with the book**, in a hidden +A `sidecars.generate` job (and a `sidecars.describe` job, Phase 1c) leaves its results **with the book**, in a hidden folder the server reads: the filesystem stays the source of truth, and the server's copy is a rebuildable index of it like everything else. The Bridge -writes the files (`internal/generate/attach.go`); the server never writes into a +writes the files (`internal/generate/attach.go`, `describejob.go`); the server never writes into a library and only reads them (`metadata/attachments.go`, `library/attachments.go`, `catalog/attachments.go`). @@ -14,8 +14,8 @@ library and only reads them (`metadata/attachments.go`, | Book | Folder | Names | |---|---|---| -| a folder book (a folder that holds its audio), a joined disc set included | `/.audiosilo/` (for a disc set, the joined book's folder, the one holding the discs) | `characters.json`, `recaps.json`, `bridge.json` | -| a single-file book (a loose file at the library root, or a book in a `collection` folder) | `/.audiosilo/` | `.characters.json`, `.recaps.json`, `.bridge.json`, the stem being the file name without its last extension (`Book.m4b` -> `Book.characters.json`) | +| a folder book (a folder that holds its audio), a joined disc set included | `/.audiosilo/` (for a disc set, the joined book's folder, the one holding the discs) | `characters.json`, `recaps.json`, `description.json`, `bridge.json` | +| a single-file book (a loose file at the library root, or a book in a `collection` folder) | `/.audiosilo/` | `.characters.json`, `.recaps.json`, `.description.json`, `.bridge.json`, the stem being the file name without its last extension (`Book.m4b` -> `Book.characters.json`) | The prefix keeps the books of one folder apart, since a collection folder's books share one `.audiosilo/`. Only these JSON names are ever written there or read @@ -27,6 +27,21 @@ from there, and never any audio. **`recaps.json`** its `model.Recaps` (with `in_short` and `ending`): exactly what the pipeline's `sidecars/` folder holds after its own validation. Their `work` field is the resolved community work's slug, or the pipeline's placeholder. +- **`description.json`** (Phase 1c) is audiosilo-meta's `model.Description`: the + book's spoiler-free description in the agent's own words, with the run's `work` + and `sources` (taken from its `characters.json`) and the share-alike licence, + checked against meta's `description.schema.json` before it is kept (how it is + written: [the description step](generating.md#the-description-step)): + +```json +{ + "work": "the-call-of-the-wild", + "text": "Buck, a large and comfortable dog on a Santa Clara Valley estate, ...", + "license": "CC-BY-SA-4.0", + "sources": [{ "type": "community" }] +} +``` + - **`bridge.json`** is the provenance: ```json @@ -48,6 +63,30 @@ from there, and never any audio. sizes and modification times the server gave (Unix seconds), or the one `.epub`. The server reads every field but `inputs`. +Beside a `description.json`, `bridge.json` carries an optional **`description`** +object (Phase 1c) saying how that file was made: + +```json +"description": { + "job_id": 14, + "generated_at": "2026-10-16T09:31:02Z", + "from": "opening_notes", + "chapters": 3, + "checked_against": "transcripts" +} +``` + +`job_id` is the job that wrote it, `from` always `opening_notes` (the only source a +description is written from), `chapters` how many opening chapters' notes the +agent saw, and `checked_against` what the no-verbatim check measured against: +`transcripts`, `book text` (an ebook's) or `none` (the source text was gone). Of +this object the server reads only `generated_at` (`AttachmentProvenance.DescriptionAt`, +the console's "written" time); an object of another shape costs that time, never +the file. A `sidecars.describe` job adds or replaces just this object in the +book's existing `bridge.json`, keeping every other field (the characters' run, its +`generated_at` included); with no `bridge.json` there (or not a regular file +holding a JSON object) it writes a fresh one of its own `kind`. + ## How the Bridge writes them 1. **Through the checked mapping only**: the book is located with @@ -63,11 +102,20 @@ The server reads every field but `inputs`. into place, `0644`, so the server never reads half a file. `characters.json` and `recaps.json` are written when the engine left them, `bridge.json` always. A file over 4 MiB fails the write (the server wouldn't read it). + `description.json` follows in a **second save** once the description step has + run (the characters and recaps never wait for it), only when the step got one. 5. Then `POST /bridge/rescan {library_id, path, mode: "attachments"}`. The same content is never written twice in a run (a content hash), and a book the server moved since the job began is written where it is now. -**Regenerate** writes over the files. +**Regenerate** writes over the files. Nothing deletes into a library yet, so a +regenerate whose own description step got none leaves the earlier +`description.json` in place, and its new `bridge.json` carries the existing +`description` object over (only beside a `description.json` that is a regular +file), so the kept file stays vouched for. A `sidecars.describe` job writes only +`description.json` and `bridge.json`, through the same write rules (mapping +re-checked, `book_not_writable` parks, the book's current path), then asks for the +same attachments rescan. ## How the server finds them @@ -77,8 +125,11 @@ ignore rules), it **lists it once, without going in**: it records the known regular files (a known name, alone or after a stem) with their sizes and modification times, by the folder holding it. A book's **signature** is its own part of that listing, `name:size:mtime_ns` for each of its known names present, -in the order `characters.json`, `recaps.json`, `bridge.json`, joined by commas -(`""` for none), its stem taken off. It is stored on the book row +in the order `characters.json`, `recaps.json`, `description.json`, `bridge.json`, +joined by commas (`""` for none), its stem taken off. Adding `description.json` +(Phase 1c) kept the other three in their order, so a book without one keeps the +signature it had byte for byte; a book that has one was read before the name was +known, so its signature changes and the next scan reads it again. It is stored on the book row (`attachments_sig`) with the `indexed_at` it was read for (`attachments_indexed_at`). @@ -109,7 +160,7 @@ the Bridge's call after writing, which a full re-index (ffprobe, tags, cover and fingerprint of every part) must not cost. It answers what is now indexed: ```json -{ "path": "Author/Book", "attachments": { "characters": true, "recaps": true, "bridge": true } } +{ "path": "Author/Book", "attachments": { "characters": true, "recaps": true, "description": true, "bridge": true } } ``` `404 not_indexable` when no indexed book is there. The route is in @@ -127,18 +178,19 @@ newer pipeline may add some) into the meta model types, then checked: |---|---| | `characters.json` | every character has an `id` and a `name` and a `reveal.chapter` of 0 or more; at most 2000 characters, 100 aliases each | | `recaps.json` | every recap has `text` and a `through.chapter` of 0 or more; at most 2000 recaps | -| both | every string at most 20,000 bytes; `work` and `license` at most 200 bytes; at most 100 `sources` | -| `bridge.json` | only a file that isn't a JSON object of the format fails; each value is trimmed and dropped (not the file) when it is over 200 bytes or holds a control character; `generated_at` is kept only as RFC 3339 (rewritten in UTC); a negative `job_id` is `0` | +| `description.json` | the `text`, trimmed (and stored trimmed), is 200 to 1500 code points (meta's `description.schema.json` bounds, which `TestDescriptionBoundsMatchTheSchema` reads from audiosilo-meta's embedded schemas) and holds no control character but a newline: the player shows it as it is (`metadata.ParseDescription`) | +| all three | every string at most 20,000 bytes; `work` and `license` at most 200 bytes; at most 100 `sources` | +| `bridge.json` | only a file that isn't a JSON object of the format fails; each value is trimmed and dropped (not the file) when it is over 200 bytes or holds a control character; `generated_at` (and the `description` object's `generated_at`) is kept only as RFC 3339 (rewritten in UTC); a negative `job_id` is `0` | One entry failing fails the whole file: the pipeline validates its own output, so a failure means the file isn't the pipeline's. A file that fails is logged and left out (never served), and its signature is recorded, so it isn't read again -until it changes. A characters file with no characters, or a recaps file with no -recaps and a blank summary, counts as none. +until it changes. A characters file with no characters, a recaps file with no +recaps and a blank summary, or a description with a blank text, counts as none. ## Storage -Migration `0048`: +Migrations `0048` and `0050`: - `books.attachments_sig` (`TEXT NOT NULL DEFAULT ''`) and `books.attachments_indexed_at` (`TEXT`, `NULL` until read): the signature and @@ -146,6 +198,8 @@ Migration `0048`: - `book_attachments` (`book_id` primary key, `REFERENCES books(id) ON DELETE CASCADE`; `characters`, `recaps`, `provenance` as checked JSON, `''` for a file that isn't there or can't be used; `updated_at`): one row per book with any. +- `book_attachments.description` (`0050`, `TEXT NOT NULL DEFAULT ''`): the checked + `description.json`, the same way. Both are derived from the files, so they are **rebuildable**: not durable state, never carried by a move (a moved book's folder takes its `.audiosilo/` along, and @@ -153,10 +207,15 @@ the scan reads it at the new path). See [the data model](../server/data-model.md#the-rebuildable-index). **`Book.generated`** (`generated: true` on the wire, omitted otherwise) says a -book's signature names a characters or recaps file. It is read from the narrow -column, so a list never joins `book_attachments`, and every book object carries -it (lists, search, `item`, recent, the next book's `book`): the player uses it to -ask `/meta` for a book with no ASIN or ISBN. +book's signature names a characters, recaps or description file (`sigParts`, an +allow-list: `bridge.json` alone, or a name a later server adds, is none). It is +read from the narrow column, so a list never joins `book_attachments`, and every +book object carries it (lists, search, `item`, recent, the next book's `book`): +the player uses it to ask `/meta` for a book with no ASIN or ISBN. Off the wire, +`Book.GeneratedParts` says which parts the signature names, so `/meta` and the +console's book page read only the columns a book has (`catalog.BookAttachments` +takes a parts mask; the estimate's `catalog.BookAttachmentParts` reads which +columns are non-empty without decoding them). ## The hidden-path rule @@ -183,6 +242,8 @@ file. cache, never into it: community content wins per dimension, generated content fills what the community work lacks (or is the whole work of a book with no match), and it never carries the community's CC BY-SA `attribution`. -`work.generated` names what came from the files. The shapes, the spoiler gates +`work.generated` names what came from the files. A description rides in its own +field, `work.generated_description`, never in `community_description`, and only +for a work with no community description. The shapes, the spoiler gates and what older players do with them are in [the reference](../server/api/reference.md#generated-content). diff --git a/docs-developers/bridge/development.md b/docs-developers/bridge/development.md index 6022e7d9..fda53c3f 100644 --- a/docs-developers/bridge/development.md +++ b/docs-developers/bridge/development.md @@ -78,7 +78,8 @@ internal/ verify/ bridge.verify, the proof job -- Phase 1a -- generate/ sidecars.generate: server job -> engine book, progress and parks, the - .audiosilo/ write, the heartbeat's estimates, the transcript retention + .audiosilo/ write, the heartbeat's estimates, the transcript retention; + sidecars.describe (Phase 1c): a description from a finished run's notes agentproto/ the staging protocol both sides share (names, JSON, request validation, regular-file reads); a leaf agentbox/ the Bridge's side: an agent.Runner that stages, waits, harvests @@ -126,7 +127,8 @@ Conventions that carry the design: - **Writes into a library** go only through typed operations, every path through `mapping.SafeJoin` against a **checked** mapping, written to a temporary file and renamed, umask 022, with a targeted rescan after (`POST /bridge/rescan`). A - library that isn't mapped and writable refuses them. Phase 1a's only write is a + library that isn't mapped and writable refuses them. The only write (Phase 1a, + and Phase 1c's `description.json`) is a book's [`.audiosilo/` files](attachments.md#how-the-bridge-writes-them); deletes, when a later phase needs them, move to `.audiosilo-trash/`. - **No agent CLI runs in the Bridge** (decision 15). The engine gets only @@ -144,8 +146,9 @@ speech recognition, the monthly budget with this month's spend, the per-book cap and the transcript retention), **Libraries** (each server library, the folder the Bridge uses and its last check, with **Check now**), **Jobs** (the jobs it ran, with cancel, a `bridge.verify` result as a file table, and for a -`sidecars.generate` job its stage, progress, cost, the park's code, hint and -retry time, and **Retry** while it is parked), **Settings** (its public address, +`sidecars.generate` or `sidecars.describe` job its stage, progress, cost, the park's code, hint and +retry time, and **Retry** while it is parked; once done, the files it saved and +whether a description came, and for a describe job what its check measured against), **Settings** (its public address, the check interval, the ASR fallback, the password), **Workers** (the [ASR workers](workers.md): add one, its token shown once in the commands to run, its state and figures, remove) and **Logs** (recent lines). It has no router: the tab rides the URL hash, and `#/jobs/` opens one job (the server's console links a parked job there, by its server job diff --git a/docs-developers/bridge/generating.md b/docs-developers/bridge/generating.md index e809d26a..b7391b8a 100644 --- a/docs-developers/bridge/generating.md +++ b/docs-developers/bridge/generating.md @@ -1,13 +1,17 @@ --- title: Characters and recaps (sidecars.generate) -description: "Bridge Phase 1a's job: who may queue it and where, its params, the estimate and the heartbeat's figures, how the Bridge runs it through the sidecars engine, its progress, parks, result and notifications, resuming after a restart, contributing, the budgets and the transcript retention." +description: "Bridge Phase 1a's job: who may queue it and where, its params, the estimate and the heartbeat's figures, how the Bridge runs it through the sidecars engine, its progress, parks, result and notifications; Phase 1c's own-words description step and the description-only job (sidecars.describe); resuming after a restart, contributing, the budgets and the transcript retention." --- `sidecars.generate` is the job that makes the Bridge useful: an admin presses **Generate characters and recaps** on a book, and the Bridge transcribes it (or reads its ebook), runs the sidecars engine's agent passes, validates the result, writes it into the book's [`.audiosilo/` folder](attachments.md) and asks the -server to read it, after which `/meta` serves it to the players. The job model it +server to read it, after which `/meta` serves it to the players. Since Phase 1c +the same run also writes an own-words, spoiler-free **description** of the book +([the description step](#the-description-step)), and `sidecars.describe` writes +one alone for a book generated earlier +([below](#a-description-on-its-own-sidecarsdescribe)). The job model it rides on (states, leases, attempts, cancel) is Phase 0's: [Pairing and jobs](pairing-and-jobs.md#the-job-model). Server code is `internal/bridge` (`jobs.go`, `estimate.go`, `status.go`), @@ -171,8 +175,10 @@ or parked. One run: 7. **Finishes**: on `done` it writes the attachments ([write rules](attachments.md#how-the-bridge-writes-them)), asks the server to read them (`POST /bridge/rescan {mode: "attachments"}`, up to three tries; the - server's next scan reads them anyway) and reports the result. An engine book - that fails fails the job with the engine's error. + server's next scan reads them anyway), then runs the + [description step](#the-description-step) and, when that gave one, saves + `description.json` beside them and asks again, and reports the result. An + engine book that fails fails the job with the engine's error. A book's later volume waits for the earlier one: the engine holds a series lock until a book reaches `ready`, so a sequel's agent stages run after its @@ -186,7 +192,7 @@ server stores it opaquely; at most 16 KiB): | Field | Meaning | |---|---| -| `step` | always set: the engine state (`inspecting`, `asr`, `fact_pass`, `synthesizing`, `auditing`, `contributing`...), or the job's own `waiting` (the engine book is queued, or the job waits on the engine, the server or the mapping), `writing` (writing into `.audiosilo/`) and `rescanning` (asking the server to read them) | +| `step` | always set: the engine state (`inspecting`, `asr`, `fact_pass`, `synthesizing`, `auditing`, `contributing`...), or the job's own `waiting` (the engine book is queued, or the job waits on the engine, the server or the mapping), `writing` (writing into `.audiosilo/`), `rescanning` (asking the server to read them) and, since Phase 1c, `describing` (the description step) | | `message` | the step in words ("Transcribing (12 of 40)"; "Needs attention: ..." while parked, "Paused: ..." while paused) | | `done`, `total` | the current stage's counts, when it has some | | `stage` | the engine state | @@ -196,7 +202,8 @@ server stores it opaquely; at most 16 KiB): | `park` | while parked only: `code`, `hint` (the Bridge's English advice) and `retry_at` (RFC 3339) when the park resumes by itself | The console and the player word the stages themselves (`bridge.stage.*` in both -UIs) and show `done/total`, the time left and the cost. +UIs; `describing` is "Writing the description") and show `done/total`, the time +left and the cost. ### States @@ -244,19 +251,26 @@ On `done` (at most 64 KiB): "library_id": 1, "path": "Author/Book", "source": "audio", - "files": ["characters.json", "recaps.json", "bridge.json"], + "files": ["characters.json", "recaps.json", "description.json", "bridge.json"], "characters": 41, "recaps": 38, "cost_usd": 12.5, "work_id": "the-way-of-kings", - "contributed": { "characters": "https://github.com/...", "recaps": "https://github.com/..." } + "contributed": { "characters": "https://github.com/...", "recaps": "https://github.com/..." }, + "description": true } ``` `files` are the names written into `.audiosilo/` (with the `.` prefix for a single-file book), `work_id` the resolved community work (or the pipeline's placeholder slug), `contributed` only with `contribute: issue`, naming each -dimension's intake issue. +dimension's intake issue. `description` (Phase 1c) says whether +`description.json` was written; when it is `false`, `description_error` says why +in a few plain words ("the agent account hit its usage limit", "the book has a +single chapter, so no description leaves out its ending"...), which the console +shows as "No description this time: ...". `cost_usd` includes the description +step's spend, a try that failed its checks included. An older Bridge sends +neither field. ### Notifications @@ -265,15 +279,190 @@ The **server** announces the jobs (not a Bridge route): `bridge.Service`'s committed, whatever made it (the Bridge's `PATCH`, or a lease that ran out on the last attempt, which fails the job), and `notify.BridgeJobEnded` records it: -- into `done`: **`bridge_job_finished`** (`Characters and recaps ready: `); +- into `done`: **`bridge_job_finished`** (`Characters and recaps ready: <title>`; + for a `sidecars.describe` job `Description ready: <title>`, "Listeners see it + in the player's About when the book has no other description."); - into `failed`, or into `needs_attention` with a park that isn't transient: **`bridge_job_failed`**, with the park code. `agent_rate_limited`, `agent_unavailable` and `asr_worker_waiting` are never announced, whatever their `retry_at`; a park that - changes its code in place is announced when the new code isn't transient. + changes its code in place is announced when the new code isn't transient. A + failed job's title names its kind (`Characters and recaps failed: <title>`, + `Description failed: <title>`). A report that leaves the state as it was (a keepalive, a parked job's progress) announces nothing. See [Notifications](../server/backups-and-notifications.md#events). +## The description step + +Phase 1c: a book's **own-words, spoiler-free description**, the paragraph a +stranger reads before deciding to listen, written by the agent and saved as +[`description.json`](attachments.md#the-formats). It is **local only**: +meta-community takes descriptions by pull request, so a `contribute: issue` run +never files one. The Bridge's code is `pipeline.Executor.Describe` +(`internal/pipeline/describe_stage.go`, its prompt `internal/agent/prompts/description.md`) +and `describeOnce` (`internal/generate/describejob.go`). + +### What the agent sees: the spoiler bound + +The bound is the **staging**, by construction: the agent's folder holds +`opening.md` and an empty `out/`, nothing else. No transcript, no ebook text, no +`knowledge-final.md` (the whole-book knowledge sheet carries the ending), no later +chapter's notes, no publisher copy; the prompt also forbids the web and the +agent's own memory of the book. `opening.md` is copied mechanically from the fact +pass's own chunks (`chunk_plan.json`): the `## Chapter N` sections of the chosen +files, their file numbering kept. + +The chosen files are the book's **opening**: those whose logical position is `0` +(front matter) or among its first **K** story positions, where + +- `n` is the number of distinct story positions the files hold (capped at the + classifier's chapter count, so one misheard late announcement can't widen the + window, and a sequel numbered on from 45 still has an opening); +- **K = max(2, ceil(n / 4))**, capped at **n - 1**: a quarter of the book, two + chapters at least, and **never the last chapter** (its ending). A book of two + chapters gets its first only; a book of **one** gets **no description** (the + step answers "the book has a single chapter"). + +Excluded edge files (an Audible intro, the closing credits) and end matter never +count. The positions are classified the way the sidecar stages did, so K counts +the same logical chapters the recaps use. That classification reads the +transcripts, which the retention deletes, so each one is kept beside the notes as +`chapter_positions.json` (keyed by the manifest it was made from); a book whose +transcripts are gone uses it, else its file numbers (a file's number is never +below its story chapter, so that bound is only less generous, never unsafe). + +### The checks + +The answer is `out/description.json`, exactly `{"text": "..."}`. Before it is kept, +every one of these must pass; a failure goes back to the agent as feedback for +another try: + +- the text, trimmed, is **200 to 1500** code points, at most **two paragraphs**, + with no control character (nor U+2028/U+2029) but a newline, and **no em dash**; +- a neutral reference-guide voice: no second person, no sentence ending in a + question mark, no sales words ("gripping", "must-read", "unforgettable"...), the + book's own title and series exempt (a book may be called *Me Before You*); +- the composed `model.Description` (the run's work and sources from its + `characters.json`, licence `CC-BY-SA-4.0`) passes meta's + `description.schema.json`; +- the **no-verbatim check**: an 8-word run shared with the book's source text + (audiosilo-meta's `extract.NGram`, the shingle the sidecars use) fails. It + measures against whatever text is still there: the transcripts (their text and + corrected layers) for an audio book, the ebook's chapter text for an ebook. + With none left it is skipped and recorded as such; a local fault of the check + itself (the disk, an unreadable transcript) fails the step rather than paying + for an agent retry. + +What it measured is recorded as `checked_against` (`transcripts`, `book text` or +`none`) in `bridge.json`'s `description` object. A run whose own work and sources +can't make a valid description fails before any spend; validation exhausted +means no description. + +### When it runs in a generate job + +An ebook's text is deleted as its book reaches `done` (a licensing rule), so the +step runs where the text is still there: + +1. **At the start of the engine's contributing stage** (which every book passes + through to resolve its community work, contributing or not): best effort and + once per book (a book with any description run, passed or failed, is never + paid for again, since a contributing park runs the stage again), only for a + book an open `sidecars.generate` job follows, without waiting out a rate + limit, any error only logged. +2. **At the job's finish** (the engine book `done`, or parked at + `contributing`), after the characters and recaps are saved: the job's pass + **reuses** the description already in the engine's work folder, re-composed + with the work and sources the contributing stage resolved, nothing spent, its + `checked_against` kept. With none, it tries once itself (and once more at + `done` when it first asked at a contributing park, so the description never + ships naming another work than the characters do), unless the book's latest + description run failed after spending: a run never pays twice. + +The step **never fails, parks or holds the job**: no rate-limit wait, an engine +that keeps the book for more than a few polls is given up on, and any error +leaves the job to finish with its characters and recaps, its result saying +`description: false` and why. A description it got follows in a second save. + +The step is not an engine stage. It records its own `describing` stage run on the +engine book (so its spend counts in the per-book and monthly budgets, the monthly +sum and the estimates, and both budgets are checked before any spend), runs +under the scheduler's hold on the book (no stage, delete, purge or sweep acts on +it meanwhile), and uses the agent model `agent.claude` routes as `describing` +(`sonnet` by default). The kept file is the engine's `sidecars/description.json`, +which the transcript purge keeps. + +## A description on its own: `sidecars.describe` + +Phase 1c's second kind (family `sidecars`, so the same `bridge.features` entry): +a description for a book the Bridge generated earlier, written from the notes +it kept (no new speech recognition). The notes outlive the transcripts: the +retention deletes the transcript text but keeps the fact notes, the chapter +positions and the sidecars. The player has no such action; the console offers it. + +**Queueing:** `POST /admin/bridge/jobs` with `kind: "sidecars.describe"`, a +`library_id`, a **required** book `path` and `params` `{}` or none (any field is +`400`; stored as `{}`). It writes, so `409 library_not_writable` as for a +generate job, and a Bridge that doesn't advertise it (a 1a or 1b Bridge) is +`409 kind_unavailable`. It is refused with **`409 generate_open`** while a +`sidecars.generate` job is open for the book (that run writes the description +itself); a second press returns the open describe job (its own dedupe key). +The other way round, a generate job queued while a describe job is open +**cancels** that job in the same transaction (at once when it is queued or +parked, else by `cancel_requested`, its error "a generate job for the book +replaced it"), since the generate run writes the description from fresher notes. +See [Kinds are allow-listed twice](pairing-and-jobs.md#kinds-are-allow-listed-twice). + +**The console** (`admin-ui/src/features/bridge/describe-section.tsx`): the book +page's Community card has a **Description** part. It shows a generated +description (from the admin book detail's `generated_description`) with the +"Generated on this server" mark and its time, clamped behind **Show more**, and +says when listeners see another description in the player instead (the book's +own, or the community's). **Write a description** confirms, then queues the job +(`describeOffered`): offered only when the estimate says `can_describe` and +`writable`, the book has generated characters or recaps (their notes are the +source), no description from either side (`has.local.description`, +`has.community.description`, with `community_unknown` unset), no description of +its own, and no generate or describe job open (the estimate's `open_job` +included). The describe job's status shows in the same part. + +**The Bridge** (`DescribeJob`, the `pipeline` lane) prepares as a generate job +does (strict when new; a resumed job parks `library_not_writable`), then finds +the engine book whose notes describe the book: the latest at its source path, +else the latest generate job's book at its library path (a book that moved +while that job ran), else the run the book's own `bridge.json` names (a book that +moved after). It runs the description step until it writes one; the agent's parks +(rate limit, budget, credential, unavailable) are the job's park +(`needs_attention`, with `code`, `hint` and `retry_at`), tried again at +`retry_at` or on **Retry**. Then it saves `description.json` and `bridge.json` +([how](attachments.md#how-the-bridge-writes-them)) and asks for the attachments +rescan. It fails, with a sentence the console shows as it is, when: + +| Failure | Message | +|---|---| +| no engine book (never generated here, or removed) | The Bridge has no notes for this book (it was not generated here, or they were removed). Generate characters and recaps again to get a description. | +| a generate run still works on it | A generate job is still running for this book; it writes the description when it finishes. | +| a single-chapter book | This book has a single chapter, so no description could leave out its ending. | +| validation exhausted | Couldn't write a description that passed the checks. | +| the engine keeps the book | The engine is busy with this book; try again in a minute. | + +Its progress is `{step: describing | writing | rescanning | done, message, done, +total, stage: "describing", source, cost_usd, park?}`, and its result: + +```json +{ + "library_id": 1, + "path": "Author/Book", + "description": true, + "files": ["description.json", "bridge.json"], + "cost_usd": 0.18, + "checked_against": "none" +} +``` + +`description` is always `true` (the console's shared result parser reads it). +An audio book past its transcript retention, or an ebook (its text went at +`done`), is checked against nothing: `checked_against: "none"`. + ## Resuming after a restart Phase 0's rules hold (a running job is resumed under its claim, a lapsed claim is @@ -339,7 +528,11 @@ files that quote the transcript (the spelling candidates and reference matches, the QA reports, plan and accepted ledger, the tail verdicts, the correction and repair logs). It keeps what a sequel builds on (the facts in `knowledge-final.md`, the spelling ledger and rules, the series glossary), the -manifest and the sidecars. +fact notes a [`sidecars.describe`](#a-description-on-its-own-sidecarsdescribe) +reads, the description step's `chapter_positions.json`, the manifest and the +sidecars (`description.json` included). Each purge takes the engine's hold on +the book: the daily sweep leaves a held book (a description being written) for +its next run. ## The engine wiring diff --git a/docs-developers/bridge/index.md b/docs-developers/bridge/index.md index 342c8ecc..6b590162 100644 --- a/docs-developers/bridge/index.md +++ b/docs-developers/bridge/index.md @@ -20,7 +20,9 @@ into the book's [`.audiosilo/` folder](attachments.md) by the sidecars engine, w its AI agents in a separate [agent container](agent-container.md). **Phase 1b** added [ASR workers](workers.md): the same binary run natively where speech recognition is fast (a Mac's Metal), taking the transcription off the Bridge. -Podcasts, read-along, Audible backups and file moves arrive in later phases. +**Phase 1c** added own-words, spoiler-free [descriptions](generating.md#the-description-step): +every generate run writes one too, and `sidecars.describe` writes one alone for a +book generated earlier. Podcasts, read-along, Audible backups and file moves arrive in later phases. ::: ## What it is for @@ -144,8 +146,8 @@ the mark; see [generated content](../server/api/reference.md#generated-content)) - [Library mappings](mappings.md) - how the Bridge finds each library and proves it has the right one. - [Characters and recaps](generating.md) - `sidecars.generate`: queueing, the - estimate, the run, its progress, parks and result, budgets and the transcript - retention. + estimate, the run, its progress, parks and result, the description step and + `sidecars.describe`, budgets and the transcript retention. - [The .audiosilo folder](attachments.md) - where the results go, their formats, and how the server finds, checks and serves them. - [The agent container](agent-container.md) - where the AI agents run, and the diff --git a/docs-developers/bridge/pairing-and-jobs.md b/docs-developers/bridge/pairing-and-jobs.md index 52d126a8..f56543bb 100644 --- a/docs-developers/bridge/pairing-and-jobs.md +++ b/docs-developers/bridge/pairing-and-jobs.md @@ -106,7 +106,7 @@ pairing and after a mapping check finishes: | Field | What it carries | |---|---| | `version` | the Bridge's version | -| `kinds` | the job kinds it runs (`bridge.verify`, and `sidecars.generate` since Phase 1a) | +| `kinds` | the job kinds it runs (`bridge.verify`, `sidecars.generate` since Phase 1a, and `sidecars.describe` since Phase 1c) | | `libraries` | its last [mapping check](mappings.md) of each library | | `workers` | its [ASR workers](workers.md) that have connected at least once: `name`, `version`, `platform` (the GOOS/GOARCH and the speech recognition in one line, e.g. `darwin/arm64, mlx-whisper (metal)`) and `last_seen` | | `ui_url` | its own page as the local network reaches it (its `public_url` setting), which the console links to | @@ -128,7 +128,7 @@ minutes** old (`bridge.OnlineWindow`). Then, and only then, `GET /server` says `capabilities.bridge: true`, with a top-level `bridge: {version, features}` beside `capabilities`. `features` are the job families the Bridge runs **that the server knows**: the part of each known kind before its dot, so `bridge.verify` gives -`bridge` and `sidecars.generate` gives `sidecars` (a list, `[]` for none, never `null`). The server reuses the heartbeat it +`bridge` and `sidecars.generate` (or `sidecars.describe`) gives `sidecars` (a list, `[]` for none, never `null`). The server reuses the heartbeat it read for up to **5 seconds**, so `GET /server`, which every client calls often, rarely reads the database for it; a pairing or an unpair drops that copy at once. A client checks the flag **and** the feature it needs; a feature the Bridge doesn't @@ -271,14 +271,25 @@ Bridge can't advertise a kind into existence, and the server can't queue one the Bridge doesn't have. The server's table also says whether a kind **writes** into the library. Such a -kind (`sidecars.generate`) is queued only for a library the last heartbeat reports +kind (`sidecars.generate`, `sidecars.describe`) is queued only for a library the last heartbeat reports **mapped and writable**, else `409 library_not_writable`, after the dedupe (an open job is still returned). `bridge.verify` writes nothing and works on any library. +Two kinds write the same book's files, so the server keeps them apart +(`bridge.Service.Enqueue`, inside its transaction, after the dedupe): a +`sidecars.describe` job is refused with `409 generate_open` (`ErrGenerateOpen`) +while a `sidecars.generate` job is open for the book, which writes the description +itself when it finishes; and a `sidecars.generate` job queued while a +`sidecars.describe` job is open **cancels** that one (`cancelOpenTx`: at once when +it is queued or parked, else `cancel_requested`, its error "a generate job for the +book replaced it"). A generate job is never refused for an open describe job. See +[A description on its own](generating.md#a-description-on-its-own-sidecarsdescribe). + On the Bridge, each kind has a handler and a **lane**: how many jobs of that lane run at once. `light` has room for 2 (`bridge.verify`); `pipeline` for 25 (`sidecars.generate`, whose jobs mostly wait on the engine's own scheduler, which -decides what actually runs). +decides what actually runs, and `sidecars.describe`, which can wait a long time +on an agent park). ### Notifications diff --git a/docs-developers/contributing/documentation.md b/docs-developers/contributing/documentation.md index 0dbcb741..cbb80017 100644 --- a/docs-developers/contributing/documentation.md +++ b/docs-developers/contributing/documentation.md @@ -81,8 +81,8 @@ mappings: | Community chapters (`internal/chapteralign`, `internal/chaptercheck`, `catalog/communitychapters.go`, the Chapters card's panel) | [server/community-chapters.md](../server/community-chapters.md) + the Community chapters section of `/users/admin/books` (and `/users/admin/health` for the Health rows) | | Auth, invites, shares | [server/auth-and-security.md](../server/auth-and-security.md) + `/users/admin/users-and-invites`, `/users/admin/sharing` | | Manager features | `/users/manager/*` + [manager developer pages](../manager/overview.md) + `manager/` screenshots | -| The Bridge: the server's side (`auth/bridge.go`, `internal/bridge`, `handlers_bridge.go`, `handlers_bridge_estimate.go`, the `bridge` capability, the console's `features/bridge/`) or `audiosilo-bridge` (its config, images, compose example, mapping checks, job runner, page, the agent container, `sidecars.generate`) | [the Bridge developer pages](../bridge/index.md) (index, pairing and jobs, mappings, characters and recaps, the .audiosilo folder, the agent container, configuration, development) + the [reference](../server/api/reference.md#the-bridge) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the capability, the codes, the bridge token) + [auth-and-security.md](../server/auth-and-security.md#bridge-tokens) + [data-model.md](../server/data-model.md#the-bridge) + [backups-and-notifications.md](../server/backups-and-notifications.md#events) (the `bridge_job_*` events) + the [cross-repo contract](../architecture/cross-repo-contract.md#17-the-bridge-a-third-client) (after the workspace `CROSS-REPO.md` §23) + `/users/admin/bridge`, `/users/admin/generating-characters-and-recaps` and `/users/getting-started/running-a-bridge` + the `admin/settings-bridge*.png`, `admin/bridge-pair.png`, `admin/book-generate.png` and `admin/generate-dialog.png` screenshots | -| Generated characters and recaps: `.audiosilo/` (the server's `library/attachments.go`, `metadata/attachments.go`, `catalog/attachments.go`, `meta/generated.go`; the Bridge's `internal/generate`) or the player's generate action and mark (`src/components/bridge/`, `GeneratedMark`) | [bridge/attachments.md](../bridge/attachments.md) + [bridge/generating.md](../bridge/generating.md) + [server/scanner.md](../server/scanner.md#the-bridges-attachments) + the `/meta` [generated content](../server/api/reference.md#generated-content) + [frontend/book-page.md](../frontend/book-page.md#the-primary-action-and-the-action-row) + `/users/admin/generating-characters-and-recaps`, `/users/listening/recaps-and-characters` | +| The Bridge: the server's side (`auth/bridge.go`, `internal/bridge`, `handlers_bridge.go`, `handlers_bridge_estimate.go`, the `bridge` capability, the console's `features/bridge/`) or `audiosilo-bridge` (its config, images, compose example, mapping checks, job runner, page, the agent container, `sidecars.generate`, `sidecars.describe`) | [the Bridge developer pages](../bridge/index.md) (index, pairing and jobs, mappings, characters and recaps, the .audiosilo folder, the agent container, configuration, development) + the [reference](../server/api/reference.md#the-bridge) + [api/index.md](../server/api/index.md#capability-flags---gate-your-features) (the capability, the codes, the bridge token) + [auth-and-security.md](../server/auth-and-security.md#bridge-tokens) + [data-model.md](../server/data-model.md#the-bridge) + [backups-and-notifications.md](../server/backups-and-notifications.md#events) (the `bridge_job_*` events) + the [cross-repo contract](../architecture/cross-repo-contract.md#17-the-bridge-a-third-client) (after the workspace `CROSS-REPO.md` §23) + `/users/admin/bridge`, `/users/admin/generating-characters-and-recaps` and `/users/getting-started/running-a-bridge` + the `admin/settings-bridge*.png`, `admin/bridge-pair.png`, `admin/book-generate.png`, `admin/generate-dialog.png` and `admin/book-description.png` screenshots | +| Generated characters, recaps and descriptions: `.audiosilo/` (the server's `library/attachments.go`, `metadata/attachments.go`, `catalog/attachments.go`, `meta/generated.go`; the Bridge's `internal/generate`, `internal/pipeline/describe_stage.go`), the console's Description part (`describe-section.tsx`) or the player's generate action, mark and About (`src/components/bridge/`, `GeneratedMark`, `aboutContent`) | [bridge/attachments.md](../bridge/attachments.md) + [bridge/generating.md](../bridge/generating.md) + [server/scanner.md](../server/scanner.md#the-bridges-attachments) + the `/meta` [generated content](../server/api/reference.md#generated-content) + [frontend/book-page.md](../frontend/book-page.md#the-primary-action-and-the-action-row) (and its About) + `/users/admin/generating-characters-and-recaps`, `/users/listening/recaps-and-characters`, `/users/listening/book-page` (About) + the `admin/book-description.png` and `web-player/book-about-generated.png` screenshots | | Metadata mirror mode (`metadata.mode`, `internal/metamirror`, `internal/meta/mirror.go`, the Source card, the mirror panel on Health > System) | [server/configuration.md](../server/configuration.md#mirror-mode-metadatamode-mirror) + the mirror routes in [api/reference.md](../server/api/reference.md#the-local-copys-status-metadatamirror) + `/users/admin/server` (Keeping a local copy), `/users/admin/health` (the local copy), `/users/faq` (privacy) | | `pkg/query`, `pkg/query/querytest`, `pkg/release` in audiosilo-meta, or the artifact schema | [meta/overview.md](../meta/overview.md#public-go-packages) + [meta/data-model.md](../meta/data-model.md#the-compiled-artifact-and-schema-versioning) + the [cross-repo contract](../architecture/cross-repo-contract.md#14-community-metadata-a-three-repo-seam) (mirror-mode servers read the artifact directly) | | Meta schemas, `metaserve` API, or intake tooling | [meta developer pages](../meta/overview.md) (data model, API, contributing) - and the [cross-repo contract](../architecture/cross-repo-contract.md) when the server's `/meta` envelope is affected | diff --git a/docs-developers/frontend/book-page.md b/docs-developers/frontend/book-page.md index 6d4262e9..a69d2139 100644 --- a/docs-developers/frontend/book-page.md +++ b/docs-developers/frontend/book-page.md @@ -238,11 +238,20 @@ few values (its state, and the bookmark glyph: a bookmark inside the book marks and the tabs): - **About** - `BookAbout` (`book-about.tsx`), drawing the pure `aboutContent(book, - meta, t)`: the community's `community_description` (CC BY-SA, so the server's - `attribution` is rendered beside it with **Improve this**, opening - `attribution.source_url` or the work's `web_url`), else the server's `description` - (from `/item` only), else the work's core description; with none, a sentence naming - the author and narrator. The detail rows are the publisher (the server's + work, recording, t)`. `work` is the `bookMetaWork` value (the matched work, or an + unmatched book's generated one) and the one source of every work-level field, the + credit included; `BookAbout` takes it as a required prop, and `recording` (the + match's, from `matchedMeta`) gives the recording facts. The text is picked once, + in this order, into `source` (`AboutSource`): the community's + `community_description` (`'community'`: CC BY-SA, so the server's `attribution` + is rendered beside it with **Improve this**, opening `attribution.source_url` or + the work's `web_url`), else the server's `description` (from `/item` only) or the + work's core description (`'own'`), else the description a Bridge generated on this + server (`'generated'`, Bridge Phase 1c: `work.generated_description`, read only + when `work.generated.description` is set), which wears the "Generated on this + server" chip (`GeneratedChip`, `testID` `about-generated-mark`) under the eyebrow + and never the credit or **Improve this**; with none (`'fallback'`), a sentence + naming the author and narrator. The detail rows are the publisher (the server's `publisher`, else the recording's) and release date (the recording's, else the book's `published`), the work's first publication, the server's `language` (a code named through `Intl.DisplayNames` in the app's language where the platform has it, diff --git a/docs-developers/frontend/state-and-data.md b/docs-developers/frontend/state-and-data.md index 3ee1f342..773a9959 100644 --- a/docs-developers/frontend/state-and-data.md +++ b/docs-developers/frontend/state-and-data.md @@ -449,17 +449,20 @@ The meta-driven tabs are rendered from `src/components/library/book-meta.tsx` (the About card is the book page's own `BookAbout`, see [The book page](book-page.md#the-aside)): `BookMetaRecapsTab`, `BookMetaCharactersTab`, and `BookMetaSeriesTab` (one -horizontal rail per series family). `matchedMeta(meta, enabled)` narrows the -envelope once for the screen (the matched half only: About, the rails and the -AudioSilo Meta links need a real community work); `bookMetaWork(meta, enabled)` is -the work the Characters and Recaps tabs, their counts and the companion render: -the matched work, or an unmatched book's [generated work](../server/api/reference.md#generated-content) -(`matched: false` with a `work`, Bridge Phase 1a). `metaEnabledFor` asks `/meta` +horizontal rail per series family). Each field is read through one of two +readers, never both: `matchedMeta(meta, enabled)` narrows the envelope once for +the screen (the matched half only: the rails, the AudioSilo Meta links, the end +credits and About's recording facts need a real community match); +`bookMetaWork(meta, enabled)` is the work About's text and every work-level field, +the Characters and Recaps tabs, their counts and the companion render: the +matched work, or an unmatched book's [generated work](../server/api/reference.md#generated-content) +(`matched: false` with a `work`, Bridge Phase 1a), so an unmatched book's generated +description (Phase 1c) reaches About too. `metaEnabledFor` asks `/meta` for a book with an ASIN or ISBN **or** `generated: true`; an answer that is only generated content goes stale after 2 minutes (`META_GENERATED_ONLY_STALE_MS`) rather than the hour a community answer keeps. `work.generated` drives the -`GeneratedMark` chip, and `communityCredit` drops the CC BY-SA credit from a -generated dimension; `seriesRails(series, currentWorkId, picks)` builds +`GeneratedMark` chip (and About's `GeneratedChip` for `generated.description`), +and `communityCredit` drops the CC BY-SA credit from a generated dimension; `seriesRails(series, currentWorkId, picks)` builds one rail per family showing the picked reading order, drops the current work from it, and drops a rail only when **every** one of its orders is empty once the current work is removed (so switching order can never make the tab vanish). diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 9ef6b9be..c618af41 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -227,8 +227,9 @@ Failures a person can fix also carry a machine-readable **`code`** next to | `has_members` | `409` | `PATCH /admin/users/{id}` assigning a person who has household members of their own | | `invalid_keep_members` | `400` | `DELETE /admin/users/{id}?keep_members=` with an entry that isn't one of that person's members | | `kind_unavailable` | `409` | `POST /admin/bridge/jobs` (or `GET /admin/bridge/estimate`) for a kind the paired Bridge doesn't run, or before any Bridge has reported | -| `library_not_writable` | `409` | `POST /admin/bridge/jobs` for a kind that writes (`sidecars.generate`) in a library the Bridge's last heartbeat doesn't report mapped and writable | +| `library_not_writable` | `409` | `POST /admin/bridge/jobs` for a kind that writes (`sidecars.generate`, `sidecars.describe`) in a library the Bridge's last heartbeat doesn't report mapped and writable | | `cannot_contribute` | `409` | `POST /admin/bridge/jobs` for `sidecars.generate` with `contribute: "issue"` while the Bridge's last heartbeat doesn't say it can contribute (`estimates.sidecars.can_contribute`) | +| `generate_open` | `409` | `POST /admin/bridge/jobs` for `sidecars.describe` while a `sidecars.generate` job is open for the book (it writes the description when it finishes) | | `finished` | `409` | `POST /admin/bridge/jobs/{id}/cancel` on a job that already ended | | `not_held` | `409` | the Bridge's `PATCH /bridge/jobs/{id}` from a claim that no longer holds the job (it ended, was cancelled, its lease ran out, or it no longer exists) | | `no_heartbeat` | `409` | the Bridge's `GET /bridge/jobs/next` before its first heartbeat | @@ -437,7 +438,7 @@ the server: | Flag | Gates | |---|---| -| `bridge` | anything that needs the [Bridge](reference.md#the-bridge). It is `true` only while a Bridge is paired and its last heartbeat is under two minutes old, so it turns off when the Bridge stops. While it is `true`, a top-level `bridge: {version, features}` object sits beside `capabilities`: `features` lists the job families the Bridge runs that the server knows (`bridge`, and `sidecars` for generating characters and recaps; a list, never `null`). The server reuses the heartbeat it read for up to 5 seconds, so the flag can lag by that much. Check the flag **and** the feature you need: the player shows an admin **Generate characters and recaps** only with `bridge`, `sidecars` in `features` and `metadata` | +| `bridge` | anything that needs the [Bridge](reference.md#the-bridge). It is `true` only while a Bridge is paired and its last heartbeat is under two minutes old, so it turns off when the Bridge stops. While it is `true`, a top-level `bridge: {version, features}` object sits beside `capabilities`: `features` lists the job families the Bridge runs that the server knows (`bridge`, and `sidecars` for generating characters and recaps and, from a Phase 1c Bridge, descriptions; a list, never `null`). The server reuses the heartbeat it read for up to 5 seconds, so the flag can lag by that much. Check the flag **and** the feature you need: the player shows an admin **Generate characters and recaps** only with `bridge`, `sidecars` in `features` and `metadata` | Gate on the flag rather than on the server version: an older server answers `size=` and `narrator=` by ignoring them (full art, the unfiltered list) and the diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 1475aa8a..5dfa4e7a 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -854,7 +854,7 @@ probed; `added_at` when unknown. Every book response (lists, search, recent, | `abridged` | boolean | whether the recording is abridged. **Absent means unknown**, never "unabridged": an Audiobookshelf file and a community match only ever say `true` (both write `false` for "not recorded"), so `false` comes only from an admin edit | | `genres` | string[] | the book's genres: the files' genre tag (split on `;` and NUL, never `,`; placeholders such as `Audiobook`, `Speech`, `Spoken Word` and `Other` dropped), an Audiobookshelf `metadata.json`, or an admin edit. Each label once, compared case-insensitively, the first spelling kept; at most 50, each at most 64 characters. Sent on list and search pages too, so a client can filter a loaded library on the device | | `tags` | string[] | freeform tags, as `genres`, from an Audiobookshelf `metadata.json` or an admin edit only (no audio tag carries them) | -| `generated` | boolean | `true` when the book's `.audiosilo/` folder holds characters or recaps the [Bridge](#the-bridge) generated on this server (read from the book's attachment signature, so a file that failed its checks still counts); omitted otherwise. A client asks [`/meta`](#generated-content) for such a book even when it has no `asin` or `isbn` | +| `generated` | boolean | `true` when the book's `.audiosilo/` folder holds characters, recaps or (since Bridge Phase 1c) a description the [Bridge](#the-bridge) generated on this server (read from the book's attachment signature, so a file that failed its checks still counts); omitted otherwise. A client asks [`/meta`](#generated-content) for such a book even when it has no `asin` or `isbn` | A book at a range, here as the omnibus of Mistborn 1-3 that is also book 1 of a box-set series: @@ -1579,7 +1579,9 @@ client that ignores these fields is unaffected. `work.community_description` (`{ "text": …, "license": … }`, `license` omitted when unstated) is the community-written, spoiler-free description: part of the CC BY-SA layer, kept apart from `description` (the CC0 core's blurb) because the two -carry different licences. It is absent for most works. +carry different licences. It is absent for most works. A description generated +on this server never appears here: it rides in `work.generated_description` +([Generated content](#generated-content)). #### Attribution @@ -1674,59 +1676,93 @@ that the client treats as "nothing to show": #### Generated content -Characters and recaps the [Bridge](#the-bridge) generated for the book on this -server (its [`.audiosilo/` files](../../bridge/attachments.md), checked and -indexed by the scan) are laid into the answer **per request**, after the shared -cache and `local`, on the request's own copy: never into the cache. Community -content wins **per dimension**: +Characters, recaps and (since Bridge Phase 1c) the description the +[Bridge](#the-bridge) generated for the book on this server (its +[`.audiosilo/` files](../../bridge/attachments.md), checked and indexed by the +scan) are laid into the answer **per request**, after the shared cache and +`local`, on the request's own copy: never into the cache. Community content wins +**per dimension**: - **A matched book.** When the community work has no characters, `work.characters` are the generated ones; when it has no recaps, `work.recaps` are the generated recaps and, when it has no `recap_summary` either, `work.recap_summary` the generated `{in_short, ending}` (omitted when - both are blank). `work.generated` then names what came from the files: + both are blank); when it has no `community_description`, + `work.generated_description` is the generated description (below). + `work.generated` then names what came from the files: ```json - "generated": { "characters": false, "recaps": true, "generated_at": "2026-10-16T09:12:44Z" } + "generated": { "characters": false, "recaps": true, "description": true, "generated_at": "2026-10-16T09:12:44Z" } ``` - `recaps` covers the recap list and the summary alike; `generated_at` is the - provenance's time, omitted when unknown; the whole object is omitted when - nothing was generated (or the community has both). + `recaps` covers the recap list and the summary alike; the three booleans are + always sent together (`description` since Phase 1c); `generated_at` is the + provenance's time (`bridge.json`'s `generated_at`, or its `description` + object's when the description is all the object names, so a description + written later by a `sidecars.describe` job shows its own time), omitted when + unknown; the whole object is omitted when nothing was generated (or the + community has it all). - **A book with no match** (no `asin`/`isbn`, or none upstream) but generated content answers `matched: false` **with a work** of its own: `id` empty, - `title` the book's, `authors` `[]`, the generated `characters`, `recaps` and - `recap_summary`, and `generated`: + `title` the book's, `authors` `[]`, the generated `characters`, `recaps`, + `recap_summary` and `generated_description`, and `generated`: ```json { "matched": false, "work": { "id": "", "title": "The Book", "authors": [], "language": "", - "characters": [ … ], "recaps": [ … ], "generated": { "characters": true, "recaps": true } } } + "characters": [ … ], "recaps": [ … ], + "generated": { "characters": true, "recaps": true, "description": false } } } + ``` + + A book whose only generated file is its description answers just that: + + ```json + { "matched": false, "work": { "id": "", "title": "The Book", "authors": [], "language": "", + "generated_description": { "text": "…" }, + "generated": { "characters": false, "recaps": false, "description": true, + "generated_at": "2026-10-16T09:31:02Z" } } } ``` - **An upstream failure** is a `502` only when the book has no generated content; with some, the answer is the unmatched one above (the server's own content doesn't depend on the upstream). +**The generated description** (Phase 1c) is the book's own-words, spoiler-free +description, `work.generated_description: { "text": … }` (no licence, no +attribution). It rides in its **own** field, never in `community_description`: +the shipped 2.0 app shows `community_description` first in About, over the +book's own description, and credits it to the community, so a generated text +there would replace real blurbs on old apps; those apps ignore the new field. +The server fills it only when the community work has no `community_description`; +the work's core `description` (the publisher's kind) doesn't block it, since the +player orders those itself (the book's own description, then the core one, then +the generated one). + **Attribution is the community's alone.** `work.attribution` is decided from the community content before anything generated is laid in, so generated -characters or recaps never make the server claim the CC BY-SA credit, and a work -whose only content is generated carries none. A client shows the credit beside -the community's dimension only (the player's `communityCredit`). +characters, recaps or a description never make the server claim the CC BY-SA +credit, and a work whose only content is generated carries none. A client shows +the credit beside the community's dimension only (the player's `communityCredit`). -**Spoiler gates:** `spoilers=hide` gates the generated parts exactly as it -gates the community's (a gate that leaves an unmatched book nothing answers -plain `{ "matched": false }`). `include=previous` stays community only. +**Spoiler gates:** `spoilers=hide` gates the generated characters and recaps +exactly as it gates the community's (a gate that leaves an unmatched book nothing +answers plain `{ "matched": false }`), and **keeps** the generated description: +it is spoiler-free by construction (the Bridge writes it from the opening +chapters' notes only). `include=previous` stays community only. Metadata off, `/meta` is `404` as ever: generated content needs metadata on, as the generate action does. A book whose row names no generated file -(`generated` absent) isn't looked up, so this costs nothing for other books. +(`generated` absent) isn't looked up, so this costs nothing for other books, and +only the parts the community work leaves to fill **and** the book's signature +names are read (a work with community characters and recaps reads just the +description). **Older players.** A player that predates the field ignores everything beside `matched: false`, so it shows nothing for an unmatched book. For a matched book it shows the merged work as it would the community's: the generated dimension without a mark, and the work's community credit beside it when the community has the other dimension. The owner kept that behaviour as built (Bridge Phase -1a): newer players mark the generated dimension and drop the credit there. +1a): newer players mark the generated dimension and drop the credit there. No +older player shows a generated description: it is a field they don't know. | Status | Meaning | |---|---| @@ -4482,7 +4518,9 @@ book path). "checked_at": "2026-10-03T09:20:44.10Z" }, "community_checking": false, - "match_query": "The Martian: Classroom Edition Andy Weir" + "match_query": "The Martian: Classroom Edition Andy Weir", + "generated_description": { "text": "Mark Watney, a botanist on the third crewed mission to Mars, ...", + "generated_at": "2026-10-16T09:31:02Z" } } ``` @@ -4562,6 +4600,12 @@ book path). the box: the match itself always sends the book's own facts, and `q` is what the admin then searches for. Always present (`""` for a book with nothing to say). +- `generated_description` - the description the [Bridge](#the-bridge) generated + for the book (its checked `.audiosilo/description.json`, Phase 1c), with + `generated_at` the time `bridge.json`'s `description` object gives (omitted when + unknown). Omitted when the book has none. It is read only when the book's + attachment signature names a description, and never the characters or recaps. + The console's Community card shows it in its Description part. | Status | Meaning | |---|---| @@ -7072,7 +7116,7 @@ booleans): - `version` - the version the Bridge reported. - `features` - the job families the Bridge runs that this server knows: the part of each known kind before its dot (`bridge.verify` is `bridge`, `sidecars.generate` - is `sidecars`), sorted, each once; + and `sidecars.describe` are `sidecars`), sorted, each once; always a list (`[]` for none), never `null`. A kind the Bridge advertises that the server doesn't know adds nothing. @@ -7223,9 +7267,10 @@ Every job route, the console's and the Bridge's, answers jobs in one shape: its library. - `params` - the kind's parameters as the server normalised them (`bridge.verify` stores a `hold_seconds` of 0 as `{}`; `sidecars.generate` stores every field with - its default); `progress` and `result` - JSON objects the Bridge reported (`{}` + its default; `sidecars.describe` stores `{}`); `progress` and `result` - JSON objects the Bridge reported (`{}` until it does; the server stores them as they are: `sidecars.generate`'s shapes - are in [Characters and recaps](../../bridge/generating.md#progress)); `error` - + are in [Characters and recaps](../../bridge/generating.md#progress), `sidecars.describe`'s in + [A description on its own](../../bridge/generating.md#a-description-on-its-own-sidecarsdescribe)); `error` - why it failed or was parked (`""` otherwise). - `state` - `queued`, `claimed`, `running`, `done`, `failed`, `needs_attention` or `cancelled` (see [Job states](#job-states)). @@ -7297,7 +7342,7 @@ another page (the server reads one row past the page to know): pass it as | Field | Meaning | |---|---| | `kind` | the job kind: one the server knows **and** the paired Bridge advertised in its last heartbeat (kinds are allow-listed twice) | -| `library_id` | the library, when the kind is about one (both kinds need it) | +| `library_id` | the library, when the kind is about one (every kind needs it) | | `path` | an indexed book in that library (optional or required, per kind); the job is about the book's own path | | `params` | the kind's parameters, checked per kind (unknown fields refused) | @@ -7319,7 +7364,17 @@ The kinds: into the library, so it is queued only for a library the last heartbeat reports mapped and writable, and `contribute: "issue"` only while the heartbeat's `estimates.sidecars.can_contribute` is true. See - [Characters and recaps](../../bridge/generating.md). + [Characters and recaps](../../bridge/generating.md). Queuing it while a + `sidecars.describe` job is open for the book cancels that job (at once when it + is queued or parked, else by asking the Bridge), since this run writes the + description too. +- **`sidecars.describe`** (Phase 1c) writes a book's own-words description from + the notes the Bridge kept when it generated the book (no new speech + recognition) into its `.audiosilo/` folder. It needs `library_id` **and** a book + `path`, and takes `params` `{}` or none (any field is refused; stored as `{}`). + It writes, so the same mapped-and-writable rule applies, and it is refused + while a `sidecars.generate` job is open for the book. See + [A description on its own](../../bridge/generating.md#a-description-on-its-own-sidecarsdescribe). The Bridge may be offline: the job waits for it. Response `201` with the new job, or `200` with the open job already there for that kind, library and path (no @@ -7333,7 +7388,7 @@ the console says so. A new job is audited as `bridge.job`. | `200` | the open job of that kind for that library and path | | `400` | `invalid request` (not JSON, an unknown field); or a check of the kind, each message starting `invalid: `: `unknown kind "…"`, `library_id is required`, `this kind takes no library or path`, `this kind takes no path`, `path is required`, `params too large`, or a params error (`hold_seconds must be 0-600`, `source must be auto, audio or ebook`, `contribute must be local or issue`, a `work_id` too long or with a control character, an unknown field) | | `404` | `library not found`; `code: "book_not_found"` - the path is not an indexed book | -| `409` | `code: "kind_unavailable"` - no Bridge has reported, or the paired one doesn't run that kind; `code: "library_not_writable"` - a kind that writes, for a library the last heartbeat doesn't report mapped and writable; `code: "cannot_contribute"` - `sidecars.generate` with `contribute: "issue"` while the Bridge can't contribute. The last two are checked after the dedupe: an open job of the book is still returned (`200`) | +| `409` | `code: "kind_unavailable"` - no Bridge has reported, or the paired one doesn't run that kind (a Bridge from before Phase 1c doesn't run `sidecars.describe`); `code: "library_not_writable"` - a kind that writes, for a library the last heartbeat doesn't report mapped and writable; `code: "cannot_contribute"` - `sidecars.generate` with `contribute: "issue"` while the Bridge can't contribute; `code: "generate_open"` - `sidecars.describe` while a `sidecars.generate` job is open for the book ("a generate job is still open for this book; it writes the description when it finishes"). The last three are checked after the dedupe: an open job of the same kind for the book is still returned (`200`) | ### `GET /api/v1/admin/bridge/jobs/{id}` @@ -7357,8 +7412,9 @@ Bridge (`cancel_requested`; see [Leases](#leases-attempts-and-cancelling)). Answ *Admin* (an API key works). What queueing `sidecars.generate` for a book would take: the confirmation dialogs (the console's and the player's) read it, and the -console's book card and series batch read its brief form. How each figure is -worked out is in +console's book card and series batch read its brief form (the card's +**Write a description** reads its `can_describe` and `has.*.description` too). +How each figure is worked out is in [Characters and recaps](../../bridge/generating.md#the-estimate). | Param | Meaning | @@ -7381,9 +7437,10 @@ worked out is in "figures": true, "duration_seconds": 173621, "writable": true, + "can_describe": true, "has": { - "community": { "characters": false, "recaps": false }, - "local": { "characters": false, "recaps": false } + "community": { "characters": false, "recaps": false, "description": false }, + "local": { "characters": false, "recaps": false, "description": false } }, "earlier": [ { "library_id": 1, "path": "Brandon Sanderson/The Stormlight Archive/The Way of Kings", @@ -7402,9 +7459,10 @@ worked out is in | `subscription`, `agent_ready`, `can_contribute` | the Bridge's: its agent credential is a subscription (the cost is notional), the agent container is ready, and it can offer results to the community (`contribute: "issue"`) | | `figures` | the last heartbeat carried estimates. Without them every field above is a default, so a false `agent_ready` means "not known yet" | | `writable` | the Bridge last reported the library mapped and writable (else queueing is `409 library_not_writable`) | -| `has.community` | what the book's community work carries (metadata on and the book matched; `false` otherwise) | +| `can_describe` | the Bridge is online and runs `sidecars.describe` (Phase 1c; a 1a or 1b Bridge doesn't). With `brief=1` too | +| `has.community` | what the book's community work carries (metadata on and the book matched; `false` otherwise): `characters`, `recaps` and `description`, which is its `community_description` **or** its core `description` (the player shows either before a generated one, so a generated description would never be read) | | `has.community_unknown` | `true` when the community couldn't be asked right now, so `has.community` is no answer (never read it as "none"); omitted otherwise | -| `has.local` | what the book's indexed [`.audiosilo/` files](../../bridge/attachments.md) carry | +| `has.local` | what the book's indexed [`.audiosilo/` files](../../bridge/attachments.md) carry: `characters`, `recaps` and `description` | | `earlier` | the books of the same library before this one in its first numbered series (any spelling), with no recaps generated here and, metadata on, none in the community (a book the community couldn't be asked about is left out): the nearest 10, in reading order, each with its own `asr_hours` and `agent_cost_usd` (costed from its audio). `[]` for none, and with `brief=1` | | `open_job` | the book's open `sidecars.generate` [job](#the-job-object); omitted when there is none | @@ -7644,11 +7702,12 @@ this never indexes a path on demand. detection. - **`attachments`** re-reads only the book's [`.audiosilo/` files](../../bridge/attachments.md), never its audio: the - Bridge's call after writing characters and recaps. The path is the book's, or a - path inside it. It answers what is now indexed: + Bridge's call after writing characters, recaps or a description. The path is + the book's, or a path inside it. It answers what is now indexed (`description` + since Phase 1c): ```json -{ "path": "Andy Weir/Project Hail Mary", "attachments": { "characters": true, "recaps": true, "bridge": true } } +{ "path": "Andy Weir/Project Hail Mary", "attachments": { "characters": true, "recaps": true, "description": true, "bridge": true } } ``` The re-read runs detached from the request for up to 10 minutes, like the diff --git a/docs-developers/server/backups-and-notifications.md b/docs-developers/server/backups-and-notifications.md index 71bbe008..0b613706 100644 --- a/docs-developers/server/backups-and-notifications.md +++ b/docs-developers/server/backups-and-notifications.md @@ -213,8 +213,11 @@ A Bridge event is reported once per move, after the move has committed, on a context the request's end doesn't cancel. Its message names the book (its title, else its path, else its library): `bridge_job_finished` for a `sidecars.generate` job reads `Characters and recaps ready: <title>` ("Listeners see them in the -player now."); `bridge_job_failed` reads `A Bridge job needs attention: <title>` -(warning colour) or `Characters and recaps failed: <title>`, its text the park +player now."), and for a `sidecars.describe` job (Phase 1c) `Description ready: +<title>` ("Listeners see it in the player's About when the book has no other +description."); `bridge_job_failed` reads `A Bridge job needs attention: <title>` +(warning colour) or `<kind> failed: <title>` (`Characters and recaps`, +`Description`, `Bridge check`, else `Bridge job`; `notify.bridgeKindWords`), its text the park code in words (`book_not_writable`, `budget_exceeded`, `agent_credential_refused`, `core_needed`; anything else "See the job in Settings > Bridge."). Both link to `/server?topic=bridge`. See [Characters and recaps](../bridge/generating.md#notifications). diff --git a/docs-developers/server/data-model.md b/docs-developers/server/data-model.md index 2dc70304..1e2b8f67 100644 --- a/docs-developers/server/data-model.md +++ b/docs-developers/server/data-model.md @@ -276,7 +276,7 @@ for nothing) with `abs_sig` (the file's size and mtime, `''` for no file) and read it; see [the layers](#metadata-overrides-and-effective-values)), `attachments_sig` (the signature of the Bridge's `.audiosilo/` files for the book, `name:size:mtime_ns` per known file, joined; `''` for none; `Book.generated` is -read from it) and `attachments_indexed_at` (the `indexed_at` they were read for, +read from it: a characters, recaps or description file) and `attachments_indexed_at` (the `indexed_at` they were read for, `NULL` until read; see [The .audiosilo folder](../bridge/attachments.md#storage)), `more_series` (the effective list of the other series the book is in beyond `series`, as JSON `[{"name","position"}]` plus `"position_end"` for a range, `'[]'` for none: only an edit, a @@ -456,9 +456,9 @@ the admin list's `genre` / `tag` filters (`catalog.labelFilters`, kept to the library by `library_id`) and the facet counts find a label in any case by an index seek. -**`book_attachments`** *(0048)* - the Bridge's generated files for a book, checked: -`book_id` (primary key, FK to `books` `ON DELETE CASCADE`), `characters`, `recaps` -and `provenance` (JSON as checked against audiosilo-meta's formats, `''` for a +**`book_attachments`** *(0048; `description` in 0050)* - the Bridge's generated files for a book, checked: +`book_id` (primary key, FK to `books` `ON DELETE CASCADE`), `characters`, `recaps`, +`description` and `provenance` (JSON as checked against audiosilo-meta's formats, `''` for a file that isn't there or can't be used) and `updated_at`; a row only for a book with any. Derived from the book's `.audiosilo/` files like `abs_file`, so rebuildable and never carried by a move. `/meta` lays it into its answer per @@ -1178,6 +1178,7 @@ The migration history so far: | 0047 | `bridge_jobs` | `bridge_jobs` and the one-row `bridge_status`: see [The Bridge](#the-bridge) | | 0048 | `book_attachments` | `books.attachments_sig` (`TEXT NOT NULL DEFAULT ''`), `books.attachments_indexed_at` (`TEXT`, `NULL` on every existing row) and `book_attachments`: the Bridge's `.audiosilo/` files. `NULL` reads as unchecked, so the next scan reads the attachments of every book that has some, without re-indexing it (`catalog.SetAttachments`) | | 0049 | `book_labels` | `books.subtitle`, `publisher`, `language`, `genres` and `tags` (`TEXT NOT NULL DEFAULT ''`), `books.abridged` (`INTEGER`, `NULL` = unknown), `books.genres_checked` (`INTEGER NOT NULL DEFAULT 0`) and `book_labels` (see [Labels](#labels-genres-and-tags)); recreates `books_fts` with `genres, tags` columns, every row copied over (no book has a label yet), and sets `books.abs_indexed_at` to `NULL` on every book with a `metadata.json` (`abs_sig <> ''`). The next scan then reads each of those files again and each book's genre tag once (`catalog.SetGenres`), without re-indexing | +| 0050 | `book_attachments_description` | `book_attachments.description` (`TEXT NOT NULL DEFAULT ''`): the Bridge's checked `.audiosilo/description.json` (Phase 1c), derived like the rest of the row. No data moves: a book read before it had no `description.json` in its signature (the name was unknown then), so a book that has one now has a changed signature and the next scan reads it again, without re-indexing | ## SQLite choices diff --git a/docs-developers/server/scanner.md b/docs-developers/server/scanner.md index 5c310a7c..827fc3ae 100644 --- a/docs-developers/server/scanner.md +++ b/docs-developers/server/scanner.md @@ -570,9 +570,9 @@ in the same transaction when they should stand. ## The Bridge's attachments -A book's hidden **`.audiosilo/`** folder holds the characters, recaps and -provenance the [Bridge](../bridge/index.md) generated for it (`characters.json`, -`recaps.json`, `bridge.json`; a single-file book's carry its file stem first, +A book's hidden **`.audiosilo/`** folder holds the characters, recaps, +description and provenance the [Bridge](../bridge/index.md) generated for it (`characters.json`, +`recaps.json`, `description.json` since Phase 1c, `bridge.json`; a single-file book's carry its file stem first, `Book.characters.json`, in a `.audiosilo/` beside it). They are not a metadata layer: nothing in the effective row reads them, and `/meta` serves them. The scan finds them the way it finds an ABS `metadata.json`: @@ -601,8 +601,8 @@ finds them the way it finds an ABS `metadata.json`: Each file is read as a regular file only, never through a link, at most 4 MiB, and checked against audiosilo-meta's formats; one that fails is logged, left out and not read again until it changes, and one that can't be read right now keeps -what is stored. The details, and the storage (`book_attachments`, migration -`0048`), are in [The .audiosilo folder](../bridge/attachments.md#how-the-server-finds-them). +what is stored. The details, and the storage (`book_attachments`, migrations +`0048` and `0050`), are in [The .audiosilo folder](../bridge/attachments.md#how-the-server-finds-them). ## Covers diff --git a/docs-developers/server/web-ui.md b/docs-developers/server/web-ui.md index 4c546cc7..e14bb23e 100644 --- a/docs-developers/server/web-ui.md +++ b/docs-developers/server/web-ui.md @@ -733,10 +733,11 @@ CSP-sensitive work against a real build served by Go. The console's own gate is stopping. `bridge-model.ts` holds the pure rules (tested). Since Phase 1a the status card adds the heartbeat's `estimates.sidecars` as Agent (backend and Ready), Paid by, This month (spend against the cap) and Estimates from rows, and - the job list words a `sidecars.generate` job with `GenerateStatus` (its stage, + the job list words a `sidecars.generate` job (and, since Phase 1c, a + `sidecars.describe` job: `isBookKind`) with `GenerateStatus` (its stage, counts, time left and cost; a park's reason with **Open in the Bridge**, a link - to `<ui_url>#/jobs/<id>`; the counts and cost once done), its path linking to the - book's page. + to `<ui_url>#/jobs/<id>`; the counts and cost once done, and for a generate job + whether its description came), its path linking to the book's page. - **Generate characters and recaps** (`features/bridge/generate-*.tsx`, `use-generate.ts`, `series-generate-dialog.tsx`; the pure rules in `generate-model.ts`, tested) - offered while `generateAvailable` (`metadata`, @@ -757,6 +758,15 @@ CSP-sensitive work against a real build served by Go. The console's own gate is brief estimate, four at a time, and queues the books with no recaps and no open job, earliest first, counting the ones it leaves out. See [Characters and recaps](../bridge/generating.md). +- **A generated description** (Phase 1c, `features/bridge/describe-section.tsx`) - + `DescribeSection` follows `GenerateSection` in the Community card: the admin + book detail's `generated_description` with the "Generated on this server" mark + and its time, clamped past about four lines (`clampsDescription`) behind Show + more, and a note when the player's About shows another description instead + (the book's own, or the community's); the book's describe job through + `GenerateStatus`; and **Write a description**, which confirms (`DescribeDialog`) + and queues `sidecars.describe` when `describeOffered` allows it. See + [A description on its own](../bridge/generating.md#a-description-on-its-own-sidecarsdescribe). - **The bell** (`components/shell/notifications-bell.tsx`) - `GET /admin/events` (`useServerEvents`: the newest 20, every minute), eight listed in a popover, each worded by `lib/server-events.ts` `describeEvent` and linking to where it diff --git a/docs-users/admin/books.md b/docs-users/admin/books.md index 05116980..6898b3ad 100644 --- a/docs-users/admin/books.md +++ b/docs-users/admin/books.md @@ -492,6 +492,11 @@ once it has generated ones), and while the Bridge works on it, the job's step, progress, time left and cost so far, with **Cancel**. The book doesn't need to be matched. See [Generating characters and recaps](generating-characters-and-recaps.md). +Under it, a **Description** part shows the short, spoiler-free description the +Bridge wrote for the book, if it has one, and offers **Write a description** for a +book generated before descriptions existed. See +[A description for the book](generating-characters-and-recaps.md#a-description-for-the-book). + ![The Community metadata card with Generate characters and recaps](/img/screenshots/admin/book-generate.png) ## Authors and narrators diff --git a/docs-users/admin/bridge.md b/docs-users/admin/bridge.md index d459affb..8eb9fe55 100644 --- a/docs-users/admin/bridge.md +++ b/docs-users/admin/bridge.md @@ -174,7 +174,10 @@ step, how far it has got, the time left and the cost so far), and once it is don how many characters and recaps it wrote and what it cost. One that **Needs attention** says why, with **Open in the Bridge**, which opens that job on the Bridge's own page, where **Retry** sends it on once you've fixed the cause. See -[When a job pauses](generating-characters-and-recaps.md#when-a-job-pauses). +[When a job pauses](generating-characters-and-recaps.md#when-a-job-pauses). Once +done it also says whether a description came with them. A **Description** job +([Write a description](generating-characters-and-recaps.md#writing-a-description-for-an-older-book)) +shows the same way, and "Description written" once done. **Cancel** stops a job. A job that is waiting is cancelled at once; one the Bridge is working on shows **Cancelling** until the Bridge stops it, within a few seconds. diff --git a/docs-users/admin/generating-characters-and-recaps.md b/docs-users/admin/generating-characters-and-recaps.md index adfc777e..e0de8384 100644 --- a/docs-users/admin/generating-characters-and-recaps.md +++ b/docs-users/admin/generating-characters-and-recaps.md @@ -1,6 +1,6 @@ --- title: Generating characters and recaps -description: "Have your Bridge write character sheets and chapter recaps for a book the community hasn't covered yet: who can, where the button is, the confirmation and its estimate, keeping them on your server or contributing them, following a run, what to do when one pauses, where the results appear, costs and the monthly cap, and how long transcripts are kept." +description: "Have your Bridge write character sheets and chapter recaps for a book the community hasn't covered yet: who can, where the button is, the confirmation and its estimate, keeping them on your server or contributing them, following a run, what to do when one pauses, where the results appear, the spoiler-free description it writes too (and Write a description for an older book), costs and the monthly cap, and how long transcripts are kept." --- The community database at [meta.audiosilo.app](https://meta.audiosilo.app) gives @@ -9,7 +9,9 @@ every book. For one it hasn't covered, your [Bridge](bridge.md) can write them itself: it transcribes the book (or reads its ebook), has an AI agent read it for the people and the events, checks the result for mistakes and spoilers, and saves it with the book. Some hours later the book's **Characters** and **Recaps** tabs -appear in the player, with nothing else to do. +appear in the player, with nothing else to do. It also writes a short, +spoiler-free [description](#a-description-for-the-book) of the book, which the +player shows when nothing else describes it. ## Who can, and what it needs @@ -105,7 +107,7 @@ agent reads it in several passes. You can close the page; the Bridge carries on. - **In the console**, the book's **Characters and recaps** part shows the job: its state, what it is doing now ("Transcribing", "Writing characters and - recaps", "Checking for mistakes"...), how far it has got, about how long is + recaps", "Checking for mistakes", "Writing the description"...), how far it has got, about how long is left and what it has cost so far, with **Cancel**. The job also appears under **Jobs** in [Settings > Bridge](bridge.md#jobs), linked to the book. - **In the player**, the same status sits under the book's buttons: @@ -145,7 +147,8 @@ reason shown; in the player, **Try again** queues it afresh. Two events in [Notifications](notifications.md#what-to-send) cover the Bridge's jobs: **A Bridge job finished** ("Characters and recaps ready" with the book's -title) and **A Bridge job failed or needs attention**. Pauses that end by +title, or "Description ready" for [Write a description](#writing-a-description-for-an-older-book)) +and **A Bridge job failed or needs attention**. Pauses that end by themselves (the agent's usage limit, the agent container briefly away) aren't announced. Both show in the console's bell whether or not you send them anywhere. @@ -168,7 +171,8 @@ longer shown (and the console no longer offers the button), though they stay in the book's folder. The files live in a hidden **`.audiosilo`** folder inside the book's folder: -`characters.json`, `recaps.json` and `bridge.json` (what made them, and when). +`characters.json`, `recaps.json`, `description.json` (the +[description](#a-description-for-the-book)) and `bridge.json` (what made them, and when). For a book that is a single audio file, they sit in a `.audiosilo` folder beside it, named after the file (`The Book.characters.json`). The server never shows that folder as a book or plays anything from it, and its next scan picks up any change @@ -179,6 +183,79 @@ The current apps and web player show generated characters and recaps for every book. An app version from before this feature shows them only for a book that is matched with community metadata, without the mark. +## A description for the book + +Besides the characters and recaps, every run writes a short **description** of +the book: the paragraph someone reads before deciding to listen, where a +publisher's blurb would go. + +- **Spoiler-free.** The agent writes it from its notes on the book's **opening + chapters only** (about the first quarter of the book, never its last chapter), + not from the whole book, so it can't give away what happens later. A book that + is a single chapter gets no description, since its opening is the whole book. +- **In its own words.** It never copies the book: a description that repeats a + run of eight words from the book's text is sent back to be rewritten. It is also + checked for length (200 to 1500 characters) and for a plain, neutral tone. +- **Kept on your server.** It is never offered to the community, even when the + run contributes the characters and recaps. + +It never holds up the characters and recaps: they are saved first. When a +description can't be written (the agent reached its usage limit, say), the job +still finishes with the characters and recaps, and the console's job line says +"No description this time" with the reason. + +### Where it shows + +On a book's page in the player, the [About card](../listening/book-page.md#about-this-book) +shows the generated description **only when nothing else describes the book**: +the community's description comes first, then the book's own (an admin edit, one +taken from a community match or an Audiobookshelf `metadata.json`, or the community +database's record), and only then the generated one. +It carries a small **Generated on this server** mark, and no credit line or +**Improve this**. Spoiler protection never hides it: there is nothing in it to +hide. A book doesn't need to be matched with community metadata for it to show. + +An app version from before this feature doesn't show a generated description. + +In the console, the book's **Community metadata** card has a **Description** part +that shows it, with **Show more** for a long one and when it was written. When +listeners see another description instead (the book's own, or the community's), +it says so. + +![The Community metadata card's Description part with a generated description and its Generated on this server mark](/img/screenshots/admin/book-description.png) + +### Writing a description for an older book + +A book generated before descriptions existed, or whose run couldn't write one, +can get one on its own: the **Description** part offers **Write a description** +when + +- the Bridge is online, can write descriptions and can write to the book's library; +- the book has characters or recaps generated on this server; +- it has no description yet: none generated here, none from the community, and + none of its own; +- nothing is being generated for it right now. + +It asks first, then queues the job. The Bridge writes it from the notes it kept +when it generated the book: **nothing is transcribed again**, so it is quick and +uses a little agent time, far less than generating (counted against the same +caps). Those notes are kept after the transcripts are deleted, so this works for a +book generated months ago. The job's progress shows in the same part, and it +pauses like a generate job (the agent's usage limit, the spending cap...) and +carries on the same way. + +It can fail with a reason that says what to do: + +- **The Bridge has no notes for this book** (it wasn't generated by this Bridge, + or its notes were removed): generate its characters and recaps again, which + writes a description too. +- **A generate job is still running for this book**: it writes the description + when it finishes. For the same reason, generating or regenerating a book cancels + a description job that hasn't finished yet. +- **The book has a single chapter**, so no description could leave out its ending. + +When it is done the event reads "Description ready" with the book's title. + ## Costs and the monthly cap The transcription runs on your Bridge's own computer (or on an diff --git a/docs-users/admin/notifications.md b/docs-users/admin/notifications.md index 2bac5010..0ba1d33c 100644 --- a/docs-users/admin/notifications.md +++ b/docs-users/admin/notifications.md @@ -113,7 +113,7 @@ Tick a box to send that event there; it saves as you tick. | **An invite was used** | A device was set up from someone's invite. | | **An update is available** | A new AudioSilo version is out. Once per version, and only while **Check for new versions** is on ([General](server.md#general)). | | **A backup failed** | A scheduled or manual [backup](backups.md) failed, with the reason. | -| **A Bridge job finished** | The [Bridge](bridge.md) finished a job: "Characters and recaps ready" with the book's title, when it [generated them](generating-characters-and-recaps.md). | +| **A Bridge job finished** | The [Bridge](bridge.md) finished a job: "Characters and recaps ready" with the book's title, when it [generated them](generating-characters-and-recaps.md), or "Description ready" when it [wrote a description](generating-characters-and-recaps.md#a-description-for-the-book) on its own. | | **A Bridge job failed or needs attention** | A Bridge job stopped with an error, or paused waiting for someone to look at it, with the reason. Pauses that end by themselves (the agent's usage limit, the agent briefly away, waiting for an ASR worker) aren't sent. The bell shows the Bridge's own error; the message sent out only names the book and the reason. | All of them show in the bell whether or not you send them anywhere. diff --git a/docs-users/listening/book-page.md b/docs-users/listening/book-page.md index 1c44fcd0..6ce6ce7a 100644 --- a/docs-users/listening/book-page.md +++ b/docs-users/listening/book-page.md @@ -57,8 +57,11 @@ A finished book shows **Finished** with the date (*"Finished 3 Oct"*) in place o The **About** card describes the book. Its words come from the first of these that has some: 1. The **community's description** from [AudioSilo Meta](../community/meta-site.md), when your server has community metadata switched on and the book is matched there. A line under it credits the people who wrote it and links the open licence they share it under, and **Improve this** opens the book's entry, so you can make it better. -2. The description your server holds for the book (one your admin edited, or the one in its tags). -3. Failing both, a sentence naming the author and the narrator. +2. The description your server holds for the book (one your admin edited, or one it took from a community match or an Audiobookshelf `metadata.json`), else the one in the community database's record of the book. +3. A description your server's [Bridge](../admin/bridge.md) wrote for the book, with a small **Generated on this server** mark under the heading and no credit line. It is spoiler-free: it covers only how the book begins (see [Generating characters and recaps](../admin/generating-characters-and-recaps.md#a-description-for-the-book)). +4. Failing all of these, a sentence naming the author and the narrator. + +![The About card of a book with a description generated on the server, marked Generated on this server](/img/screenshots/web-player/book-about-generated.png) A long description folds after a few lines, with **Show more**. Under it, what's known of the recording: the **Publisher**, when it was **Released**, when the story was **First published**, its **Language**, and whether it's **Abridged**. What your server says about the publisher, the language and abridged comes first, then the community's. Anything nobody knows is left out, so a book that doesn't say whether it's abridged has no **Abridged** line rather than a "No". A matched book with no community description has a quiet **View on AudioSilo Meta** link instead of the credit. diff --git a/docs-users/listening/recaps-and-characters.md b/docs-users/listening/recaps-and-characters.md index ae73a7c5..11712de7 100644 --- a/docs-users/listening/recaps-and-characters.md +++ b/docs-users/listening/recaps-and-characters.md @@ -16,6 +16,8 @@ For a book the community hasn't covered, your server's admin can have the server The community's material always comes first: when the community has characters but no recaps for a book, you see the community's characters and the generated recaps, each tab marked for what it holds. An app version from before this feature shows generated material only for a book matched with community metadata, without the mark. +The Bridge also writes a short, spoiler-free description of the book, which the book page's [About card](book-page.md#about-this-book) shows, with the same mark, when nothing else describes the book. + ## Nothing gets spoiled before you reach it The Characters and Recaps tabs follow **where you are in the book**, using your current playback position (or your saved progress if you aren't playing it right now): diff --git a/screenshots/README.md b/screenshots/README.md index abb2b02b..43b7928e 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -138,6 +138,17 @@ default) and a first book the community has no characters and recaps for (a seed LibriVox book has no ASIN or ISBN, so it never matches); otherwise the step fails and the shots keep their previous images. +The generated description shots (`admin/book-description.png`, +`web-player/book-about-generated.png`, Bridge Phase 1c) need a book with a +`description.json`. Before the servers start, `run.sh` writes one into **The Call +of the Wild**'s `.audiosilo/` folder on every run (the fixture +`fixtures/call-of-the-wild.description.json`, an own-words, spoiler-free +`model.Description`, and the `bridge.json` a description job writes, dated two +days ago); no other shot shows that book's About or Community card, and its files +carry no description of their own. The console shot is taken before the stand-in +Bridge is paired, so its card shows only the Description part; the web shot is the +desktop About card on the book's page, reached through Library > Books. + The support shots come after those (`admin/support-card.png`, `admin/account-menu.png`, `admin/update-notice.png`). The overview's support card only shows once the server's first account is 30 days old, so the diff --git a/screenshots/capture-admin.mjs b/screenshots/capture-admin.mjs index d5c48b9d..05aa3b12 100644 --- a/screenshots/capture-admin.mjs +++ b/screenshots/capture-admin.mjs @@ -945,6 +945,26 @@ await step('server: audit log', async () => { await shoot(page, 'admin/audit.png'); }); +// ── A generated description (Bridge Phase 1c) ──────────────────────────────── +// run.sh seeds The Call of the Wild's .audiosilo folder with a description.json +// and the bridge.json a description job writes, so its Community card shows the +// Description part with the description and its Generated on this server mark. +// Before the stand-in Bridge is paired, so the card has no Characters and recaps +// part beside it (and no Write a description offer: the book has one already). +await step('book: generated description', async () => { + if (!wanted('admin/book-description.png')) return; + const list = await api(token, 'GET', `/admin/books?author=${encodeURIComponent('Jack London')}&limit=50`); + const book = (list?.books ?? []).find((b) => `${b.title} ${b.path}`.includes('Call of the Wild')); + if (!book) throw new Error('no seeded Call of the Wild'); + await open(page, bookRoute(book), (p) => p.getByRole('heading', {name: 'Details', exact: true})); + const part = page.locator('section[aria-labelledby="describe-title"]'); + await part.getByText(/^Generated on this server/).waitFor({timeout: 15000}); + const card = part.locator('xpath=..'); + await card.scrollIntoViewIfNeeded(); + await sleep(800); + await shoot(page, 'admin/book-description.png', {clip: around([await card.boundingBox()])}); +}); + // ── The Bridge: Settings > Bridge before and after pairing ────────────────────── // The Bridge is a separate program, so a stand-in plays its part here through // the server's own Bridge routes, as the real one would: it trades the code the diff --git a/screenshots/capture-web.mjs b/screenshots/capture-web.mjs index 42de15ec..7eb49870 100644 --- a/screenshots/capture-web.mjs +++ b/screenshots/capture-web.mjs @@ -619,6 +619,24 @@ async function captureWide(name, viewport, shots) { }); } + if (wanted(shots.bookAboutGenerated)) { + // The Call of the Wild's About card: run.sh seeds its .audiosilo folder with a + // description.json, the book has no description of its own and no community + // match, so About shows the generated one with its mark (Library > Books, by + // its title). + await step('generated description', async () => { + await librarySection(page, 'Books'); + await openNamed(page, 'The Call of the Wild'); + const mark = tid(page, 'about-generated-mark'); + await mark.waitFor({timeout: 15000}); + const card = mark.locator('xpath=..'); + await card.scrollIntoViewIfNeeded(); + await sleep(SETTLE_MS); + await page.mouse.move(0, 0); + await shoot(page, shots.bookAboutGenerated, {clip: around([await card.boundingBox()])}); + }); + } + if (wanted(shots.journal, shots.journalBookmarks)) { // The Journal from the profile menu: the Diary (today's drift-off strip), then // the Bookmarks tab with its label filter. @@ -912,6 +930,7 @@ await captureWide('desktop', {width: 1440, height: 900}, { bookmarkEditor: 'web-player/bookmark-editor.png', bookDetails: 'web-player/book-details.png', bookFinished: 'web-player/book-finished.png', + bookAboutGenerated: 'web-player/book-about-generated.png', journal: 'web-player/journal.png', journalBookmarks: 'web-player/journal-bookmarks.png', player: 'web-player/player.png', diff --git a/screenshots/fixtures/call-of-the-wild.description.json b/screenshots/fixtures/call-of-the-wild.description.json new file mode 100644 index 00000000..57c70a95 --- /dev/null +++ b/screenshots/fixtures/call-of-the-wild.description.json @@ -0,0 +1,6 @@ +{ + "work": "the-call-of-the-wild", + "text": "Buck is a big, good-natured dog, half St. Bernard and half Scotch shepherd, who has spent his four years as the favourite of Judge Miller's sunny estate in California's Santa Clara Valley. In the autumn of 1897 gold has been found in the far north, and the men rushing to the Klondike need strong dogs to haul their sleds. One evening a gardener with gambling debts quietly leads Buck away and sells him to strangers.\n\nCaged, beaten and carried north by train and steamer, Buck learns his first hard lesson from a man in a red sweater who carries a club, and comes ashore at Dyea among sled dogs, snow and men who live by rules he has never known. Jack London's short novel follows a pampered house dog thrown into the work and the dangers of the Yukon trail.", + "license": "CC-BY-SA-4.0", + "sources": [{ "type": "community" }] +} diff --git a/screenshots/manifest.mjs b/screenshots/manifest.mjs index d00dae7e..bdb91385 100644 --- a/screenshots/manifest.mjs +++ b/screenshots/manifest.mjs @@ -26,6 +26,7 @@ export const SHOTS = [ {file: 'web-player/bookmark-editor.png', capture: 'web', title: 'Web player - Bookmark editor', hint: "the Edit bookmark dialog on the Quote bookmark: the time chip and chapter, the note, the label chips with Quote chosen (closed with Cancel, unchanged)"}, {file: 'web-player/book-details.png', capture: 'web', title: 'Web player - Book details tab', hint: 'the Details tab of the playing book (downloaded first, so Plays from this device): the notice, the files table (codec, about N kbps, length), the path on the server'}, {file: 'web-player/book-finished.png', capture: 'web', title: 'Web player - Finished book', hint: 'the page of the provisioned finished book: the Finished <date> badge, the four stars, Listen again'}, + {file: 'web-player/book-about-generated.png', capture: 'web', title: 'Web player - Generated description', hint: "The Call of the Wild's About card (clipped) on its book page: the description run.sh seeds into its .audiosilo folder (a description.json as a Bridge writes it), with the Generated on this server mark under the heading and no credit line; the book has no description of its own and no community match"}, {file: 'web-player/journal.png', capture: 'web', title: 'Web player - Journal', hint: "the Journal's Diary from the profile menu (the You hub's Journal section): Today with its 24 hour bar and sessions, the drift-off strip under the provisioned sleep-timer session, earlier days below"}, {file: 'web-player/journal-bookmarks.png', capture: 'web', title: 'Web player - Journal bookmarks', hint: "the Journal's Bookmarks tab: the export actions, search, the label filter chips and every bookmark across books, newest first"}, {file: 'web-player/player.png', capture: 'web', title: 'Web player - Now playing', hint: 'the full player on a desktop, opened from the docked bar with the book paused: cover, status line, seek bar and timeline, transport, actions, and the companion column'}, @@ -110,6 +111,7 @@ export const SHOTS = [ {file: 'admin/settings-bridge-paired.png', capture: 'admin', title: 'Admin - Bridge, paired', hint: 'Server > Settings ?topic=bridge with a stand-in Bridge paired through the API (heartbeat sent with both kinds and the sidecars estimates, page link set): Online, the status facts with the Agent, Paid by, This month and Estimates from rows, Libraries (the seeded library Ready, any other one mapped but read-only), Workers (one ASR worker, a Mac on Metal), Jobs with a finished Verify, the Unpair danger zone (viewport as tall as the page)'}, {file: 'admin/book-generate.png', capture: 'admin', title: 'Admin - Generate characters and recaps', hint: 'the first book\'s Community metadata card (clipped) with its Characters and recaps part and Generate characters and recaps, while the stand-in Bridge offers sidecars.generate'}, {file: 'admin/generate-dialog.png', capture: 'admin', title: 'Admin - Generate dialog', hint: 'the Generate characters and recaps confirmation over the first book\'s page: Reads, Length, Transcription, Agent cost (a subscription), Results, the subscription note and Also contribute to the community (the stand-in can contribute); closed, never submitted'}, + {file: 'admin/book-description.png', capture: 'admin', title: 'Admin - Generated description', hint: "The Call of the Wild's Community metadata card (clipped) with its Description part: the description run.sh seeds into its .audiosilo folder, Generated on this server two days ago, the text clamped behind Show more; shot before the stand-in Bridge is paired, so there is no Characters and recaps part"}, {file: 'admin/bell.png', capture: 'admin', title: 'Admin - Notifications bell', hint: 'the top bar bell open over the overview: new books and sign-ins'}, {file: 'admin/audit.png', capture: 'admin', title: 'Admin - Audit log', hint: 'Server > Audit log: the filters and the provisioning\'s changes (the admin\'s, and maya adding household members in a full run), newest first'}, {file: 'admin/support-card.png', capture: 'admin', title: 'Admin - Support card', hint: "the overview's Support AudioSilo card, clipped (captured last, with the capture database's accounts backdated 40 days so the card is due)"}, diff --git a/screenshots/run.sh b/screenshots/run.sh index b5572a66..50553029 100755 --- a/screenshots/run.sh +++ b/screenshots/run.sh @@ -112,6 +112,32 @@ if [ "${SKIP_SEED:-0}" != "1" ]; then christmas_carol_1111_librivox art_of_war_librivox fi +# ── 2b. A generated description (Bridge Phase 1c) ────────────────────────── +# One seeded book gets the files a Bridge leaves when it writes a description +# (sidecars.describe): .audiosilo/description.json, an own-words, spoiler-free +# model.Description (fixtures/), and the bridge.json such a job writes (dated +# two days ago), for admin/book-description.png and +# web-player/book-about-generated.png. The Call of the Wild: no other shot shows +# its About or its Community card, and its files describe nothing themselves, so +# the player's About falls through to the generated one. Written on every run +# (the cached library may predate it); the server only reads it. +DESC_BOOK="$LIBRARY/Jack London/The Call of the Wild" +if [ -d "$DESC_BOOK" ]; then + mkdir -p "$DESC_BOOK/.audiosilo" + cp "$HERE/fixtures/call-of-the-wild.description.json" "$DESC_BOOK/.audiosilo/description.json" + node -e ' + const at = new Date(Date.now() - 2 * 86400e3).toISOString().replace(/\.\d+Z$/, "Z"); + const prov = { + generator: "audiosilo-bridge", version: "0.2.0", kind: "sidecars.describe", job_id: 7, + generated_at: at, source: "audio", work_id: "the-call-of-the-wild", inputs: [], + description: {job_id: 7, generated_at: at, from: "opening_notes", chapters: 2, checked_against: "transcripts"}, + }; + require("node:fs").writeFileSync(process.argv[1], JSON.stringify(prov, null, 2) + "\n"); + ' "$DESC_BOOK/.audiosilo/bridge.json" +else + echo " ! no $DESC_BOOK: the generated description shots will fail" +fi + # ── 3. Servers ────────────────────────────────────────────────────────────── cleanup() { [ -n "${MAIN_PID:-}" ] && kill "$MAIN_PID" 2>/dev/null || true diff --git a/static/img/screenshots/admin/book-description.png b/static/img/screenshots/admin/book-description.png new file mode 100644 index 00000000..1570c4cf Binary files /dev/null and b/static/img/screenshots/admin/book-description.png differ diff --git a/static/img/screenshots/web-player/book-about-generated.png b/static/img/screenshots/web-player/book-about-generated.png new file mode 100644 index 00000000..c2154d21 Binary files /dev/null and b/static/img/screenshots/web-player/book-about-generated.png differ