From 76f943148b66c64213ce1c1105d2162a7402bb23 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Fri, 16 Oct 2026 14:04:12 +0100 Subject: [PATCH 1/5] Bridge 1c: generated descriptions in .audiosilo, /meta and the console description.json is a known attachment (checked against meta's model.Description, 200-1500 code points, migration 0050) and rides in /meta's new work.generated_description when the community work has no community_description. New job kind sidecars.describe (strict empty params, 409 generate_open while a generate job is open); the estimate gains can_describe and has.*.description; the admin book detail carries generated_description, shown on the book page's Community card with a Write a description action. --- CLAUDE.md | 66 +++- admin-ui/src/api/types.ts | 15 + admin-ui/src/features/book/book-aside.tsx | 4 +- admin-ui/src/features/bridge/bridge-jobs.tsx | 6 +- .../src/features/bridge/describe-section.tsx | 194 ++++++++++ .../src/features/bridge/describe.test.tsx | 278 +++++++++++++++ .../features/bridge/generate-model.test.ts | 100 ++++++ .../src/features/bridge/generate-model.ts | 73 +++- .../src/features/bridge/generate-status.tsx | 45 ++- admin-ui/src/features/bridge/use-generate.ts | 25 +- admin-ui/src/i18n/locales/de.json | 19 + admin-ui/src/i18n/locales/en.json | 19 + admin-ui/src/i18n/locales/es.json | 19 + admin-ui/src/i18n/locales/fr.json | 19 + admin-ui/src/i18n/locales/it.json | 19 + admin-ui/src/i18n/locales/pt.json | 19 + admin-ui/src/lib/errors.ts | 3 + admin-ui/src/lib/server-events.test.ts | 8 + admin-ui/src/lib/server-events.ts | 16 +- internal/api/bridge_attachments_test.go | 10 +- internal/api/bridge_describe_test.go | 334 ++++++++++++++++++ internal/api/handlers_bridge.go | 18 +- internal/api/handlers_bridge_estimate.go | 31 +- internal/api/handlers_meta.go | 15 +- internal/bridge/jobs.go | 40 ++- internal/bridge/sidecars_test.go | 67 ++++ internal/catalog/attachments.go | 31 +- internal/catalog/attachments_test.go | 29 +- internal/catalog/bookdetail.go | 21 ++ internal/library/attachments.go | 11 +- internal/library/attachments_test.go | 67 ++++ internal/meta/cachedenrich.go | 24 +- internal/meta/cachedenrich_test.go | 14 + internal/meta/generated.go | 52 ++- internal/meta/generated_test.go | 96 ++++- internal/meta/service.go | 4 + internal/metadata/attachments.go | 110 ++++-- internal/metadata/attachments_test.go | 95 ++++- internal/notify/notify.go | 8 +- internal/notify/notify_test.go | 22 ++ internal/store/migration_0050_test.go | 33 ++ .../0050_book_attachments_description.sql | 8 + 42 files changed, 1925 insertions(+), 162 deletions(-) create mode 100644 admin-ui/src/features/bridge/describe-section.tsx create mode 100644 admin-ui/src/features/bridge/describe.test.tsx create mode 100644 internal/api/bridge_describe_test.go create mode 100644 internal/store/migration_0050_test.go create mode 100644 internal/store/migrations/0050_book_attachments_description.sql diff --git a/CLAUDE.md b/CLAUDE.md index f33acd42..cdbefc56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,10 +107,10 @@ internal/auth/ users, argon2id, opaque hashed tokens, auth codes; hash.go internal/relpath/ library-relative path rules shared by catalog and auth: Clean (canonical form) and Within (the segment-boundary rule of share scopes and cast tokens) internal/catalog/ libraries, access grants, books, FTS search, listening state (the data layer) internal/library/ filesystem view (fsview.go: SafeJoin, and ContentPath/ContentJoin, the one hidden-segment rule) + background scanner (scanner.go: incl. IndexMoved and the .audiosilo/ walk); attachments.go (a book's Bridge attachments: signatures, RefreshAttachments); ebook.go (BookEbook, the Bridge's own .epub rule, for the estimate; a readable one only: a zip whose directory lists META-INF/container.xml, as the Bridge reads it) -internal/metadata/ dhowden/tag + ffprobe extraction (a `series-part` tag "1-3" is a range: seriesrange.go, ParseSeriesRange, the one grammar of a range position, and ParseSeriesPosition, a number or a range; incl. ReleaseDate: a date tag as YYYY[-MM[-DD]], stored as books.released, never as published); DeriveFromPath (structural path parsing, the scan's baseline: the folder holding the book is the series and the one above it the author, but a lone folder is the author); layout.go: ReadPathLayout (author/series/book LAYOUT, the match's path facts) and FromPathLayout (a path-first library's values); absfile.go: ParseABSFile/ReadABSFile (an Audiobookshelf metadata.json, see Metadata overrides); attachments.go: AttachmentsDir `.audiosilo`, Read/ParseCharacters, Recaps and Provenance (the Bridge's generated files, checked against audiosilo-meta's formats; see Bridge attachments); titlepos.go: PositionInTitle (a series position read from a title that is the series' name then a number, "He Who Fights with Monsters 4: ..."; narrow: set off by a space or comma, 1-3 digits, then the end or a separator, never a range) +internal/metadata/ dhowden/tag + ffprobe extraction (a `series-part` tag "1-3" is a range: seriesrange.go, ParseSeriesRange, the one grammar of a range position, and ParseSeriesPosition, a number or a range; incl. ReleaseDate: a date tag as YYYY[-MM[-DD]], stored as books.released, never as published); DeriveFromPath (structural path parsing, the scan's baseline: the folder holding the book is the series and the one above it the author, but a lone folder is the author); layout.go: ReadPathLayout (author/series/book LAYOUT, the match's path facts) and FromPathLayout (a path-first library's values); absfile.go: ParseABSFile/ReadABSFile (an Audiobookshelf metadata.json, see Metadata overrides); attachments.go: AttachmentsDir `.audiosilo`, Read/ParseCharacters, Recaps, Description and Provenance (the Bridge's generated files, checked against audiosilo-meta's formats; see Bridge attachments); titlepos.go: PositionInTitle (a series position read from a title that is the series' name then a number, "He Who Fights with Monsters 4: ..."; narrow: set off by a space or comma, 1-3 digits, then the end or a separator, never a range) internal/media/ Range streaming, download, embedded cover extraction internal/names/ reading people in an Author/Narrator credit: Split (the deliberately shy co-credit rule), Reversed ("Surname, Given"), SortKey (surname first; the catalog registers it as the SQL function name_sort for the admin list's surname sort) -internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache and its persistent SQLite level (store.go); the /meta bundle's previous works and spoiler gating (bundle.go); the admin console's match (match.go: metaserve works/match over tag + path facts, metadata.ReadPathLayout; works/search fallback for an older metaserve); community cover fetches (cover.go: public addresses only); owned books' work ids for the Series cards (workids.go); placing the caller's books on rails and the next rail entry (local.go); which of a work's series a book is in (seriesrules.go: the series rules below); generated content laid beside the community work (generated.go: `Answer`, `Unmatched`, `MetaGenerated`); cache-only reads for the Bridge's estimate (cachedenrich.go: `CachedEnrichment`, `Coverages`) +internal/meta/ Phase 1.5 community metadata lookup: HTTP client + Service (asin/isbn -> composed enrichment envelope) with a bounded TTL cache and its persistent SQLite level (store.go); the /meta bundle's previous works and spoiler gating (bundle.go); the admin console's match (match.go: metaserve works/match over tag + path facts, metadata.ReadPathLayout; works/search fallback for an older metaserve); community cover fetches (cover.go: public addresses only); owned books' work ids for the Series cards (workids.go); placing the caller's books on rails and the next rail entry (local.go); which of a work's series a book is in (seriesrules.go: the series rules below); generated content laid beside the community work (generated.go: `Answer`, `Unmatched`, `MetaGenerated`, `MetaGeneratedDescription`); cache-only reads for the Bridge's estimate (cachedenrich.go: `CachedEnrichment`, `Coverages`, each with a community description flag) internal/chapteralign/ fits a community recording's chapter list onto a book's own audio (pure, no I/O): anchors by title + time, places the rest in proportion, snaps each to its pause (an injected Prober); classifies fill/titles/refine/restructure/same or why not (length_mismatch, structure_mismatch, crosses_files). testdata/mythos is a real 34-vs-174-chapter golden case internal/chaptercheck/ the community chapter check: a background pass (every 10 min, and on Catalog.OnBookChange) and on request (Start), meta.RecordingChapters -> chapteralign (ffmpeg silencedetect via media.DetectSilences) -> catalog.SaveCommunityChapters internal/covercolors/ the background cover colour pass: Runner reads the colour of every book whose cover may have art and holds none for it (catalog.CoverColorsDue), one at a time, once the start or a burst of OnBookChange kicks has been quiet 30 s (<= 10 min), and hourly; the reading is api.colorCover (a cached thumbnail, else an UNCACHED 160 px one), handed in as its Colorer; art with no colour is recorded as such (cover_color = the version alone; CoverSource.ColorRead, while Colored stays "has a colour", so a thumbnail that decodes still records over it); art files that aren't there (an unmounted share) are covercolors.ErrArtMissing: left due, not a failure @@ -125,7 +125,7 @@ internal/logring/ the admin console's log viewer: an slog handler teeing rec internal/updates/ the update check: GitHub Releases' latest release, once a day while on (config update_check) internal/backup/ database backups: VACUUM INTO the backups folder on a schedule or on request, retention, restore applied at the next start internal/jsonfile/ the small JSON files beside the database (backup's restore marker/result, the mirror's state.json): Write is owner-only, temp file synced then renamed (all or nothing); Read[T] is nil without a file -internal/bridge/ the Bridge's side on the server (BRIDGE-PLAN.md): its last heartbeat (status.go: validation, the 2-minute online window, features, the lenient `estimates`, Writable, CanContribute) and the job queue (jobs.go: kinds the server can check (`bridge.verify`, `sidecars.generate`), dedupe, priority claim with a lease, long-poll wake, lease expiry, held updates and parks, cooperative cancel, `OnTransition` / `Job.Notable`); estimate.go (EstimateSidecars: a job's cost from the heartbeat's figures); pairing and the bridge token are auth's (auth/bridge.go) +internal/bridge/ the Bridge's side on the server (BRIDGE-PLAN.md): its last heartbeat (status.go: validation, the 2-minute online window, features, the lenient `estimates`, Writable, CanContribute) and the job queue (jobs.go: kinds the server can check (`bridge.verify`, `sidecars.generate`, `sidecars.describe`), dedupe, priority claim with a lease, long-poll wake, lease expiry, held updates and parks, cooperative cancel, `OnTransition` / `Job.Notable`); estimate.go (EstimateSidecars: a job's cost from the heartbeat's figures); pairing and the bridge token are auth's (auth/bridge.go) internal/notify/ the event feed (the console's bell) and its deliveries to webhook / ntfy / Discord destinations (incl. the Bridge's `bridge_job_finished` / `bridge_job_failed`) internal/api/ HTTP transport: routing (api.go), middleware, rate limiting, handlers_*.go internal/server/ HTTP(S) server, TLS modes (off/selfsigned/autocert), home HTTP on the self-signed port, graceful shutdown @@ -256,7 +256,8 @@ values; the lists canonical JSON), `books.genres_checked` (the genre tag's one-t `genres`|`tags`, `label_key`, the book's spelling: derived, rebuilt by `refreshEffective`), recreates `books_fts` with `genres, tags` columns (rows copied) and sets `abs_indexed_at` NULL on every book with a metadata.json (`abs_sig <> ''`), so the next scan reads each again for the new fields; see Labels -below. +below. `0050` adds `book_attachments.description` (the Bridge's checked `description.json`, Phase 1c; +`''` for none; derived like the rest of the row). Routes `/me/queue`, `/me/collections/**`, `/me/share-targets` (`handlers_queue.go`, `handlers_collections.go`; a stranger's collection id is 404, a viewer's write 403 @@ -506,9 +507,17 @@ future metadata site can attach enrichment without reshaping the schema. The transport-only. Degradation: disabled -> 404 (and the `metadata` capability is false, so clients hide the UI); no asin/isbn or no upstream match -> `200 {"matched": false}` (with a `work` of the book's own when the Bridge generated - characters or recaps for it: `meta.Answer`); upstream unreachable -> 502, unless + characters, recaps or a description for it: `meta.Answer`); upstream unreachable -> 502, unless the book has generated content (this server's own), answered as unmatched with - it. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction. + it. **Generated description** (Phase 1c): a book's `description.json` rides in the additive + `work.generated_description: {text}` (`MetaGeneratedDescription`), NEVER in `community_description` + (a shipped 2.0 app shows that first and credits the community; it ignores the new field), and + `work.generated.description` (bool, always sent beside `characters`/`recaps`) marks it. It fills + only a work with no `community_description` (the core `description`, the publisher's kind, doesn't + block it: the player orders those), so `Fillable` is also true without one; `?spoilers=hide` keeps + it (spoiler-free by construction: the Bridge writes it from the opening chapters' notes only), and + attribution never counts it. An unmatched book with only a description answers `{matched: false, + work: {title, generated_description, generated: {description: true, ...}}}`. Out of scope for now: no cover remote-fallback, no tag-based ASIN extraction. **Reading-order families** (metaserve schema_version 7): `seriesRails` collapses each family (key `ordering_of || id`) into ONE rail whose top-level view is the MAIN view - the ref with no `ordering_of` (the primary), else the first ref - so @@ -898,12 +907,18 @@ future metadata site can attach enrichment without reshaping the schema. The left as it was by the backfill and recorded as none by a re-index, so the next scan reads it again (`readABS`' `read`); both log a warning. Known gap: a position read from an ABS title (`titlePosition`) shows as `tag`. **Bridge attachments** (`metadata/attachments.go`, `library/attachments.go`, `catalog/attachments.go`, - migration `0048`; BRIDGE-PLAN.md Phase 1a): the Bridge writes a book's generated `characters.json`, - `recaps.json` and `bridge.json` (provenance) into `.audiosilo/` in a folder book's own folder, or beside + migrations `0048`, `0050`; BRIDGE-PLAN.md Phases 1a and 1c): the Bridge writes a book's generated `characters.json`, + `recaps.json`, `description.json` and `bridge.json` (provenance) into `.audiosilo/` in a folder book's own folder, or beside a single-file book with its stem before each name (`Book.characters.json`). They are NOT a metadata layer (nothing in the effective row reads them): the scan stores them checked (audiosilo-meta's - `model.Characters` / `model.Recaps`, bounded, a regular file only, never through a link) in - `book_attachments`, and `/meta` serves them (below). The discovery walk lists each `.audiosilo` folder + `model.Characters` / `model.Recaps` / `model.Description`, bounded, a regular file only, never through a link) in + `book_attachments`, and `/meta` serves them (below). A description (`metadata.ParseDescription`) is + its text trimmed, 200-1500 code points (meta's `description.schema.json` bounds, + `TestDescriptionBoundsMatchTheSchema` reads them from `meta.SchemaFS`), no control character but a + line break; a blank text is none, anything else fails the file. `AttachmentNames` lists it before + `bridge.json`, so a book without one keeps its stored signature byte for byte. bridge.json's + `description.generated_at` is read as `AttachmentProvenance.DescriptionAt` (the rest of that object + is the Bridge's). The discovery walk lists each `.audiosilo` folder ONCE (never walks into it; no audio lives there), and a book's signature (`books.attachments_sig`: name:size:mtime_ns of its own known names) is compared like `abs_sig`: an unchanged book whose attachments are new, changed, gone or not read for this index is read without a re-index @@ -913,7 +928,7 @@ future metadata site can attach enrichment without reshaping the schema. The never taking a book's generated content away. `Scanner.RefreshAttachments` re-reads one indexed book's attachments, never its audio (the Bridge's `POST /bridge/rescan` mode `attachments`). The player's `Book.generated` (list, search, item, recent, the next book) is true when the signature names - a characters or recaps file (`generatedSig`, the narrow column: no join), so the player asks `/meta` + a characters, recaps or description file (`generatedSig`, the narrow column: no join), so the player asks `/meta` for a book with no ASIN/ISBN too. Derived from the files: rebuildable, never carried by a move. **Series ranges** (an omnibus of books 1-3): the `series_index` field may hold a range, canonical `"1-3"` (input `1 - 3` and the en dash too; `metadata.ParseSeriesRange`: 0 < start < @@ -1617,7 +1632,7 @@ future metadata site can attach enrichment without reshaping the schema. The `Retry-After` (the receiver reports a load error; the sender should say "busy, try again"); it marks the SENDER's session transcoded (`StreamMarks.Note` keys on `Credential.DeviceID`, the parent token, whose progress saves make the session). -- **The Bridge (BRIDGE-PLAN.md Phases 0 and 1a, capability `bridge`, `auth/bridge.go`, `internal/bridge`, +- **The Bridge (BRIDGE-PLAN.md Phases 0, 1a and 1c, capability `bridge`, `auth/bridge.go`, `internal/bridge`, `api/handlers_bridge.go`, `handlers_bridge_estimate.go`; security-critical)**: the optional companion (repo `audiosilo-bridge`, private) that writes to the libraries and runs the heavy jobs, so this server stays read-only. **The server never connects to the Bridge**: it stores intents (jobs) and the Bridge's last heartbeat, and @@ -1667,7 +1682,13 @@ future metadata site can attach enrichment without reshaping the schema. The Phase 1a's `sidecars.generate` (a book `path` required, params `{source, contribute, work_id}`; it writes, so it is refused 409 `library_not_writable` for a library the last heartbeat doesn't report mapped and writable, and `contribute: "issue"` is refused 409 `cannot_contribute` unless the heartbeat's - `estimates.sidecars.can_contribute` says the Bridge can; the open job is still returned). + `estimates.sidecars.can_contribute` says the Bridge can; the open job is still returned), and Phase 1c's + `sidecars.describe` (a description from the notes the Bridge kept from generating the book, no new + ASR: a book `path` required, params `{}` or none, any field 400 (`describeParams`), stored as `{}`; + it writes, so the same `library_not_writable`; refused 409 `generate_open` (`ErrGenerateOpen`, checked + inside Enqueue's transaction after the dedupe) while a `sidecars.generate` job is open for the book, + which writes the description itself, while a generate job is never refused for an open describe; its + own dedupe key; an older Bridge doesn't advertise it, so it is the usual 409 `kind_unavailable`). States queued -> claimed -> running -> done | failed | needs_attention | cancelled; the Bridge PARKS a job it keeps running (`needs_attention`, the park's code in `progress.park.code`, its message in `error`) and unparks it (back to `running`) under the same claim: a held state, so its reports still @@ -1700,7 +1721,7 @@ future metadata site can attach enrichment without reshaping the schema. The `full` (or none) indexes it (`IndexPath`; with `from`, the book's path before a move the Bridge made, `Scanner.IndexMoved` carries its durable state when the old book is gone from disk and the fingerprints match, else ignores it), mode `attachments` re-reads only the book's `.audiosilo/` files - (`Scanner.RefreshAttachments`, answering `{path, attachments: {characters, recaps, bridge}}`); 404 + (`Scanner.RefreshAttachments`, answering `{path, attachments: {characters, recaps, description, bridge}}`); 404 `not_indexable`. Console: `GET /admin/bridge` (pairing, online, heartbeat, kinds, features, each library with the Bridge's mapping, workers, `ui_url`), `GET`/`POST /admin/bridge/jobs`, `GET /admin/bridge/jobs/{id}`, `GET /admin/bridge/estimate?library_id=&path=&kind=[&brief=1]` (the @@ -1709,15 +1730,26 @@ future metadata site can attach enrichment without reshaping the schema. The `agent_cost_usd` null while there is no history, `subscription`, `agent_ready`, `figures` false when the Bridge sent none, so a false `agent_ready` means "not known"), `writable`, what the book already has (`has.community` via `meta.Service.Coverages`, cache first and never a full Enrich per book, - `community_unknown` when it couldn't be asked; `has.local` from `book_attachments`), up to 10 earlier + `community_unknown` when it couldn't be asked; `has.local` from `book_attachments`; each with + `characters`, `recaps` and, since Phase 1c, `description`: the community work's `community_description` + (`Coverage.Description`; the core description doesn't count) or a stored generated one), `can_describe` + (the Bridge is online and advertises `sidecars.describe`; brief too), up to 10 earlier books of its series in the library with no recaps (`catalog.EarlierInSeries`, each costed), and any open job; `brief=1` skips the folder look and the series scan, for a card or a batch); 409 `kind_unavailable`. With a `path`, `GET /bridge/libraries/{id}/book` also answers a `book` block (title, authors and narrators split by `names.Split`, series, `series_index`, asin, isbn, duration, and `work_id` from the metadata cache alone, `CachedWorkID`), never on the sample-book answer. Settings > Bridge in `admin-ui` (`features/bridge/`: the topic shows the heartbeat's estimates and - each generate job's status; the book page's aside and each series card generate characters and - recaps, a series card queueing those of its books without recaps in reading order). Jobs are carried by + each generate or describe job's status; the book page's aside and each series card generate characters and + recaps, a series card queueing those of its books without recaps in reading order). The admin book + detail carries `generated_description: {text, generated_at?}` (omitted without one; `generated_at` is + bridge.json's description time), which the book page's Community card shows (`describe-section.tsx`: + a Description sub-part with the "Generated on this server" mark, the text clamped behind Show more; + "Write a description" confirms and queues `sidecars.describe` when `describeOffered`: `can_describe`, + local characters or recaps, no description here or from the community, no open generate or describe + job; the describe job's status through `GenerateStatus`, whose done line for a generate job also says + whether its description came (`result.description`, `description_error`)). Notifications word a + describe job as the description ("Description ready: "). Jobs are carried by `bridge.MoveJobs`, which `catalog.moveBookState` calls in its transaction with the catalog's clock (an open job colliding with an open one of its kind at the new path is cancelled; `DedupeKey` rebuilds the key), and go with their library. **Hidden folders**: no route reaches content through a hidden diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index 39f77f00..361e4c71 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -1641,6 +1641,17 @@ export interface AdminBookDetail { * the tags look swapped or junk (meta.SearchPrefill). */ match_query: string; + /** + * The description the Bridge generated for the book (its .audiosilo/description.json): + * absent when it has none. `generated_at` is when it was written, when known. + */ + generated_description?: GeneratedDescription; +} + +/** A book's generated description on its page (catalog.GeneratedDescription). */ +export interface GeneratedDescription { + text: string; + generated_at?: string; } /** Where a book's chapters come from: its own files, or a community list fitted onto them. */ @@ -2351,6 +2362,8 @@ export interface SidecarsResult { export interface SidecarsCoverage { characters: boolean; recaps: boolean; + /** A description: generated here, or the community's community_description (Phase 1c). */ + description?: boolean; } /** An earlier book of the series without recaps (the estimate's `earlier`). */ @@ -2388,6 +2401,8 @@ export interface BridgeEstimate { figures: boolean; /** The library is mapped and writable on the Bridge. */ writable: boolean; + /** The Bridge is online and runs sidecars.describe (a description from the notes it kept). */ + can_describe?: boolean; has: { community: SidecarsCoverage; local: SidecarsCoverage; diff --git a/admin-ui/src/features/book/book-aside.tsx b/admin-ui/src/features/book/book-aside.tsx index c041ce0e..65f91d49 100644 --- a/admin-ui/src/features/book/book-aside.tsx +++ b/admin-ui/src/features/book/book-aside.tsx @@ -8,12 +8,13 @@ import { Button } from '@/components/ui/button'; import { Card, CardHeader } from '@/components/ui/card'; import { formatNumber, formatPercent, formatRelative, progressFraction } from '@/lib/format'; import { cn } from '@/lib/utils'; +import { DescribeSection } from '@/features/bridge/describe-section'; import { GenerateSection } from '@/features/bridge/generate-section'; import { ProgressMenu } from '@/features/people/progress-actions'; /** * The right-hand column: who's listening, community metadata (and generating - * characters and recaps with the Bridge), and who can see the book. + * characters, recaps and a description with the Bridge), and who can see the book. */ export function BookAside({ detail, @@ -193,6 +194,7 @@ function CommunityCard({ )} + ); } diff --git a/admin-ui/src/features/bridge/bridge-jobs.tsx b/admin-ui/src/features/bridge/bridge-jobs.tsx index ad0a38bf..2386cad7 100644 --- a/admin-ui/src/features/bridge/bridge-jobs.tsx +++ b/admin-ui/src/features/bridge/bridge-jobs.tsx @@ -22,7 +22,7 @@ import { resultSummary, stateLook, } from './bridge-model'; -import { KIND_GENERATE } from './generate-model'; +import { wordsItsOwnStatus } from './generate-model'; import { GenerateStatus } from './generate-status'; import { useBridgeJobs } from './use-bridge'; @@ -134,8 +134,8 @@ function JobRow({ ? '' : (libraryNames.get(job.library_id) ?? t('bridge.jobs.libraryId', { id: formatNumber(job.library_id, lang) })); - // A generate job words its progress and result its own way (GenerateStatus). - const generate = job.kind === KIND_GENERATE; + // A generate or describe job words its progress and result its own way (GenerateStatus). + const generate = wordsItsOwnStatus(job.kind); const progress: ReturnType = generate ? {} : progressOf(job.progress); const summary = generate ? null : resultSummary(job.result); const details = hasContent(job.result) || hasContent(job.params); diff --git a/admin-ui/src/features/bridge/describe-section.tsx b/admin-ui/src/features/bridge/describe-section.tsx new file mode 100644 index 00000000..35da4abc --- /dev/null +++ b/admin-ui/src/features/bridge/describe-section.tsx @@ -0,0 +1,194 @@ +import { useId, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { PenLine, X } from 'lucide-react'; +import { keys, useServerInfo } from '@/api/hooks'; +import type { AdminBook, GeneratedDescription } from '@/api/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogBody, DialogContent, DialogFormFooter } from '@/components/ui/dialog'; +import { formatRelative } from '@/lib/format'; +import { cancelJob, queueJob } from './bridge-actions'; +import { isOpen, jobActions, stateLook } from './bridge-model'; +import { + clampsDescription, + describeOffered, + generateAvailable, + generateJobsKnown, + KIND_DESCRIBE, +} from './generate-model'; +import { GenerateStatus } from './generate-status'; +import { useBookGenerateJob, useBridgeLink, useGenerateEstimate } from './use-generate'; + +/** + * The Community card's description part (Bridge Phase 1c): the description the + * Bridge generated for the book (its text, clamped, with Show more), the + * describe job while one is open or how it failed, and "Write a description", + * which queues `sidecars.describe` after a confirmation. Offered only when the + * Bridge can write one (describeOffered). Renders nothing when there is nothing + * to show. + */ +export function DescribeSection({ + book, + description, +}: { + book: AdminBook; + description?: GeneratedDescription; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.resolvedLanguage ?? 'en'; + const qc = useQueryClient(); + const server = useServerInfo().data; + const available = generateAvailable(server); + const { job, jobs } = useBookGenerateJob( + book.library_id, + book.path, + generateJobsKnown(server), + KIND_DESCRIBE, + ); + // The same brief estimate the characters-and-recaps part reads (one query). + const estimate = useGenerateEstimate(book.library_id, book.path, available, { brief: true }); + const parked = job?.state === 'needs_attention'; + const uiUrl = useBridgeLink(parked); + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(false); + const [cancelling, setCancelling] = useState(false); + const textId = useId(); + + const offer = available && describeOffered(estimate.data, jobs); + // A cancelled job says nothing; a finished one is the description itself once it shows. + const showJob = job && job.state !== 'cancelled' && !(job.state === 'done' && description); + if (!open && !showJob && !offer && !description) return null; + + const look = job ? stateLook(job) : undefined; + const canCancel = job ? jobActions(job).cancel : false; + const clamps = description ? clampsDescription(description.text) : false; + const jobOpen = job ? isOpen(job.state) : false; + + return ( +
+
+

+ {t('bridge.describe.title')} +

+ {showJob && look ? {t(look.key)} : null} +
+ {description ? ( + <> +

+ {description.generated_at + ? t('bridge.describe.noteAt', { + when: formatRelative(description.generated_at, lang), + }) + : t('bridge.generate.localNote')} +

+

+ {description.text} +

+ {clamps ? ( +
+ +
+ ) : null} + + ) : null} + {showJob && job ? : null} + {(showJob && jobOpen && canCancel) || offer ? ( +
+ {offer ? ( + + ) : null} + {job && jobOpen && canCancel ? ( + + ) : null} +
+ ) : null} + +
+ ); +} + +/** + * The confirmation before `sidecars.describe`: what it does, what it reads (the + * notes the Bridge kept from generating this book, no new speech recognition), + * that it costs a little agent time, and where the result appears. + */ +function DescribeDialog({ + open, + onOpenChange, + book, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + book: AdminBook; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [busy, setBusy] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (busy) return; + setBusy(true); + const ok = await queueJob( + qc, + { kind: KIND_DESCRIBE, library_id: book.library_id, path: book.path, params: {} }, + { name: book.title || book.path, online: true }, + ); + setBusy(false); + // Whether a job is open changed with it. + void qc.invalidateQueries({ queryKey: keys.bridgeEstimate(book.library_id, book.path) }); + if (ok) onOpenChange(false); + }; + + return ( + + +
void submit(e)} className="contents" noValidate> + +

{t('bridge.describe.dialogBody')}

+

{t('bridge.describe.dialogCost')}

+

{t('bridge.describe.dialogWhere')}

+
+ + +
+
+ ); +} diff --git a/admin-ui/src/features/bridge/describe.test.tsx b/admin-ui/src/features/bridge/describe.test.tsx new file mode 100644 index 00000000..a3451bfa --- /dev/null +++ b/admin-ui/src/features/bridge/describe.test.tsx @@ -0,0 +1,278 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { setToken } from '@/api/token'; +import type { BridgeEstimate, BridgeJob } from '@/api/types'; +import { mockFetch, type MockRequest, type MockRoute } from '@/test/fetch-mock'; +import { serverInfo } from '@/test/fixtures'; +import { bookDetail } from '@/test/library-fixtures'; +import { renderApp } from '@/test/render-app'; +import { signedInRoutes } from '@/test/routes'; + +// The book page's description part (Bridge Phase 1c): the generated description +// with its mark and Show more, "Write a description" and its confirmation, and +// a describe job's status. + +const BOOK_PATH = 'Brandon Sanderson/The Stormlight Archive/01 - The Way of Kings'; +const URL = `/library/book?library=1&path=${encodeURIComponent(BOOK_PATH)}`; + +const bridgeOn = { + ...serverInfo, + capabilities: { ...serverInfo.capabilities, bridge: true }, + bridge: { version: '0.17.0', features: ['bridge', 'sidecars'] }, +}; + +const LONG_TEXT = + 'A storm-wracked land where soldiers fight over gemhearts on the Shattered Plains. ' + + 'A slave once a surgeon, a scholar with a secret debt and a highprince who sees ' + + 'visions are drawn toward the same old war, each asking what an oath is worth. ' + + 'The opening follows them as the first threads begin to cross.'; + +/** A brief estimate of a book with generated characters and recaps, and no description. */ +function estimate(over: Partial = {}): BridgeEstimate { + return { + kind: 'sidecars.generate', + library_id: 1, + path: BOOK_PATH, + source: 'audio', + duration_seconds: 163800, + asr_hours: 11.4, + agent_cost_usd: 18.2, + subscription: false, + agent_ready: true, + can_contribute: false, + figures: true, + writable: true, + can_describe: true, + has: { + community: { characters: false, recaps: false, description: false }, + local: { characters: true, recaps: true, description: false }, + }, + earlier: [], + ...over, + }; +} + +function job(over: Partial = {}): BridgeJob { + return { + id: 40, + kind: 'sidecars.describe', + library_id: 1, + path: BOOK_PATH, + params: {}, + state: 'running', + progress: {}, + result: {}, + error: '', + requested_by: { id: 1, username: 'chris' }, + priority: 0, + attempts: 1, + lease_until: null, + cancel_requested: false, + created_at: '2026-10-16T12:00:00Z', + updated_at: '2026-10-16T12:01:00Z', + claimed_at: '2026-10-16T12:00:05Z', + finished_at: null, + ...over, + }; +} + +function routes(over: Record = {}) { + return signedInRoutes({ + 'GET /server': { body: bridgeOn }, + 'GET /admin/libraries/1/book': { body: bookDetail() }, + 'GET /admin/bridge/jobs': { body: { jobs: [] } }, + 'GET /admin/bridge/estimate': { body: estimate() }, + ...over, + }); +} + +const queued = (calls: MockRequest[]) => + calls.filter((c) => c.method === 'POST' && c.path === '/admin/bridge/jobs'); + +beforeEach(() => setToken('stored')); +afterEach(() => vi.unstubAllGlobals()); + +describe('the generated description', () => { + it('shows the text with its mark, clamped behind Show more', async () => { + const user = userEvent.setup(); + mockFetch( + routes({ + 'GET /admin/libraries/1/book': { + body: bookDetail({ generated_description: { text: LONG_TEXT } }), + }, + 'GET /admin/bridge/estimate': { + body: estimate({ + has: { + community: { characters: false, recaps: false, description: false }, + local: { characters: true, recaps: true, description: true }, + }, + }), + }, + }), + ); + renderApp(URL); + const part = await screen.findByRole('region', { name: 'Description' }); + expect(within(part).getByText(LONG_TEXT)).toHaveClass('line-clamp-4'); + // The mark the characters-and-recaps part uses too. + expect(within(part).getByText('Generated on this server.')).toBeInTheDocument(); + const more = within(part).getByRole('button', { name: 'Show more' }); + expect(more).toHaveAttribute('aria-expanded', 'false'); + await user.click(more); + expect(within(part).getByText(LONG_TEXT)).not.toHaveClass('line-clamp-4'); + expect(within(part).getByRole('button', { name: 'Show less' })).toBeInTheDocument(); + // It has one already: nothing to offer. + expect(within(part).queryByRole('button', { name: 'Write a description' })).toBeNull(); + }); + + it('shows it even while the Bridge is away', async () => { + mockFetch( + routes({ + 'GET /server': { body: serverInfo }, + 'GET /admin/libraries/1/book': { + body: bookDetail({ generated_description: { text: 'Short enough to show whole.' } }), + }, + }), + ); + renderApp(URL); + const part = await screen.findByRole('region', { name: 'Description' }); + expect(within(part).getByText('Short enough to show whole.')).not.toHaveClass('line-clamp-4'); + expect(within(part).queryByRole('button', { name: 'Show more' })).toBeNull(); + }); +}); + +describe('write a description', () => { + it('confirms, then queues sidecars.describe with no params', async () => { + const user = userEvent.setup(); + const calls = mockFetch( + routes({ + 'POST /admin/bridge/jobs': { status: 201, body: job({ state: 'queued' }) }, + }), + ); + renderApp(URL); + await user.click(await screen.findByRole('button', { name: 'Write a description' })); + const dialog = await screen.findByRole('dialog', { name: 'Write a description' }); + expect(within(dialog).getByText(/from the notes it kept/)).toBeInTheDocument(); + expect(within(dialog).getByText(/No new speech recognition runs/)).toBeInTheDocument(); + expect(within(dialog).getByText(/in the book's About in the player/)).toBeInTheDocument(); + expect(queued(calls)).toHaveLength(0); + await user.click(within(dialog).getByRole('button', { name: 'Write description' })); + await waitFor(() => expect(queued(calls)).toHaveLength(1)); + expect(queued(calls)[0].body).toEqual({ + kind: 'sidecars.describe', + library_id: 1, + path: BOOK_PATH, + params: {}, + }); + }); + + it.each<[string, Partial]>([ + ['an older Bridge', { can_describe: undefined }], + ['a Bridge that cannot now', { can_describe: false }], + [ + 'a community description', + { + has: { + community: { characters: false, recaps: false, description: true }, + local: { characters: true, recaps: true, description: false }, + }, + }, + ], + [ + 'nothing generated to write from', + { + has: { + community: { characters: true, recaps: true, description: false }, + local: { characters: false, recaps: false, description: false }, + }, + }, + ], + ])('is not offered with %s', async (_name, over) => { + const calls = mockFetch(routes({ 'GET /admin/bridge/estimate': { body: estimate(over) } })); + renderApp(URL); + await screen.findByRole('heading', { level: 1, name: 'The Way of Kings' }); + await waitFor(() => expect(calls.some((c) => c.path === '/admin/bridge/estimate')).toBe(true)); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByRole('button', { name: 'Write a description' })).toBeNull(); + }); + + it('is not offered while a generate job is open (it writes one)', async () => { + mockFetch( + routes({ + 'GET /admin/bridge/jobs': { + body: { jobs: [job({ id: 39, kind: 'sidecars.generate', params: {} })] }, + }, + }), + ); + renderApp(URL); + // The generate job's own status shows; no describe action beside it. + expect(await screen.findByRole('button', { name: /cancel generating/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Write a description' })).toBeNull(); + }); + + it("follows a describe job's stage, and says when it failed", async () => { + mockFetch( + routes({ + 'GET /admin/bridge/jobs': { + body: { + jobs: [job({ progress: { step: 'describing', stage: 'describing', cost_usd: 0.12 } })], + }, + }, + }), + ); + renderApp(URL); + const part = await screen.findByRole('region', { name: 'Description' }); + expect(await within(part).findByText('Writing the description')).toBeInTheDocument(); + expect(within(part).getByText(/\$0\.12 so far/)).toBeInTheDocument(); + expect( + within(part).getByRole('button', { name: 'Cancel writing the description' }), + ).toBeInTheDocument(); + expect(within(part).queryByRole('button', { name: 'Write a description' })).toBeNull(); + }); + + it('shows a failed describe job with its error', async () => { + const error = "Couldn't write a description that passed the checks."; + mockFetch( + routes({ + 'GET /admin/bridge/jobs': { + body: { jobs: [job({ state: 'failed', error, finished_at: '2026-10-16T12:05:00Z' })] }, + }, + }), + ); + renderApp(URL); + const part = await screen.findByRole('region', { name: 'Description' }); + expect(await within(part).findByText(error)).toBeInTheDocument(); + // Not open any more: it can be asked again. + expect(within(part).getByRole('button', { name: 'Write a description' })).toBeInTheDocument(); + }); +}); + +describe("a generate job's description", () => { + it('says the description came with it, or why not', async () => { + mockFetch( + routes({ + 'GET /admin/bridge/jobs': { + body: { + jobs: [ + job({ + id: 41, + kind: 'sidecars.generate', + state: 'done', + finished_at: '2026-10-16T14:00:00Z', + result: { + characters: 3, + recaps: 2, + description: false, + description_error: 'the agent is rate limited', + }, + }), + ], + }, + }, + }), + ); + renderApp(URL); + expect( + await screen.findByText('No description this time: the agent is rate limited'), + ).toBeInTheDocument(); + }); +}); diff --git a/admin-ui/src/features/bridge/generate-model.test.ts b/admin-ui/src/features/bridge/generate-model.test.ts index 077aca89..22a01955 100644 --- a/admin-ui/src/features/bridge/generate-model.test.ts +++ b/admin-ui/src/features/bridge/generate-model.test.ts @@ -8,6 +8,9 @@ import { batchCheck, batchPlan, bridgeJobLink, + clampsDescription, + DESCRIPTION_CLAMP_CHARS, + describeOffered, destinationKey, estimateView, GENERATE_PARKED_POLL_MS, @@ -19,10 +22,12 @@ import { generatePollMs, generateProgress, generateResult, + KIND_DESCRIBE, KIND_GENERATE, KNOWN_PARKS, KNOWN_STAGES, latestGenerateJob, + latestJobOf, parkKey, planEstimate, roundEta, @@ -30,6 +35,7 @@ import { settleLimited, stageLabel, titleCase, + wordsItsOwnStatus, type BatchCheck, } from './generate-model'; @@ -620,3 +626,97 @@ describe('the series batch', () => { expect(started).toEqual([1, 2]); }); }); + +describe('a description (Phase 1c)', () => { + const has = ( + local: Partial>, + community: Partial> = {}, + ) => ({ + community: { characters: false, recaps: false, description: false, ...community }, + local: { characters: false, recaps: false, description: false, ...local }, + }); + const open = (kind: string, state: BridgeJobState = 'running') => ({ kind, state }); + + it('is offered for a book with generated content, no description and nothing open', () => { + expect(describeOffered({ can_describe: true, has: has({ characters: true }) }, [])).toBe(true); + expect(describeOffered({ can_describe: true, has: has({ recaps: true }) }, undefined)).toBe( + true, + ); + // A finished or cancelled job is no reason to hold back. + expect( + describeOffered({ can_describe: true, has: has({ recaps: true }) }, [ + open(KIND_DESCRIBE, 'failed'), + open(KIND_GENERATE, 'done'), + ]), + ).toBe(true); + }); + + it('is not offered without a Bridge that can, content to write from, or with one already', () => { + const local = has({ characters: true, recaps: true }); + expect(describeOffered(undefined, [])).toBe(false); + expect(describeOffered({ has: local }, [])).toBe(false); + expect(describeOffered({ can_describe: false, has: local }, [])).toBe(false); + expect(describeOffered({ can_describe: true, has: has({}) }, [])).toBe(false); + expect( + describeOffered( + { can_describe: true, has: has({ characters: true, description: true }) }, + [], + ), + ).toBe(false); + expect( + describeOffered( + { can_describe: true, has: has({ characters: true }, { description: true }) }, + [], + ), + ).toBe(false); + }); + + it('is not offered while a generate or describe job is open', () => { + const e = { can_describe: true, has: has({ characters: true }) }; + expect(describeOffered(e, [open(KIND_GENERATE)])).toBe(false); + expect(describeOffered(e, [open(KIND_DESCRIBE, 'needs_attention')])).toBe(false); + // Another kind (a verify) doesn't count. + expect(describeOffered(e, [open('bridge.verify')])).toBe(true); + }); + + it('polls a describe job like a generate one, and finds each kind', () => { + expect(generatePollMs([open(KIND_DESCRIBE)])).toBe(GENERATE_POLL_MS); + expect(generatePollMs([open(KIND_DESCRIBE, 'needs_attention')])).toBe(GENERATE_PARKED_POLL_MS); + const jobs = [open(KIND_DESCRIBE), open(KIND_GENERATE, 'done')]; + expect(latestJobOf(jobs, KIND_GENERATE)).toBe(jobs[1]); + expect(latestJobOf(jobs, KIND_DESCRIBE)).toBe(jobs[0]); + expect(latestGenerateJob(jobs)).toBe(jobs[1]); + expect(wordsItsOwnStatus(KIND_DESCRIBE)).toBe(true); + expect(wordsItsOwnStatus('bridge.verify')).toBe(false); + }); + + it("reads a result's description outcome", () => { + expect(generateResult({ description: true, checked_against: 'none' })).toEqual({ + description: true, + contributed: [], + }); + expect( + generateResult({ characters: 1, description: false, description_error: ' rate limited ' }), + ).toEqual({ + characters: 1, + description: false, + descriptionError: 'rate limited', + contributed: [], + }); + // An error beside a written description says nothing. + expect(generateResult({ description: true, description_error: 'x' })?.descriptionError).toBe( + undefined, + ); + expect(generateResult({ description: 'yes' })).toBeNull(); + }); + + it('clamps a long description only', () => { + expect(clampsDescription('x'.repeat(DESCRIPTION_CLAMP_CHARS))).toBe(false); + expect(clampsDescription('x'.repeat(DESCRIPTION_CLAMP_CHARS + 1))).toBe(true); + }); + + it('labels the describing stage', () => { + expect(KNOWN_STAGES).toContain('describing'); + expect(strings['bridge.stage.describing']).toBe('Writing the description'); + }); +}); diff --git a/admin-ui/src/features/bridge/generate-model.ts b/admin-ui/src/features/bridge/generate-model.ts index 9218aaf8..04e3702b 100644 --- a/admin-ui/src/features/bridge/generate-model.ts +++ b/admin-ui/src/features/bridge/generate-model.ts @@ -12,14 +12,20 @@ import { relBaseName } from '@/lib/paths'; import { spanIn } from '@/features/library/series/series-model'; import { isOpen, num, progressOf, safeHttpUrl, text } from './bridge-model'; -// Characters and recaps from the Bridge (BRIDGE-PLAN.md Phase 1a): the rules the -// book page's Community card, its generate dialog, the series batch and the -// Bridge page's job rows follow for `sidecars.generate`. Pure: no React, no i18n +// Characters and recaps from the Bridge (BRIDGE-PLAN.md Phase 1a), and the +// description written from their notes (Phase 1c): the rules the book page's +// Community card, its generate dialog, the series batch and the Bridge page's +// job rows follow for `sidecars.generate` and `sidecars.describe`. Pure: no React, no i18n // instance; callers pass the keys these return to t(). /** The job kind, and the feature (job family) the Bridge advertises for it. */ export const KIND_GENERATE = 'sidecars.generate'; export const FEATURE_SIDECARS = 'sidecars'; +/** Phase 1c's description-only kind: a description from the notes the Bridge kept. */ +export const KIND_DESCRIBE = 'sidecars.describe'; + +/** The kinds the book page follows: generate and describe (both in the sidecars family). */ +const BOOK_KINDS: readonly string[] = [KIND_GENERATE, KIND_DESCRIBE]; /** * Whether the Generate action is offered at all: community metadata is on (the @@ -52,9 +58,9 @@ export function generateAction(has: { } /** - * How often a book's generate jobs are asked again: about every 10 s while one - * is queued or working, every minute while one is parked (it waits on a person - * or a reset), else not at all. + * How often a book's generate and describe jobs are asked again: about every + * 10 s while one is queued or working, every minute while one is parked (it + * waits on a person or a reset), else not at all. */ export const GENERATE_POLL_MS = 10_000; export const GENERATE_PARKED_POLL_MS = 60_000; @@ -62,16 +68,55 @@ export const GENERATE_PARKED_POLL_MS = 60_000; export function generatePollMs( jobs: readonly Pick[] | undefined, ): number | false { - const open = jobs?.filter((j) => j.kind === KIND_GENERATE && isOpen(j.state)) ?? []; + const open = jobs?.filter((j) => BOOK_KINDS.includes(j.kind) && isOpen(j.state)) ?? []; if (open.some((j) => j.state !== 'needs_attention')) return GENERATE_POLL_MS; return open.length ? GENERATE_PARKED_POLL_MS : false; } -/** The book's newest generate job (the list is newest first), if any. */ +/** The book's newest job of a kind (the list is newest first), if any. */ +export function latestJobOf>( + jobs: readonly J[] | undefined, + kind: string, +): J | undefined { + return jobs?.find((j) => j.kind === kind); +} + +/** The book's newest generate job, if any. */ export function latestGenerateJob>( jobs: readonly J[] | undefined, ): J | undefined { - return jobs?.find((j) => j.kind === KIND_GENERATE); + return latestJobOf(jobs, KIND_GENERATE); +} + +/** Whether a job's progress and result are worded by GenerateStatus (generate and describe). */ +export function wordsItsOwnStatus(kind: string): boolean { + return BOOK_KINDS.includes(kind); +} + +/** + * Whether the card offers "Write a description": the Bridge can (online, runs + * sidecars.describe), the book has characters or recaps generated here (their + * notes are what the description is written from), it has no description yet, + * here or from the community, and no generate or describe job is open for it + * (a generate job writes one when it finishes). + */ +export function describeOffered( + e: Pick | undefined, + jobs: readonly Pick[] | undefined, +): boolean { + if (!e?.can_describe) return false; + const { local, community } = e.has; + if (!(local.characters || local.recaps) || local.description || community.description) { + return false; + } + return !(jobs ?? []).some((j) => BOOK_KINDS.includes(j.kind) && isOpen(j.state)); +} + +/** A generated description long enough to clamp, with a Show more toggle (about four lines). */ +export const DESCRIPTION_CLAMP_CHARS = 240; + +export function clampsDescription(text: string): boolean { + return text.trim().length > DESCRIPTION_CLAMP_CHARS; } // The engine's stage names (state.State) and the Bridge's own steps, each with a @@ -100,6 +145,7 @@ export const KNOWN_STAGES = [ 'waiting', 'writing', 'rescanning', + 'describing', ] as const; /** "qa_sweep" -> "Qa sweep": a name this console has no label for, made readable. */ @@ -216,11 +262,14 @@ export function generateProgress(progress: unknown): GenerateProgress { return out; } -/** A finished generate job's result as the card words it. */ +/** A finished generate (or describe) job's result as the card words it. */ export interface GenerateResult { characters?: number; recaps?: number; costUsd?: number; + /** The description was written (true) or not (false, with why); absent from an older Bridge. */ + description?: boolean; + descriptionError?: string; /** The community issues filed (http(s) links only), by what each offers. */ contributed: { what: string; url: string }[]; } @@ -236,6 +285,9 @@ export function generateResult(result: unknown): GenerateResult | null { if (recaps !== undefined) out.recaps = recaps; const cost = amount(r.cost_usd); if (cost !== undefined) out.costUsd = cost; + if (typeof r.description === 'boolean') out.description = r.description; + const why = text(r.description_error); + if (out.description === false && why) out.descriptionError = why; if (r.contributed && typeof r.contributed === 'object') { for (const [what, url] of Object.entries(r.contributed as Record)) { const safe = typeof url === 'string' ? safeHttpUrl(url) : undefined; @@ -246,6 +298,7 @@ export function generateResult(result: unknown): GenerateResult | null { out.characters !== undefined || out.recaps !== undefined || out.costUsd !== undefined || + out.description !== undefined || out.contributed.length > 0; return any ? out : null; } diff --git a/admin-ui/src/features/bridge/generate-status.tsx b/admin-ui/src/features/bridge/generate-status.tsx index 88cef639..9fa8268a 100644 --- a/admin-ui/src/features/bridge/generate-status.tsx +++ b/admin-ui/src/features/bridge/generate-status.tsx @@ -7,6 +7,7 @@ import { bridgeJobLink, generateProgress, generateResult, + KIND_DESCRIBE, parkKey, roundEta, stageLabel, @@ -14,11 +15,12 @@ import { import { isOpen } from './bridge-model'; /** - * Where a `sidecars.generate` job stands, in a few lines: while it works, the - * stage, the count, the ETA and the cost so far (with a bar when there is a - * count); parked, why and the hint, with a link into the Bridge's own page; - * done, what it made; failed, the error. The book page's Community card and the - * Bridge page's job rows both show a job this way. + * Where a `sidecars.generate` (or `sidecars.describe`) job stands, in a few + * lines: while it works, the stage, the count, the ETA and the cost so far (with + * a bar when there is a count); parked, why and the hint, with a link into the + * Bridge's own page; done, what it made (a generate job's description outcome + * too); failed, the error. The book page's Community card and the Bridge page's + * job rows both show a job this way. */ export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string }) { const { t, i18n } = useTranslation(); @@ -27,17 +29,21 @@ export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string if (job.state === 'done') { const r = generateResult(job.result); - const made = [ - r?.characters !== undefined - ? t('bridge.generate.characters', { - count: r.characters, - n: formatNumber(r.characters, lang), - }) - : undefined, - r?.recaps !== undefined - ? t('bridge.generate.recaps', { count: r.recaps, n: formatNumber(r.recaps, lang) }) - : undefined, - ].filter((s): s is string => Boolean(s)); + const describe = job.kind === KIND_DESCRIBE; + const made = describe + ? [t('bridge.describe.done')] + : [ + r?.characters !== undefined + ? t('bridge.generate.characters', { + count: r.characters, + n: formatNumber(r.characters, lang), + }) + : undefined, + r?.recaps !== undefined + ? t('bridge.generate.recaps', { count: r.recaps, n: formatNumber(r.recaps, lang) }) + : undefined, + r?.description ? t('bridge.generate.descriptionMade') : undefined, + ].filter((s): s is string => Boolean(s)); const facts = [ made.length ? formatList(made, lang) : t('bridge.generate.doneNoCounts'), r?.costUsd !== undefined @@ -53,6 +59,13 @@ export function GenerateStatus({ job, uiUrl }: { job: BridgeJob; uiUrl?: string