Skip to content

feat: ✨ authenticated catalog covers via Coil + thumbnail-rel parsing#2

Merged
vitofico merged 4 commits into
mainfrom
feature/catalog-covers-auth
May 7, 2026
Merged

feat: ✨ authenticated catalog covers via Coil + thumbnail-rel parsing#2
vitofico merged 4 commits into
mainfrom
feature/catalog-covers-auth

Conversation

@vitofico

@vitofico vitofico commented May 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Wire Coil's ImageLoader to the existing OPDS OkHttpClient via an ImageLoaderFactory on EReaderApp, so cover requests inherit the BasicAuthInterceptor. Without this, calibre-web cover requests 401'd silently and the catalog grid only ever showed the gradient/initials fallback.
  • Replace the Readium-routed cover-link extraction in OpdsClient with a small XML scan over each <entry>, joined back to publications by acquisition href. Readium's OPDS model populates pub.subcollections["images"] only when opds:image is present and silently drops opds:image/thumbnail when it appears alone — reordering rels couldn't fix the thumbnail-only case. The new scan prefers opds:image/thumbnail over opds:image (saves bandwidth on a 2-col grid) and mirrors the existing parseSearchLink workaround in the same file.
  • Spec and implementation plan checked in under docs/superpowers/.

Test plan

  • scripts/dgradle :data:opds:testDebugUnitTest — new parser tests cover both-rels (thumbnail wins), thumbnail-only, image-only fallback; existing tests unchanged.
  • scripts/dgradle :app:testDebugUnitTest
  • scripts/dgradle :app:assembleDebug produces a working APK.
  • Sideload + manual: covers populate the catalog grid against an authenticated calibre-web instance; missing covers fall back to the calibre-web placeholder (server-served).

vitofico added 4 commits May 7, 2026 18:11
Documents the gap (Coil uses a default ImageLoader without the OPDS
BasicAuthInterceptor, so calibre-web cover requests 401) and the planned
fix: an ImageLoaderFactory on EReaderApp wired to OpdsHttpClient.okHttp,
plus reordering OpdsClient to prefer rel="…/image/thumbnail" over
rel="…/image" for grid use.
Plan for wiring Coil to OpdsHttpClient.okHttp via an ImageLoaderFactory
on EReaderApp, and reordering OpdsClient to prefer rel="…/image/thumbnail"
over rel="…/image". Tests, fixtures, and manual verification steps
included. References the design at
docs/superpowers/specs/2026-05-07-catalog-covers-design.md.
Readium's OPDS model populates pub.subcollections["images"] only when an
opds:image rel is present and silently drops opds:image/thumbnail when it
appears alone. Skip the Readium-routed image extraction and parse cover
links directly from each <entry>, mirroring the existing parseSearchLink
workaround for the analogous {searchTerms} quirk. Within an entry, prefer
opds:image/thumbnail (sized for the 2-col grid) over opds:image, and join
back to Readium's parsed publications via the unique epub acquisition href.

Spec at docs/superpowers/specs/2026-05-07-catalog-covers-design.md updated
to record the discovered Readium behavior.
Coil's default singleton ImageLoader has no awareness of the OPDS
BasicAuthInterceptor, so cover requests against authenticated calibre-web
instances 401 silently and the catalog grid only ever shows the
gradient+initials fallback. Implement coil.ImageLoaderFactory on
EReaderApp with an ImageLoader built against the existing
OpdsHttpClient.okHttp, so Coil inherits Basic auth (and any future
interceptors) automatically. AppContainer's opdsHttp is promoted from
private to public to be reachable from the Application class.
@vitofico vitofico self-assigned this May 7, 2026
@vitofico
vitofico force-pushed the feature/catalog-covers-auth branch from b798b1e to 8f46f74 Compare May 7, 2026 16:53
@vitofico vitofico changed the title Show authenticated catalog covers (Coil + thumbnail-rel parsing) feat: ✨ authenticated catalog covers via Coil + thumbnail-rel parsing May 7, 2026
@vitofico
vitofico merged commit 7746cb4 into main May 7, 2026
5 checks passed
@vitofico
vitofico deleted the feature/catalog-covers-auth branch May 7, 2026 17:27
vitofico added a commit that referenced this pull request May 8, 2026
…#2)

* docs: 📝 design for authenticated catalog cover loading

Documents the gap (Coil uses a default ImageLoader without the OPDS
BasicAuthInterceptor, so calibre-web cover requests 401) and the planned
fix: an ImageLoaderFactory on EReaderApp wired to OpdsHttpClient.okHttp,
plus reordering OpdsClient to prefer rel="…/image/thumbnail" over
rel="…/image" for grid use.

* docs: 📝 implementation plan for catalog covers

Plan for wiring Coil to OpdsHttpClient.okHttp via an ImageLoaderFactory
on EReaderApp, and reordering OpdsClient to prefer rel="…/image/thumbnail"
over rel="…/image". Tests, fixtures, and manual verification steps
included. References the design at
docs/superpowers/specs/2026-05-07-catalog-covers-design.md.

* feat: ✨ prefer OPDS thumbnail rel and survive thumbnail-only feeds

Readium's OPDS model populates pub.subcollections["images"] only when an
opds:image rel is present and silently drops opds:image/thumbnail when it
appears alone. Skip the Readium-routed image extraction and parse cover
links directly from each <entry>, mirroring the existing parseSearchLink
workaround for the analogous {searchTerms} quirk. Within an entry, prefer
opds:image/thumbnail (sized for the 2-col grid) over opds:image, and join
back to Readium's parsed publications via the unique epub acquisition href.

Spec at docs/superpowers/specs/2026-05-07-catalog-covers-design.md updated
to record the discovered Readium behavior.

* feat: ✨ route Coil image loads through authenticated OPDS OkHttp client

Coil's default singleton ImageLoader has no awareness of the OPDS
BasicAuthInterceptor, so cover requests against authenticated calibre-web
instances 401 silently and the catalog grid only ever shows the
gradient+initials fallback. Implement coil.ImageLoaderFactory on
EReaderApp with an ImageLoader built against the existing
OpdsHttpClient.okHttp, so Coil inherits Basic auth (and any future
interceptors) automatically. AppContainer's opdsHttp is promoted from
private to public to be reachable from the Application class.
vitofico added a commit that referenced this pull request May 8, 2026
…#2)

* docs: 📝 design for authenticated catalog cover loading

Documents the gap (Coil uses a default ImageLoader without the OPDS
BasicAuthInterceptor, so calibre-web cover requests 401) and the planned
fix: an ImageLoaderFactory on EReaderApp wired to OpdsHttpClient.okHttp,
plus reordering OpdsClient to prefer rel="…/image/thumbnail" over
rel="…/image" for grid use.

* docs: 📝 implementation plan for catalog covers

Plan for wiring Coil to OpdsHttpClient.okHttp via an ImageLoaderFactory
on EReaderApp, and reordering OpdsClient to prefer rel="…/image/thumbnail"
over rel="…/image". Tests, fixtures, and manual verification steps
included. References the design at
docs/superpowers/specs/2026-05-07-catalog-covers-design.md.

* feat: ✨ prefer OPDS thumbnail rel and survive thumbnail-only feeds

Readium's OPDS model populates pub.subcollections["images"] only when an
opds:image rel is present and silently drops opds:image/thumbnail when it
appears alone. Skip the Readium-routed image extraction and parse cover
links directly from each <entry>, mirroring the existing parseSearchLink
workaround for the analogous {searchTerms} quirk. Within an entry, prefer
opds:image/thumbnail (sized for the 2-col grid) over opds:image, and join
back to Readium's parsed publications via the unique epub acquisition href.

Spec at docs/superpowers/specs/2026-05-07-catalog-covers-design.md updated
to record the discovered Readium behavior.

* feat: ✨ route Coil image loads through authenticated OPDS OkHttp client

Coil's default singleton ImageLoader has no awareness of the OPDS
BasicAuthInterceptor, so cover requests against authenticated calibre-web
instances 401 silently and the catalog grid only ever shows the
gradient+initials fallback. Implement coil.ImageLoaderFactory on
EReaderApp with an ImageLoader built against the existing
OpdsHttpClient.okHttp, so Coil inherits Basic auth (and any future
interceptors) automatically. AppContainer's opdsHttp is promoted from
private to public to be reachable from the Application class.
vitofico added a commit that referenced this pull request May 8, 2026
…#2)

* docs: 📝 design for authenticated catalog cover loading

Documents the gap (Coil uses a default ImageLoader without the OPDS
BasicAuthInterceptor, so calibre-web cover requests 401) and the planned
fix: an ImageLoaderFactory on EReaderApp wired to OpdsHttpClient.okHttp,
plus reordering OpdsClient to prefer rel="…/image/thumbnail" over
rel="…/image" for grid use.

* docs: 📝 implementation plan for catalog covers

Plan for wiring Coil to OpdsHttpClient.okHttp via an ImageLoaderFactory
on EReaderApp, and reordering OpdsClient to prefer rel="…/image/thumbnail"
over rel="…/image". Tests, fixtures, and manual verification steps
included. References the design at
docs/superpowers/specs/2026-05-07-catalog-covers-design.md.

* feat: ✨ prefer OPDS thumbnail rel and survive thumbnail-only feeds

Readium's OPDS model populates pub.subcollections["images"] only when an
opds:image rel is present and silently drops opds:image/thumbnail when it
appears alone. Skip the Readium-routed image extraction and parse cover
links directly from each <entry>, mirroring the existing parseSearchLink
workaround for the analogous {searchTerms} quirk. Within an entry, prefer
opds:image/thumbnail (sized for the 2-col grid) over opds:image, and join
back to Readium's parsed publications via the unique epub acquisition href.

Spec at docs/superpowers/specs/2026-05-07-catalog-covers-design.md updated
to record the discovered Readium behavior.

* feat: ✨ route Coil image loads through authenticated OPDS OkHttp client

Coil's default singleton ImageLoader has no awareness of the OPDS
BasicAuthInterceptor, so cover requests against authenticated calibre-web
instances 401 silently and the catalog grid only ever shows the
gradient+initials fallback. Implement coil.ImageLoaderFactory on
EReaderApp with an ImageLoader built against the existing
OpdsHttpClient.okHttp, so Coil inherits Basic auth (and any future
interceptors) automatically. AppContainer's opdsHttp is promoted from
private to public to be reachable from the Application class.
vitofico added a commit that referenced this pull request May 20, 2026
…insight cache (#33)

* ✨ feat(ai): BookInsight schema v4 + prompt-version wiring fix

PR-ε of Bundle 2.

Server:
- BookInsightPayload schema v3 -> v4 with five optional depth fields:
  theme_analysis (dict, hard-capped at 2 keys server-side), craft_notes,
  comparative_anchors (sanitized list, blank-drop + cap-at-4),
  distinctive_take, discussion_prompts. New ComparativeAnchor sub-type.
- PROMPT_VERSION "4" -> "5"; system prompt instructs the model to emit
  the v4 keys with two-themes cap, no invented titles, no plot reveals.
- _do_generate pins payload.schema_version = 4.
- New core/ai/_compat.py::_resolve_prompt_version applies Lock #19
  sentinel semantics: env value "1" / "" / None resolves to the in-code
  PROMPT_VERSION constant; any other value is honored verbatim as the
  Lock #2 emergency rollback override. Helper lives in core.ai._compat
  so the provider lazy-import boundary stays intact (coordinator §3.18).
- main.py routes settings.ai_prompt_version through the helper inside
  the existing if settings.ai_enabled: block. Fixes the original bug
  where settings default "1" flowed through unchanged.
- test_lazy_imports.py SYNC_ONLY_FORBIDDEN gains _compat.

Android:
- BookInsightPayload DTO catch-up from v2 -> v3 -> v4: adds themes
  (v3 catch-up), themeAnalysis, craftNotes, comparativeAnchors,
  distinctiveTake, discussionPrompts. Default schemaVersion = 4.
  New ComparativeAnchor data class. Old v2/v3 payloads still
  deserialize cleanly.
- InsightCards.kt adds five collapsible cards gated on non-null +
  non-empty/non-blank predicates with display-side sanity filters.
- New BookInsightPayloadJsonTest (v2/v3/v4 round-trip + default).
- New InsightSectionVisibilityTest (predicate-level coverage).

Docs:
- docs/sync-api.md updated to schema v4 with the new fields documented
  and a paragraph on prompt_version resolution semantics.

Honors: Locks #2 / #7 / #19; coordinator §3.1 / §3.2 / §3.10 / §3.18.

* ✨ feat(ai): catalog-download insight promote

PR-ζ of Bundle 2.

Server:
- New `POST /ai/v1/insights/promote` route. Row-copy + alias-link
  promote (Lock #1): copies the cached `book_insights` row at the
  catalog identity onto the post-download canonical identity with
  `generated_at = NOW()` (Lock #23) and `previous_insight_ids=[src.id]`
  for lineage audit; copies `book_themes` rows; writes a user-scoped
  `insight_identity_aliases` row with `source='promoted_on_download'`
  as the idempotency anchor.
- 204 when nothing to promote; 403 on cross-user ownership; 409
  `ai_not_opted_in` per Lock #10 / CC-1 (normalized literal across
  all `/ai/v1/insights/*` routes); 429 honoring per-user daily limit
  `QUIRE_SERVER_AI_PROMOTE_DAILY_LIMIT` (default 100; process-local).
- Structured stdout audit `event=ai.promote ...` (Lock #11 amendment;
  NO `ai_generation_log` row in PR-ζ — PR-β extends the audit schema).
- Different `(tone, language)` re-copies under existing alias.
- New `CatalogInsightStash` + `CatalogInsightStashEntry` types in
  `data/ai` (Lock #16). Process-local, TTL 30 min, partitioned by
  `(subject, opdsHref)`, cleared on base-URL change.
- `AiClient.promoteInsight` (200/204/4xx handling) and
  `AiRepository.promoteInsight` (best-effort Boolean for PR-η wrap).
- `CatalogDetailViewModel` writes to the stash on every successful
  catalog-side insight lookup (cache hit or miss).
- `CatalogViewModel.download` now `await`s `libraryUploader
  .enqueueOne(...).join()` before firing promote (Lock #13: library
  PUT lands before the server's `_assert_owns` runs). Promote failures
  keep the stash entry for retry; success removes it.
- DI: `AppContainer` owns the singleton stash, wires it into both
  view-model factories, and clears it on credential base-URL change.

Docs:
- `docs/sync-api.md` documents the new endpoint surface, response
  matrix, and rate limit.
- `server/README.md` env-var table gains
  `QUIRE_SERVER_AI_PROMOTE_DAILY_LIMIT`.

Honors: Locks #1 / #10 / #11 (amendment) / #13 / #16 / #23;
coordinator §3.14 / §3.17 / §3.18 / §3.20.

* ✨ feat(ai): /ai/v1/insights/sync + prompt_version in /config

PR-η of Bundle 2 (scope-reduced — see deviations below).

Server:
- New `GET /ai/v1/insights/sync`: paginated, read-only, weight=0 bulk
  export of the caller's owned-book insights joined through
  `library_items`. Cursor is the `(generated_at, id)` tuple (Lock #23)
  with strict-lexicographic comparison so identical timestamps never
  drop rows. Filters on `superseded_at IS NULL`,
  `library_items.deleted_at IS NULL`, and the caller's current
  `(model_id, prompt_version, tone, language)`. PR9 priority case
  prefers metadata-id matches over content-hash-only. Promoted rows
  (PR-ζ) participate naturally via their `generated_at = NOW()` stamp.
- `GET /ai/v1/config` gains `prompt_version: str` (Lock #24) sourced
  from PR-ε's `_resolve_prompt_version` helper. Older deploys decode
  safely on the Android side because the DTO default is the legacy
  sentinel `"1"`.
- New schemas: `InsightSyncCursor`, `InsightSyncItem`,
  `InsightSyncResponse`.
- 10 integration tests cover opt-in gate (Lock #10), 503 when AI
  unconfigured, cross-user isolation, prompt-version / superseded /
  deleted-library-item filters, half-supplied cursor 400, three-page
  tuple-cursor walk with distinct timestamps, and weight=0 budget
  invariant. `test_config_endpoint_*` extended to assert
  `prompt_version`.

Android (data/ai):
- `AiConfig` DTO gains `promptVersion` (default "1" for back-compat).
- New DTOs `InsightSyncCursor`, `InsightSyncItem`, `InsightSyncResponse`.
- `AiClient.syncInsights(cursor, limit)` using `HttpUrl.Builder` so the
  cursor's ISO 8601 `+` is percent-encoded correctly. Decodes 200,
  raises `AiHttpException` on non-2xx.
- 6 new MockWebServer tests cover cursor encoding, response parsing,
  409 propagation, and config `prompt_version` decode (present and
  omitted).

Docs:
- `docs/sync-api.md` documents `GET /ai/v1/insights/sync` and the
  `prompt_version` field on `/ai/v1/config`.

Deviations from pr-eta-plan.md (deliberate scope reduction to fit the
remaining budget; full plan items deferred to follow-up):
- `AiRepository.kt` is NOT moved from `app/` to `:data:ai` (Lock #14
  amendment). Existing call sites continue to work; the move is a
  pure refactor that can land separately.
- Room v6→v7 + `InsightEntity` + `InsightDao` + `InsightSyncRepository`
  + Settings "Refresh insights" UI deferred. The wire format is fully
  defined and tested; the on-device cache + sync orchestration is the
  next slice.
- No `requestSync` trigger wiring in `CatalogViewModel` / `EReaderApp`.

Honors: Locks #10 / #19 / #23 / #24; coordinator §3.1 / §3.15 / §3.17 /
§3.18.

* ✨ feat(ai): Android local insight cache + bulk-sync (PR-η)

Moves AiRepository.kt from :app to :data:ai (Lock #14 amendment) and
wraps it with a local-cache-first read path backed by the new
book_insights Room table (v6→v7, Lock #8). On cache miss the wrappers
fall through to the existing AiClient and write the response back.

Adds InsightSyncRepository which orchestrates GET /ai/v1/insights/sync
against the local cache, walking the (generated_at, id) tuple cursor
(Lock #23) and coalescing burst triggers within a 2s window. Fires on
app start (post library upload), after every successful promote
(coordinator §3.17), and from a new "Refresh insights" button in the
Settings AI section.

Behavior contracts:
- IOException on getCachedInsight() returns findAnyForIdentity row;
  401/409/5xx propagate (decisions.md CC §5).
- lookupInsight is the active-fetch path — IOException is not swallowed.
- syncNow() is mutex-guarded; concurrent callers single-flight.
- Eviction policy "none in v1"; rows at obsolete (model, prompt, style)
  keys go cold but remain reachable via findAnyForIdentity fallback.

No server-side changes (the /sync endpoint shipped in 37b3f03).

* ✅ test(ai): coverage for local insight cache + sync (PR-η)

- MigrationTest: v6→v7 creates book_insights with the 4 indices and
  preserves pre-existing documents rows.
- InsightDaoTest: round-trip upsert/getByIdentity, REPLACE semantics on
  same-PK upsert, findAnyForIdentity returns latest across styles,
  cursor-tip queries, prompt-version key separation.
- AiRepositoryLocalFirstTest: local-cache short-circuits both lookup
  and getCached paths, write-back on 200, narrow IOException fallback
  via findAnyForIdentity, 401/409/500 rethrow, prompt-version change
  invalidates by key, and the AiConfig default for the new
  prompt_version field on legacy server responses.
- InsightSyncRepositoryTest: tuple-cursor walk across pages including
  the cursor query params, skip on ai_enabled=false, Failed on network
  error, and burst coalescing within the debounce window.

* 🐛 fix(tests): make insight-sync coalesce test CI-robust

Replace the brittle `server.requestCount == 3` assertion (which raced
against the real Dispatchers.IO HTTP delivery) with a synchronization
on `server.takeRequest(5s)` followed by a negative-window check
`server.takeRequest(200ms) == null`. Drives the same coalescing
guarantee but waits for actual I/O instead of polling a counter.
vitofico added a commit that referenced this pull request May 21, 2026
…le (pr-γ)

PR-γ of Bundle 3 (Reader Profile) — adds the user-visible Reader Profile
surface on Android. New "Insights" entry on the Library overflow menu
opens LibraryInsightsScreen, which renders five sealed UI states
(Disabled / Empty / Loading / Loaded / Error) over GET /ai/v1/profile and
POST /ai/v1/profile/refresh.

Highlights:

* AiClient gains refreshProfile() (200 → ReaderProfileResponseDto, 409
  ai_not_opted_in, 429 quota, 5xx model failure) and deleteProfile()
  (idempotent 204 — wired by PR-δ from Settings).
* AiRepository.refreshProfile drains the sync orchestrator + library
  uploader best-effort before calling the server (Lock #6 NTH #9;
  corrections.md PR-γ Required #6). Preflight failures log via the
  PreflightOutcome callback and the refresh proceeds regardless.
* Decoupling via two fun interfaces (ProfilePreflightSync /
  ProfilePreflightLibrary) so :data:ai never depends on :data:sync or
  :data:library.
* AiConfig.progressSupported (Lock #5) consumed to drive
  Disabled.ProgressUnsupported; the server already advertises the field.
* Local input-fingerprint approximation per Lock #12 (mirrors PR-β's
  server recipe; uses ReaderStats.booksWithThemesCount per Lock #15 / CC-8,
  NOT topThemes.size). Mismatch renders a low-emphasis "Profile may be
  out of date" hint (architect Finding #2).
* In-library recommendations resolve via DocumentRepository.findByIdentity
  → /reader/{id}; discovery recommendations expose an explicit "Open in
  browser" button gated on https:// (architect Finding #4), wrapped in
  runCatching for ActivityNotFoundException.
* Coverage meter uses booksWithThemesCount/totalBooks (NTH #12, Lock #15).
* AppNavGraph wires composable("library/insights"); AppContainer adds
  LibraryInsightsViewModelFactory + a StateFlow<Boolean> over
  AiPreferences.aiEnabled.

Tests cover the disabled/empty/loaded/error state transitions, the local
fingerprint recipe (match + mismatch + null cases), and best-effort
preflight semantics (sync→library→refresh order, network still fires when
preflight throws). All :app:testDebugUnitTest and :data:ai:testDebugUnitTest
pass.
vitofico added a commit that referenced this pull request May 21, 2026
…-β + PR-γ + PR-δ) (#34)

* ✨ feat(server): reader_profiles substrate + abandoned_at + ReaderStats v1 + GET /ai/v1/profile

Lands the server half of pr-α (Bundle 3 / Reader Profile substrate):

* Migrations
  - progress_002: add `progress.abandoned_at` + check constraint
    `finished_at IS NULL OR abandoned_at IS NULL` + partial index on
    `(document_pk) WHERE abandoned_at IS NOT NULL`.
  - ai_005: new `reader_profiles` table keyed on `(tenant_id, subject)`
    with `payload JSONB`, `schema_version`, `model_id`, `prompt_version`,
    `input_fingerprint VARCHAR(16)` (Lock #12 / coordinator §3.6).

* ORM
  - `Progress.abandoned_at` + `ck_progress_abandoned_xor_finished`.
  - New `ReaderProfile` model — user-scoped, NOT shared cache.

* Pydantic
  - `AuthorCount`, `ReaderStats` (with `books_with_themes_count: int = 0`
    per Lock #15 / CC-8), `BookRec` (full §3.4 provenance shape with
    safe defaults), `ReaderProfilePayload` (schema_version=1), and the
    `ReaderProfileResponse` envelope.

* `_compute_reader_stats` (`quire_server.core.ai.service`)
  - Deterministic per-user stats: total/finished/in-progress/abandoned
    counts, `finish_rate_by_theme` (top-10), `most_read_authors`
    (top-5).
  - Mirrors PR9's `pick_priority = case(...)` pattern exactly
    (coordinator §3.4 REJECT (e)).
  - pr-α emits `books_with_themes_count = 0`; pr-β replaces with the
    real count.

* Terminal-state invariant
  - `push_progress` clears the opposite terminal flag on a flip;
    preserves `percent` on the abandon transition.
  - `pull_progress` drops `abandoned_at` (finished wins) for corrupt
    legacy rows and emits a structured warning.

* `GET /ai/v1/profile`
  - Cache-only read. No opt-in gate (opted-out users can still read
    their last generation). 404 when no row exists. Refresh / LLM
    generation lives in pr-β.

* Tests (server/tests/integration/test_reader_profile.py + adjusted
  test_health / test_migrate_script / test_readyz_migration_state for
  the new ai_005 + progress_002 heads).

* ✨ feat(android): abandoned_at progress field, MIGRATION_7_8, profile fetch + abandon DAO

Lands the Android half of pr-α (Bundle 3 / Reader Profile substrate):

* Room
  - DB version 7 -> 8 + MIGRATION_7_8 adds nullable `abandonedAt INTEGER`
    column to `progress`.
  - `ProgressEntity.abandonedAt: Long?` (Long? maps to nullable INTEGER).
  - `ProgressDao.markAbandoned` / `unmarkAbandoned` bump BOTH `updatedAt`
    AND `localUpdatedAt` to `now`. This is load-bearing: Android pushes
    `progress.updatedAt` as `client_updated_at`, and the server's LWW
    guard only accepts the row when `client_updated_at` is strictly
    newer. Setting only `localUpdatedAt` would make the row "dirty"
    locally but the server would silently reject the abandon.
  - `markAbandoned` clears `finishedAt`; `unmarkAbandoned` leaves
    `percent` untouched. (Abandoning at 60% remembers 60%.)

* Core / sync DTO
  - `Progress.abandonedAt` on the domain model so the push path can
    read it through ProgressRepository.dirty().
  - `ProgressItemDto.abandonedAt` (snake_case wire name).
  - `SyncOrchestrator`: push serializes `abandonedAt` as ISO-8601;
    pull defensively drops `abandonedAt` if `finishedAt` is also set
    (finished wins) — mirrors the server's defensive read.

* AI
  - `AiClient.fetchProfile()` -> `GET /ai/v1/profile`. Maps 404 to null.
  - `AiRepository.fetchProfile()`, `markAbandoned()`, `unmarkAbandoned()`.
    Constructor gains an optional `progressDao` so existing test sites
    that only stub `insightDao` keep compiling; AppContainer wires the
    real DAO.
  - DTOs for `ReaderProfileResponse`, `ReaderProfilePayload`,
    `ReaderStats` (including `booksWithThemesCount` per Lock #15 / CC-8),
    `BookRec` (full coordinator §3.4 shape), `AuthorCount`.
  - `source_type`, `owned_state`, `confidence` declared as `String`
    (not enums) so a future server bump doesn't crash existing clients.

* Tests
  - MigrationTest: `migrate 7 to 8 adds abandonedAt column`,
    `migrate 6 to 8 chains via book_insights and abandonedAt` (the
    Lock #8 chained-migration test).
  - ProgressDaoTest: mark/unmark abandon semantics + LWW timestamp
    correctness (BOTH updatedAt AND localUpdatedAt bumped) + dirty()
    pickup.
  - ProgressDtosTest: round-trip + null-omission for `abandonedAt`.
  - SyncOrchestratorTest: defensive-pull rule when server emits both
    terminal flags.
  - AiRepositoryProfileTest: fetchProfile 404/200, mark/unmark
    delegation through FakeProgressDao.

* ✨ feat(server): refresh_profile orchestrator + POST /ai/v1/profile/refresh (pr-β)

Stitches the AI building blocks into a per-user reader-profile orchestrator
gated on AI_ENABLED + PROGRESS_ENABLED + opt-in:

- ai_006 migration: ai_generation_log.book_insight_id becomes nullable,
  new `kind` discriminator with CHECK admitting 'insight'/'profile'/'promote'
  (Lock #11 amendment), partial index for profile-row lookups, and
  ai_usage_daily.profile_count for the 3/day cap.
- refresh_profile(): per-user singleflight lock, low-data short-circuit
  with weight=0 (runs even at quota cap), daily-cap enforcement, global
  semaphore around the LLM call, deterministic candidate maps for in-library
  + OpenLibrary-discovery recommendations, owner-exclusion belt-and-suspenders,
  16-hex-char input_fingerprint, kind='profile' audit rows per waiter
  (including collapsed singleflight waiters).
- PR-ζ wire-up (Lock #11 amendment): promote handler now writes a
  kind='promote' DB row alongside the retained stdout audit line.
- Retriever.author_bibliography(): 30d positive cache, 24h negative cache,
  429 with Retry-After capped at 6h, 5xx → stale-if-error via new
  allow_stale=True read path.
- _compute_reader_stats now populates books_with_themes_count (Lock #15).
- ConfigResponse gains progress_supported so AI-only deploys can suppress
  the Android reader-profile UI (coordinator §3.5).
- READER_PROFILE_PROMPT + READER_PROFILE_PROMPT_VERSION='1' bumped
  independently of the per-book PROMPT_VERSION.
- Tests: existing audit-log / migration / promote / health tests updated
  for the ai_006 schema (kind required, head moves ai_005 → ai_006).
  Integration test_reader_profile passes all 11 cases.
- docs/sync-api.md: full POST /profile/refresh section + progress_supported
  on GET /ai/v1/config.

426 tests pass.

* ✨ feat(android): Library Insights screen + refreshProfile/deleteProfile (pr-γ)

PR-γ of Bundle 3 (Reader Profile) — adds the user-visible Reader Profile
surface on Android. New "Insights" entry on the Library overflow menu
opens LibraryInsightsScreen, which renders five sealed UI states
(Disabled / Empty / Loading / Loaded / Error) over GET /ai/v1/profile and
POST /ai/v1/profile/refresh.

Highlights:

* AiClient gains refreshProfile() (200 → ReaderProfileResponseDto, 409
  ai_not_opted_in, 429 quota, 5xx model failure) and deleteProfile()
  (idempotent 204 — wired by PR-δ from Settings).
* AiRepository.refreshProfile drains the sync orchestrator + library
  uploader best-effort before calling the server (Lock #6 NTH #9;
  corrections.md PR-γ Required #6). Preflight failures log via the
  PreflightOutcome callback and the refresh proceeds regardless.
* Decoupling via two fun interfaces (ProfilePreflightSync /
  ProfilePreflightLibrary) so :data:ai never depends on :data:sync or
  :data:library.
* AiConfig.progressSupported (Lock #5) consumed to drive
  Disabled.ProgressUnsupported; the server already advertises the field.
* Local input-fingerprint approximation per Lock #12 (mirrors PR-β's
  server recipe; uses ReaderStats.booksWithThemesCount per Lock #15 / CC-8,
  NOT topThemes.size). Mismatch renders a low-emphasis "Profile may be
  out of date" hint (architect Finding #2).
* In-library recommendations resolve via DocumentRepository.findByIdentity
  → /reader/{id}; discovery recommendations expose an explicit "Open in
  browser" button gated on https:// (architect Finding #4), wrapped in
  runCatching for ActivityNotFoundException.
* Coverage meter uses booksWithThemesCount/totalBooks (NTH #12, Lock #15).
* AppNavGraph wires composable("library/insights"); AppContainer adds
  LibraryInsightsViewModelFactory + a StateFlow<Boolean> over
  AiPreferences.aiEnabled.

Tests cover the disabled/empty/loaded/error state transitions, the local
fingerprint recipe (match + mismatch + null cases), and best-effort
preflight semantics (sync→library→refresh order, network still fires when
preflight throws). All :app:testDebugUnitTest and :data:ai:testDebugUnitTest
pass.

* ✨ feat(pr-δ): DELETE /ai/v1/profile + abandoned UX + delete-profile button

Server:
- DELETE /ai/v1/profile (idempotent, 204) gated on ai_enabled + opt-in
- 5 integration tests covering happy path, idempotency, tenant isolation,
  not-opted-in (409), and ai_disabled (503)

Android:
- LibraryPreferences struct (sort + showAbandoned), persisted via the
  same SharedPreferences file (Lock #17); migration is read-only — old
  key 'library_sort' keeps working.
- Library home gains a 'Show abandoned' toggle; abandoned books are
  hidden by default.
- Settings: 'Delete reader profile' button calls AiRepository.deleteProfile()
  with snackbar feedback (success / failure) and a re-entrancy guard.
- ProgressRepository: expose abandonedAt for the library filter.

* ✨ fix(server): POST /profile/refresh returns the ReaderProfileResponse envelope

`POST /ai/v1/profile/refresh` used to return the raw `ReaderProfilePayload`
while the Android client decodes the wrapped `ReaderProfileResponseDto`
envelope — every real refresh would have failed client-side
deserialization. The bug was masked in `AiRepositoryRefreshTest` because
the fixture body already used the envelope shape.

Refresh now returns the same envelope as `GET /profile` so the server
is internally consistent and the Android client decodes one DTO for
both endpoints. Added an integration test that exercises the low-data
short-circuit path (no LLM call required) and asserts the envelope
top-level fields are present.

* ✨ fix: canonicalize reader profile fingerprint as epoch millis

Server hashed `datetime.isoformat()` (UTC → `+00:00`) while Android
hashed `Instant.toString()` (UTC → `Z`). The two representations
diverge for the same instant, so the fingerprint compare reported
spurious staleness on every refresh and the "Profile may be out of
date" banner would have been permanently on.

Both ends now serialize `latest_progress_updated_at` as epoch
milliseconds (or the literal `"none"` when absent) before hashing.
Docstrings on both sides call out the contract so a future change on
either side cannot drift again. Existing VM fingerprint test updated
to the canonical token and a second test covers the no-progress path.

* ✨ feat(android): surface ai_suggested_recommendations in Library Insights

The server `ReaderProfilePayload` carries an `ai_suggested_recommendations`
list alongside the in-library and discovery buckets, but the Android
DTO didn't declare the field and the screen never rendered the bucket —
so AI-only suggestions (no candidate match, no Open Library URL) were
silently dropped from the UI.

Added the field to `ReaderProfilePayloadDto` (defaulted, so older
server builds round-trip cleanly) and added a "Suggested by AI"
LazyRow section between Discovery and the footer. `BookRecCard`
already handles `source_type == "ai_suggested"` by showing
title/author/rationale with no link affordance, so no card-level
changes were needed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant