diff --git a/CHANGELOG.md b/CHANGELOG.md index cebadb8c..1c02d583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Ask this book — on-demand indexing, Phase 2 (user-uploaded books) — backend (2026-06-19) + +Backend for "Ask this book" over **user-uploaded books** (`UserBook`/`UserChapter`), mirroring the P1 catalog path with **per-user isolation as a hard requirement** (a user must never retrieve another user's chunks). New **isolated** `user_chapter_chunk` table (NOT polymorphic with `chapter_chunk`) carries a **denormalized `user_id`** alongside `user_book_id`; retrieval filters on **both** in SQL (defense in depth), in both the vector and lexical branches. `UserBook` gains the same `rag_status/rag_chunk_count/rag_embedded_count/rag_indexed_at/rag_error` fields as `Edition`. `BookChunkingService.ChunkUserBookAsync` chunks `UserChapter.PlainText` into the new table (stamping the owner id from the book); the existing `ChapterEmbeddingWorker` now runs a **second batch poll** over `user_chapter_chunk` on the **same single OpenAI drain** (no second worker) and flips `user_books.rag_status → Ready` when `embedded == chunk`. `IRagService.RetrieveUserBookAsync` reuses the identical RRF hybrid (vector NN + lexical FTS) via a shared private helper, so fusion/vector-format/timeout stay byte-identical to the catalog path. Owner-scoped endpoints: `POST/GET /me/books/{id}/index` (atomic claim `WHERE id=@id AND user_id=@uid AND rag_status IN (0,3)`, clears stale chunks before re-chunk, rate-limited `rag.index`) and `POST /me/books/{id}/ask` (**no spoiler gate** — full-book retrieval over the user's own document, no private-notes corpus; reuses `RagAskService.AskFromChunksAsync`; 404 if not the owner's book). `GET /me/books/{id}` detail DTO gains `ragStatus`/counts. P1 catalog path unchanged. Migration `AddUserBookRagIndex` (creates `user_chapter_chunk` + HNSW/GIN/`(user_id, user_book_id)` indexes + generated `search_vector` + cascade FKs from both `user_books` and `user_chapters`, plus the `user_books.rag_*` columns; Up/Down verified against pgvector). 852 unit tests green (incl. `ChunkUserBookAsync` row shape + a SQL-level isolation guard asserting both `user_id`/`user_book_id` filters in both retrievers) + integration tests (unauth→401, non-owner→404, owner→202/200/answer). Frontend = parallel agent. + +### Ask this book — on-demand indexing, Phase 2 (user-uploaded books) — web (2026-06-19) + +P1 shipped on-demand "Ask this book" for catalog editions; P2 unhides it for **user-uploaded books** in the web reader — the priority case (users upload books to read *and* ask about them). The Ask toolbar button + panel now render for `mode==='userbook'`, routing index status / prepare / ask through the owner-scoped `/me/books/{id}/{index,ask}` endpoints (no spoiler gate — it's the user's own document, so answers draw from the whole book). The P1 catalog path is unchanged. The reader builds a single `AskTarget` (`{ kind: 'edition'|'userbook', id, ragStatus, ragChunkCount, ragEmbeddedCount }`) from whichever book it loaded and threads it through `AskPanel` → `useAsk` / `useRagIndex`; the hooks select the endpoint family by `kind`, so the index state-machine (Prepare → Preparing N/M → Ready → Ask) and styling are reused verbatim. The user-book detail (`GET /me/books/{id}`) gains `ragStatus`/counts, surfaced via `NormalizedBook` to seed the panel with no extra fetch. (StudyBuddy stays catalog-only.) tsc + web build clean; 559 web tests green (extended `useRagIndex`/`useAsk`/`AskPanel` for the userbook target hitting `/me/books/{id}/...`). Backend = parallel agent. (Phase 3 = observability.) + ### Ask this book — on-demand indexing, Phase 1 (catalog) (2026-06-19) "Ask this book" returned a misleading "you haven't read enough" for **every** catalog book — because none were RAG-indexed (the 614 editions were imported before RAG; `chapter_chunk` was empty). Books are now indexed **on demand, per book**: a reader clicks **"Prepare this book for questions"** → `POST /books/{editionId}/index` **atomically claims** the edition (`UPDATE … WHERE rag_status IN (NotIndexed, Failed)` — DB-level dedup, concurrent triggers index once, a Ready book is a no-op so OpenAI is never re-billed), chunks it (`BookChunkingService`, extracted from ingestion + reused), and the existing `ChapterEmbeddingWorker` fills embeddings and flips `rag_status → Ready` once `embedded == chunk`. `GET /books/{editionId}/index` polls progress; the reader shows Prepare → "Preparing… N/M" → Ask. **No bulk indexing** — only books someone actually asks, one-time embed per book, rate-limited (`rag.index`, 20/hr/IP). The misleading message now only appears for a genuinely-indexed book hitting the spoiler gate; un-indexed books show the Prepare CTA. New `editions.rag_status/rag_chunk_count/rag_embedded_count/rag_indexed_at/rag_error` (migration `AddEditionRagIndexState`, with a backfill that marks already-chunked editions Ready at $0). architect → backend + frontend → adversarial QA (P1 double-chunk/re-embed-on-legacy + unmount-poll fixed). 845 unit + 555 web tests green. (Phase 2 = user-uploaded books; Phase 3 = observability.) diff --git a/apps/web/src/api/ask.ts b/apps/web/src/api/ask.ts index 3e6cb09b..34b021ae 100644 --- a/apps/web/src/api/ask.ts +++ b/apps/web/src/api/ask.ts @@ -1,10 +1,23 @@ import { authFetch } from './client' import type { AskResponse } from '@textstack/shared' -import type { RagIndexState } from '../types/api' +import type { RagIndexState, RagIndexStatus } from '../types/api' export type { AskResponse, AskCitation } from '@textstack/shared' export type { RagIndexState, RagIndexStatus } from '../types/api' +/** + * Identifies what the "Ask this book" panel is pointed at (AI-027 P2). A catalog `edition` + * routes to `/books/{id}/...`; a user-uploaded `userbook` routes to `/me/books/{id}/...`. + * The reader builds this from whichever book it loaded and threads it through the panel/hooks. + */ +export interface AskTarget { + kind: 'edition' | 'userbook' + id: string + ragStatus?: RagIndexStatus + ragChunkCount?: number + ragEmbeddedCount?: number +} + /** * On-demand RAG index (AI-027 P1). Reads the current index state for a catalog edition. * Cookie auth via {@link authFetch}; throws `ApiError` on failure. @@ -39,3 +52,37 @@ export function ask( signal, }) } + +/** + * User-uploaded book variant of {@link getIndexStatus} (AI-027 P2). Owner-scoped via cookie auth. + */ +export function getUserIndexStatus(id: string, signal?: AbortSignal): Promise { + return authFetch(`/me/books/${id}/index`, { method: 'GET', signal }) +} + +/** + * User-uploaded book variant of {@link prepareIndex} (AI-027 P2). Owner-scoped. + */ +export function prepareUserIndex(id: string, signal?: AbortSignal): Promise { + return authFetch(`/me/books/${id}/index`, { method: 'POST', signal }) +} + +/** + * User-uploaded book variant of {@link ask} (AI-027 P2). No spoiler gate — it's the user's own + * document, so answers draw from the whole book; `currentChapterId` is still passed for citation + * context. Owner-scoped via cookie auth; throws `ApiError` on failure. + */ +export function askUserBook( + id: string, + question: string, + k?: number, + signal?: AbortSignal, + currentChapterId?: string, +): Promise { + return authFetch(`/me/books/${id}/ask`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question, k, ...(currentChapterId ? { currentChapterId } : {}) }), + signal, + }) +} diff --git a/apps/web/src/api/userBooks.ts b/apps/web/src/api/userBooks.ts index 49a137c9..ec326b40 100644 --- a/apps/web/src/api/userBooks.ts +++ b/apps/web/src/api/userBooks.ts @@ -1,5 +1,6 @@ import { authFetch, API_BASE } from './client' import { trackBookUploaded } from '../lib/analytics' +import type { RagIndexStatus } from '../types/api' export interface UserBook { id: string @@ -61,6 +62,10 @@ export interface UserBookDetail { createdAt: string updatedAt: string completedAt: string | null + // On-demand RAG index for "Ask this book" (AI-027 P2). Absent on older payloads → NotIndexed. + ragStatus?: RagIndexStatus + ragChunkCount?: number + ragEmbeddedCount?: number } export interface UserChapter { diff --git a/apps/web/src/components/reader/AskPanel.tsx b/apps/web/src/components/reader/AskPanel.tsx index 779fdf92..b8f95acd 100644 --- a/apps/web/src/components/reader/AskPanel.tsx +++ b/apps/web/src/components/reader/AskPanel.tsx @@ -3,19 +3,19 @@ import { useFocusTrap } from '../../hooks/useFocusTrap' import { useTranslation } from '../../hooks/useTranslation' import { useAsk } from '../../hooks/useAsk' import { useRagIndex } from '../../hooks/useRagIndex' -import type { AskCitation } from '../../api/ask' -import type { RagIndexStatus } from '../../types/api' +import type { AskCitation, AskTarget } from '../../api/ask' interface Props { open: boolean - editionId: string + /** + * What this panel asks against (AI-027 P2). Carries the kind (catalog edition vs user upload), + * the id, and the seeded RAG index state/counts so the panel renders correctly with no extra + * fetch and routes status/prepare/ask through the right endpoints. + */ + askTarget: AskTarget /** GUID of the chapter the user is actively reading — gates the RAG spoiler check. */ currentChapterId?: string isAuthenticated: boolean - /** Seed RAG index state from `publicBook` so the panel renders correctly with no extra fetch. */ - initialRagStatus?: RagIndexStatus - initialChunkCount?: number - initialEmbeddedCount?: number onSignIn: () => void onNavigateToCitation: (citation: AskCitation) => void onClose: () => void @@ -23,25 +23,17 @@ interface Props { export function AskPanel({ open, - editionId, + askTarget, currentChapterId, isAuthenticated, - initialRagStatus, - initialChunkCount, - initialEmbeddedCount, onSignIn, onNavigateToCitation, onClose, }: Props) { const { t } = useTranslation() const containerRef = useFocusTrap(open) - const { history, isLoading, error, ask } = useAsk(editionId, currentChapterId) - const { status, chunkCount, embeddedCount, preparing, prepare } = useRagIndex( - editionId, - initialRagStatus, - initialChunkCount, - initialEmbeddedCount, - ) + const { history, isLoading, error, ask } = useAsk(askTarget, currentChapterId) + const { status, chunkCount, embeddedCount, preparing, prepare } = useRagIndex(askTarget) const [input, setInput] = useState('') const historyRef = useRef(null) diff --git a/apps/web/src/components/reader/__tests__/AskPanel.test.tsx b/apps/web/src/components/reader/__tests__/AskPanel.test.tsx index d738a79e..25959232 100644 --- a/apps/web/src/components/reader/__tests__/AskPanel.test.tsx +++ b/apps/web/src/components/reader/__tests__/AskPanel.test.tsx @@ -6,21 +6,30 @@ vi.mock('../../../hooks/useTranslation', () => ({ })) vi.mock('../../../hooks/useFocusTrap', () => ({ useFocusTrap: () => ({ current: null }) })) -const { askState } = vi.hoisted(() => ({ +const { askState, useAskSpy } = vi.hoisted(() => ({ askState: { history: [] as unknown[], isLoading: false, error: null as string | null, ask: vi.fn() }, + useAskSpy: vi.fn(), +})) +vi.mock('../../../hooks/useAsk', () => ({ + useAsk: (...args: unknown[]) => { useAskSpy(...args); return askState }, })) -vi.mock('../../../hooks/useAsk', () => ({ useAsk: () => askState })) -const { ragState } = vi.hoisted(() => ({ +const { ragState, useRagIndexSpy } = vi.hoisted(() => ({ ragState: { status: 'Ready', chunkCount: 0, embeddedCount: 0, preparing: false, prepare: vi.fn() }, + useRagIndexSpy: vi.fn(), +})) +vi.mock('../../../hooks/useRagIndex', () => ({ + useRagIndex: (...args: unknown[]) => { useRagIndexSpy(...args); return ragState }, })) -vi.mock('../../../hooks/useRagIndex', () => ({ useRagIndex: () => ragState })) import { AskPanel } from '../AskPanel' +import type { AskTarget } from '../../../api/ask' + +const editionTarget: AskTarget = { kind: 'edition', id: 'ed-1' } const baseProps = { open: true, - editionId: 'ed-1', + askTarget: editionTarget, onSignIn: vi.fn(), onNavigateToCitation: vi.fn(), onClose: vi.fn(), @@ -33,6 +42,8 @@ afterEach(() => { ragState.chunkCount = 0 ragState.embeddedCount = 0 ragState.prepare = vi.fn() + useAskSpy.mockReset() + useRagIndexSpy.mockReset() }) describe('AskPanel', () => { @@ -76,6 +87,13 @@ describe('AskPanel', () => { expect(prepare).toHaveBeenCalled() }) + it('threads a userbook askTarget through to both hooks (AI-027 P2)', () => { + const userTarget: AskTarget = { kind: 'userbook', id: 'ub-1', ragStatus: 'NotIndexed' } + render() + expect(useRagIndexSpy).toHaveBeenCalledWith(userTarget) + expect(useAskSpy).toHaveBeenCalledWith(userTarget, undefined) + }) + it('renders a citation chip and navigates on click', () => { const citation = { marker: 1, chunkId: 'c1', chapterId: 'ch1', chapterOrd: 4, charStart: 0, charEnd: 1, preview: 'snippet' } askState.history = [{ question: 'q', answer: 'a [1]', citations: [citation], insufficient: false }] diff --git a/apps/web/src/hooks/useAsk.test.ts b/apps/web/src/hooks/useAsk.test.ts index fa749cf7..75927c04 100644 --- a/apps/web/src/hooks/useAsk.test.ts +++ b/apps/web/src/hooks/useAsk.test.ts @@ -1,22 +1,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' -vi.mock('../api/ask', () => ({ ask: vi.fn() })) -import { ask as askApi } from '../api/ask' +vi.mock('../api/ask', () => ({ ask: vi.fn(), askUserBook: vi.fn() })) +import { ask as askApi, askUserBook as askUserBookApi, type AskTarget } from '../api/ask' import { useAsk } from './useAsk' const mockAsk = askApi as unknown as ReturnType +const mockAskUserBook = askUserBookApi as unknown as ReturnType + +const edition: AskTarget = { kind: 'edition', id: 'ed-1' } +const userbook: AskTarget = { kind: 'userbook', id: 'ub-1' } const citation = { marker: 1, chunkId: 'c1', chapterId: 'ch1', chapterOrd: 2, charStart: 0, charEnd: 5, preview: 'preview', } describe('useAsk', () => { - beforeEach(() => mockAsk.mockReset()) + beforeEach(() => { + mockAsk.mockReset() + mockAskUserBook.mockReset() + }) it('appends a turn on success', async () => { mockAsk.mockResolvedValueOnce({ answer: 'Because [1].', citations: [citation], lastReadOrd: 3, insufficient: false }) - const { result } = renderHook(() => useAsk('ed-1')) + const { result } = renderHook(() => useAsk(edition)) await act(() => result.current.ask('why?')) @@ -28,7 +35,7 @@ describe('useAsk', () => { it('sets error and keeps history empty on failure', async () => { mockAsk.mockRejectedValueOnce(new Error('boom')) - const { result } = renderHook(() => useAsk('ed-1')) + const { result } = renderHook(() => useAsk(edition)) await act(() => result.current.ask('why?')) @@ -38,7 +45,7 @@ describe('useAsk', () => { it('flags insufficient turns', async () => { mockAsk.mockResolvedValueOnce({ answer: 'Read more first.', citations: [], lastReadOrd: 0, insufficient: true }) - const { result } = renderHook(() => useAsk('ed-1')) + const { result } = renderHook(() => useAsk(edition)) await act(() => result.current.ask('why?')) @@ -48,7 +55,7 @@ describe('useAsk', () => { it('forwards currentChapterId to the api when provided', async () => { mockAsk.mockResolvedValueOnce({ answer: 'ok', citations: [], lastReadOrd: 0, insufficient: false }) - const { result } = renderHook(() => useAsk('ed-1', 'chap-guid-4')) + const { result } = renderHook(() => useAsk(edition, 'chap-guid-4')) await act(() => result.current.ask('why?')) @@ -56,9 +63,21 @@ describe('useAsk', () => { expect(mockAsk).toHaveBeenCalledWith('ed-1', 'why?', undefined, expect.anything(), 'chap-guid-4') }) - it('no-ops without an editionId', async () => { + it('routes a userbook target to askUserBook (not the catalog ask)', async () => { + mockAskUserBook.mockResolvedValueOnce({ answer: 'ok', citations: [], lastReadOrd: 0, insufficient: false }) + const { result } = renderHook(() => useAsk(userbook, 'chap-guid-9')) + + await act(() => result.current.ask('why?')) + + await waitFor(() => expect(mockAskUserBook).toHaveBeenCalled()) + expect(mockAskUserBook).toHaveBeenCalledWith('ub-1', 'why?', undefined, expect.anything(), 'chap-guid-9') + expect(mockAsk).not.toHaveBeenCalled() + }) + + it('no-ops without a target', async () => { const { result } = renderHook(() => useAsk(undefined)) await act(() => result.current.ask('why?')) expect(mockAsk).not.toHaveBeenCalled() + expect(mockAskUserBook).not.toHaveBeenCalled() }) }) diff --git a/apps/web/src/hooks/useAsk.ts b/apps/web/src/hooks/useAsk.ts index ae90113c..9c38e2b2 100644 --- a/apps/web/src/hooks/useAsk.ts +++ b/apps/web/src/hooks/useAsk.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useRef, useEffect } from 'react' -import { ask as askApi, type AskCitation } from '../api/ask' +import { ask as askApi, askUserBook as askUserBookApi, type AskCitation, type AskTarget } from '../api/ask' import { ApiError } from '../api/client' export interface AskTurn { @@ -12,8 +12,13 @@ export interface AskTurn { /** * Session "Ask this book" state (AI-026a): an in-memory Q&A history (not persisted), plus loading * and error. `ask` appends a turn; in-flight requests are aborted on a new question / unmount. + * + * `target.kind` (AI-027 P2) routes the POST — catalog `/books/{id}/ask` vs user-upload + * `/me/books/{id}/ask`. */ -export function useAsk(editionId: string | undefined, currentChapterId?: string) { +export function useAsk(target: AskTarget | undefined, currentChapterId?: string) { + const id = target?.id + const kind = target?.kind const [history, setHistory] = useState([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) @@ -24,7 +29,7 @@ export function useAsk(editionId: string | undefined, currentChapterId?: string) const ask = useCallback( async (question: string) => { const q = question.trim() - if (!q || !editionId || isLoading) return + if (!q || !id || isLoading) return abortRef.current?.abort() const ctrl = new AbortController() @@ -33,7 +38,8 @@ export function useAsk(editionId: string | undefined, currentChapterId?: string) setError(null) try { - const res = await askApi(editionId, q, undefined, ctrl.signal, currentChapterId) + const fn = kind === 'userbook' ? askUserBookApi : askApi + const res = await fn(id, q, undefined, ctrl.signal, currentChapterId) setHistory(prev => [ ...prev, { question: q, answer: res.answer, citations: res.citations, insufficient: res.insufficient }, @@ -46,7 +52,7 @@ export function useAsk(editionId: string | undefined, currentChapterId?: string) if (abortRef.current === ctrl) setIsLoading(false) } }, - [editionId, isLoading, currentChapterId], + [id, kind, isLoading, currentChapterId], ) return { history, isLoading, error, ask } diff --git a/apps/web/src/hooks/useRagIndex.test.ts b/apps/web/src/hooks/useRagIndex.test.ts index 55e02891..21895e7a 100644 --- a/apps/web/src/hooks/useRagIndex.test.ts +++ b/apps/web/src/hooks/useRagIndex.test.ts @@ -1,17 +1,35 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' -vi.mock('../api/ask', () => ({ getIndexStatus: vi.fn(), prepareIndex: vi.fn() })) -import { getIndexStatus, prepareIndex } from '../api/ask' +vi.mock('../api/ask', () => ({ + getIndexStatus: vi.fn(), + prepareIndex: vi.fn(), + getUserIndexStatus: vi.fn(), + prepareUserIndex: vi.fn(), +})) +import { + getIndexStatus, + prepareIndex, + getUserIndexStatus, + prepareUserIndex, + type AskTarget, +} from '../api/ask' import { useRagIndex } from './useRagIndex' const mockGet = getIndexStatus as unknown as ReturnType const mockPrepare = prepareIndex as unknown as ReturnType +const mockGetUser = getUserIndexStatus as unknown as ReturnType +const mockPrepareUser = prepareUserIndex as unknown as ReturnType + +const edition = (over: Partial = {}): AskTarget => ({ kind: 'edition', id: 'ed-1', ...over }) +const userbook = (over: Partial = {}): AskTarget => ({ kind: 'userbook', id: 'ub-1', ...over }) describe('useRagIndex', () => { beforeEach(() => { mockGet.mockReset() mockPrepare.mockReset() + mockGetUser.mockReset() + mockPrepareUser.mockReset() vi.useFakeTimers() }) afterEach(() => { @@ -20,14 +38,14 @@ describe('useRagIndex', () => { }) it('seeds from initial status without fetching', () => { - const { result } = renderHook(() => useRagIndex('ed-1', 'Ready', 10, 10)) + const { result } = renderHook(() => useRagIndex(edition({ ragStatus: 'Ready', ragChunkCount: 10, ragEmbeddedCount: 10 }))) expect(result.current.status).toBe('Ready') expect(result.current.chunkCount).toBe(10) expect(mockGet).not.toHaveBeenCalled() }) it('defaults to NotIndexed when unseeded', () => { - const { result } = renderHook(() => useRagIndex('ed-1')) + const { result } = renderHook(() => useRagIndex(edition())) expect(result.current.status).toBe('NotIndexed') }) @@ -37,7 +55,7 @@ describe('useRagIndex', () => { .mockResolvedValueOnce({ status: 'Indexing', chunkCount: 4, embeddedCount: 2 }) .mockResolvedValueOnce({ status: 'Ready', chunkCount: 4, embeddedCount: 4 }) - const { result } = renderHook(() => useRagIndex('ed-1', 'NotIndexed')) + const { result } = renderHook(() => useRagIndex(edition({ ragStatus: 'NotIndexed' }))) await act(async () => { result.current.prepare() @@ -69,7 +87,7 @@ describe('useRagIndex', () => { it('prepare returning Ready immediately does not poll', async () => { mockPrepare.mockResolvedValueOnce({ status: 'Ready', chunkCount: 8, embeddedCount: 8 }) - const { result } = renderHook(() => useRagIndex('ed-1', 'NotIndexed')) + const { result } = renderHook(() => useRagIndex(edition({ ragStatus: 'NotIndexed' }))) await act(async () => { result.current.prepare() @@ -83,7 +101,7 @@ describe('useRagIndex', () => { it('sets Failed when prepare throws', async () => { mockPrepare.mockRejectedValueOnce(new Error('boom')) - const { result } = renderHook(() => useRagIndex('ed-1', 'NotIndexed')) + const { result } = renderHook(() => useRagIndex(edition({ ragStatus: 'NotIndexed' }))) await act(async () => { await result.current.prepare() @@ -94,7 +112,7 @@ describe('useRagIndex', () => { it('polls when seeded already Indexing', async () => { mockGet.mockResolvedValueOnce({ status: 'Ready', chunkCount: 3, embeddedCount: 3 }) - const { result } = renderHook(() => useRagIndex('ed-1', 'Indexing', 3, 1)) + const { result } = renderHook(() => useRagIndex(edition({ ragStatus: 'Indexing', ragChunkCount: 3, ragEmbeddedCount: 1 }))) await act(async () => { await vi.advanceTimersByTimeAsync(3000) @@ -104,7 +122,7 @@ describe('useRagIndex', () => { it('stops polling after unmount (no further getIndexStatus calls)', async () => { mockGet.mockResolvedValue({ status: 'Indexing', chunkCount: 4, embeddedCount: 1 }) - const { unmount } = renderHook(() => useRagIndex('ed-1', 'Indexing', 4, 1)) + const { unmount } = renderHook(() => useRagIndex(edition({ ragStatus: 'Indexing', ragChunkCount: 4, ragEmbeddedCount: 1 }))) await act(async () => { await vi.advanceTimersByTimeAsync(3000) @@ -123,7 +141,7 @@ describe('useRagIndex', () => { mockGet.mockImplementationOnce( () => new Promise(res => { resolvePoll = res }), ) - const { unmount } = renderHook(() => useRagIndex('ed-1', 'Indexing', 4, 1)) + const { unmount } = renderHook(() => useRagIndex(edition({ ragStatus: 'Indexing', ragChunkCount: 4, ragEmbeddedCount: 1 }))) await act(async () => { await vi.advanceTimersByTimeAsync(3000) // fires poll → pending promise @@ -136,4 +154,39 @@ describe('useRagIndex', () => { // No assertion error / unhandled rejection = pass; the guard skips setState post-unmount. expect(true).toBe(true) }) + + // --- AI-027 P2: user-uploaded books route to the /me/books/{id}/... endpoints. --- + + it('userbook target hits the /me/books prepare + poll endpoints (not the catalog ones)', async () => { + mockPrepareUser.mockResolvedValueOnce({ status: 'Indexing', chunkCount: 5, embeddedCount: 0 }) + mockGetUser.mockResolvedValueOnce({ status: 'Ready', chunkCount: 5, embeddedCount: 5 }) + + const { result } = renderHook(() => useRagIndex(userbook({ ragStatus: 'NotIndexed' }))) + + await act(async () => { + result.current.prepare() + }) + expect(mockPrepareUser).toHaveBeenCalledWith('ub-1', expect.anything()) + expect(mockPrepare).not.toHaveBeenCalled() + expect(result.current.status).toBe('Indexing') + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + expect(mockGetUser).toHaveBeenCalledWith('ub-1') + expect(mockGet).not.toHaveBeenCalled() + expect(result.current.status).toBe('Ready') + }) + + it('userbook seeded already Indexing polls the user endpoint', async () => { + mockGetUser.mockResolvedValueOnce({ status: 'Ready', chunkCount: 2, embeddedCount: 2 }) + const { result } = renderHook(() => useRagIndex(userbook({ ragStatus: 'Indexing', ragChunkCount: 2, ragEmbeddedCount: 1 }))) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + expect(mockGetUser).toHaveBeenCalledWith('ub-1') + expect(mockGet).not.toHaveBeenCalled() + expect(result.current.status).toBe('Ready') + }) }) diff --git a/apps/web/src/hooks/useRagIndex.ts b/apps/web/src/hooks/useRagIndex.ts index b90b2d03..8cfda8c5 100644 --- a/apps/web/src/hooks/useRagIndex.ts +++ b/apps/web/src/hooks/useRagIndex.ts @@ -1,6 +1,12 @@ import { useState, useCallback, useRef, useEffect } from 'react' -import { getIndexStatus, prepareIndex } from '../api/ask' -import type { RagIndexStatus } from '../types/api' +import { + getIndexStatus, + prepareIndex, + getUserIndexStatus, + prepareUserIndex, + type AskTarget, +} from '../api/ask' +import type { RagIndexState, RagIndexStatus } from '../types/api' const POLL_INTERVAL_MS = 3000 @@ -14,23 +20,37 @@ export interface RagIndexHook { prepare: () => void } +/** Picks the status/prepare endpoints for the target kind (catalog vs user-uploaded). */ +function endpointsFor(target: AskTarget | undefined): { + getStatus: (id: string, signal?: AbortSignal) => Promise + postPrepare: (id: string, signal?: AbortSignal) => Promise +} { + if (target?.kind === 'userbook') { + return { getStatus: getUserIndexStatus, postPrepare: prepareUserIndex } + } + return { getStatus: getIndexStatus, postPrepare: prepareIndex } +} + /** - * On-demand per-book RAG index state (AI-027 P1). Seeds from the catalog book's `ragStatus` / - * counts so the panel renders correctly on load with no extra fetch. `prepare()` POSTs to start - * indexing, then polls `getIndexStatus` every ~3s while `status === 'Indexing'`, stopping at + * On-demand per-book RAG index state (AI-027). Seeds from the book's `ragStatus` / counts (threaded + * via {@link AskTarget}) so the panel renders correctly on load with no extra fetch. `prepare()` + * POSTs to start indexing, then polls every ~3s while `status === 'Indexing'`, stopping at * Ready/Failed. The poll interval is cleaned up on unmount and whenever indexing finishes. + * + * The `target.kind` (`edition` | `userbook`) selects the endpoint family — `/books/{id}/...` for + * catalog editions (P1), `/me/books/{id}/...` for user uploads (P2). */ -export function useRagIndex( - editionId: string | undefined, - initialStatus?: RagIndexStatus, - initialChunkCount?: number, - initialEmbeddedCount?: number, -): RagIndexHook { - const [status, setStatus] = useState(initialStatus ?? 'NotIndexed') - const [chunkCount, setChunkCount] = useState(initialChunkCount ?? 0) - const [embeddedCount, setEmbeddedCount] = useState(initialEmbeddedCount ?? 0) +export function useRagIndex(target: AskTarget | undefined): RagIndexHook { + const id = target?.id + const [status, setStatus] = useState(target?.ragStatus ?? 'NotIndexed') + const [chunkCount, setChunkCount] = useState(target?.ragChunkCount ?? 0) + const [embeddedCount, setEmbeddedCount] = useState(target?.ragEmbeddedCount ?? 0) const [preparing, setPreparing] = useState(false) + // Keep the endpoint pair in a ref so the poll/prepare callbacks don't churn on every render. + const endpointsRef = useRef(endpointsFor(target)) + endpointsRef.current = endpointsFor(target) + const timerRef = useRef | null>(null) const abortRef = useRef(null) const mountedRef = useRef(true) @@ -53,9 +73,9 @@ export function useRagIndex( }, [stopPolling]) const poll = useCallback(async () => { - if (!editionId) return + if (!id) return try { - const res = await getIndexStatus(editionId) + const res = await endpointsRef.current.getStatus(id) // The interval is cleared on unmount, but an already-in-flight poll can still resolve after // unmount — guard the setState to avoid an update on an unmounted component. if (!mountedRef.current) return @@ -66,7 +86,7 @@ export function useRagIndex( } catch { // Transient poll failure — keep polling; a persistent failure surfaces via the next tick. } - }, [editionId, stopPolling]) + }, [id, stopPolling]) const startPolling = useCallback(() => { stopPolling() @@ -74,13 +94,13 @@ export function useRagIndex( }, [poll, stopPolling]) const prepare = useCallback(async () => { - if (!editionId || preparing) return + if (!id || preparing) return abortRef.current?.abort() const ctrl = new AbortController() abortRef.current = ctrl setPreparing(true) try { - const res = await prepareIndex(editionId, ctrl.signal) + const res = await endpointsRef.current.postPrepare(id, ctrl.signal) if (!mountedRef.current) return setStatus(res.status) setChunkCount(res.chunkCount) @@ -91,7 +111,7 @@ export function useRagIndex( } finally { if (mountedRef.current && abortRef.current === ctrl) setPreparing(false) } - }, [editionId, preparing, startPolling]) + }, [id, preparing, startPolling]) // If we mount already mid-index (seeded), pick up polling. useEffect(() => { diff --git a/apps/web/src/hooks/useReaderChapter.ts b/apps/web/src/hooks/useReaderChapter.ts index 180c0dc8..416eabcf 100644 --- a/apps/web/src/hooks/useReaderChapter.ts +++ b/apps/web/src/hooks/useReaderChapter.ts @@ -4,7 +4,7 @@ import { useNetworkRecovery } from './useNetworkRecovery' import { getUserBook, getUserBookChapter } from '../api/userBooks' import { getCachedChapter, cacheChapter } from '../lib/offlineDb' import { InvalidContentTypeError } from '../lib/fetchWithRetry' -import type { Chapter, BookDetail } from '../types/api' +import type { Chapter, BookDetail, RagIndexStatus } from '../types/api' import type { TocChapter } from '../components/reader/ReaderTocDrawer' export type ReaderMode = 'public' | 'userbook' @@ -25,6 +25,10 @@ export interface NormalizedBook { title: string totalWordCount?: number | null chapters: TocChapter[] + // On-demand RAG index for "Ask this book" (AI-027 P2 — user uploads). Seeds the Ask panel. + ragStatus?: RagIndexStatus + ragChunkCount?: number + ragEmbeddedCount?: number } interface Params { @@ -198,6 +202,9 @@ export function useReaderChapter({ id: bk.id, title: bk.title, totalWordCount: bk.totalWordCount, + ragStatus: bk.ragStatus, + ragChunkCount: bk.ragChunkCount, + ragEmbeddedCount: bk.ragEmbeddedCount, chapters: bk.chapters.map(c => ({ id: c.id, identifier: c.slug || String(c.chapterNumber), diff --git a/apps/web/src/pages/ReaderPage.tsx b/apps/web/src/pages/ReaderPage.tsx index 041a0101..1cef8be8 100644 --- a/apps/web/src/pages/ReaderPage.tsx +++ b/apps/web/src/pages/ReaderPage.tsx @@ -21,7 +21,7 @@ import { ReaderFooterNav } from '../components/reader/ReaderFooterNav' import { ReaderSettingsDrawer } from '../components/reader/ReaderSettingsDrawer' import { AskPanel } from '../components/reader/AskPanel' import { StudyBuddyPanel } from '../components/reader/StudyBuddyPanel' -import type { AskCitation } from '../api/ask' +import type { AskCitation, AskTarget } from '../api/ask' import { scrollToCitation } from '../lib/citationScroll' import { ReaderTocDrawer } from '../components/reader/ReaderTocDrawer' import { ReaderSearchDrawer } from '../components/reader/ReaderSearchDrawer' @@ -397,8 +397,30 @@ export function ReaderPage({ mode = 'public' }: ReaderPageProps) { return `/${language}/library/my/${id}/read/${identifier}` }, [mode, bookSlug, id, language, getLocalizedPath]) - // RAG "Ask this book" — catalog editions only (user uploads aren't chunked). - const askEditionId = mode === 'public' ? publicBook?.id : undefined + // RAG "Ask this book" target (AI-027). P1: catalog editions. P2: user uploads — on-demand + // indexing via the owner-scoped `/me/books/{id}/...` endpoints, no spoiler gate. The target + // carries the kind + seeded index state/counts so the panel routes to the right endpoints. + const askTarget: AskTarget | undefined = + mode === 'public' && publicBook + ? { + kind: 'edition', + id: publicBook.id, + ragStatus: publicBook.ragStatus, + ragChunkCount: publicBook.ragChunkCount, + ragEmbeddedCount: publicBook.ragEmbeddedCount, + } + : mode === 'userbook' && book + ? { + kind: 'userbook', + id: book.id, + ragStatus: book.ragStatus, + ragChunkCount: book.ragChunkCount, + ragEmbeddedCount: book.ragEmbeddedCount, + } + : undefined + + // StudyBuddy stays catalog-only (P1 behavior preserved); it has its own panel + endpoints. + const studyBuddyEditionId = mode === 'public' ? publicBook?.id : undefined const pendingCitationRef = useRef(null) const handleNavigateToCitation = useCallback((c: AskCitation) => { const target = chapterList?.find(ch => ch.chapterNumber === c.chapterOrd) @@ -516,7 +538,7 @@ export function ReaderPage({ mode = 'public' }: ReaderPageProps) { sourceUrl={mode === 'userbook' ? clipSourceUrl : null} sourceDomain={mode === 'userbook' ? sourceDomain(clipSourceUrl) : null} useLocalizedLink={mode === 'public'} - showAsk={!!askEditionId} + showAsk={!!askTarget} onAskClick={() => setAskOpen(true)} onSearchClick={() => setSearchOpen(true)} onTocClick={() => setTocOpen(true)} @@ -543,7 +565,7 @@ export function ReaderPage({ mode = 'public' }: ReaderPageProps) { ttsSpeed={settings.ttsSpeed} showInlineTranslations={settings.showInlineTranslations} scrollToHighlightId={scrollToHighlightId} - onStudyBuddy={askEditionId ? setStudyBuddyPassage : undefined} + onStudyBuddy={studyBuddyEditionId ? setStudyBuddyPassage : undefined} >
setSettingsOpen(false)} /> - {askEditionId && ( + {askTarget && ( setAskOpen(false)} /> )} - {askEditionId && studyBuddyPassage && ( + {studyBuddyEditionId && studyBuddyPassage && ( Task> RetrieveAsync( Guid editionId, string query, int k, int? maxChapterOrd, CancellationToken ct); + + /// + /// Phase 2: hybrid retrieval over a USER-uploaded book's chunks (isolated user_chapter_chunk + /// table). Filters on BOTH AND in SQL — a + /// user must never retrieve another user's chunks (per-user isolation, defense in depth). The + /// returned ChapterId is the user chapter id (for reader deep-links). Same RRF fusion as + /// ; user books have no spoiler gate so callers pass + /// = null for full-book retrieval. + /// + Task> RetrieveUserBookAsync( + Guid userId, Guid userBookId, string query, int k, int? maxChapterOrd, CancellationToken ct); } diff --git a/backend/src/Ai/TextStack.Ai.Rag/RagService.cs b/backend/src/Ai/TextStack.Ai.Rag/RagService.cs index 9766dca5..ab740922 100644 --- a/backend/src/Ai/TextStack.Ai.Rag/RagService.cs +++ b/backend/src/Ai/TextStack.Ai.Rag/RagService.cs @@ -36,27 +36,12 @@ public async Task> RetrieveAsync( { if (string.IsNullOrWhiteSpace(query)) return []; - // No gate-value short-circuit: `null` means "no gate" (ungated full-book retrieval — the tool - // path for anonymous users), and a non-null ordinal — INCLUDING 0 — is a real spoiler ceiling - // that flows to the SQL gate (`chapter_ord <= @maxChapterOrd`, correct for 0 since catalog - // chapters are 0-based). The previous `<= 0` short-circuit wrongly treated ord-0 (a read first - // chapter) as "nothing read". The authenticated RAG path null-guards "no progress" upstream in - // RagContextService, so retrieve is never asked to surface a whole book for a non-reader there. - if (k <= 0) - k = IRagService.DefaultK; - - var queryVector = await _embedder.EmbedAsync(query, ct); - var vectorLiteral = FormatVector(queryVector); - var pool = Math.Max(k, MinCandidatePool); - // Two retrievers in one round-trip, each spoiler-gated in its own WHERE (AI-024 — hard SQL - // filter, never a prompt instruction; null @maxChapterOrd = no gate): + // Two retrievers in one round-trip over chapter_chunk, each spoiler-gated in its own WHERE + // (AI-024 — hard SQL filter, never a prompt instruction; null @maxChapterOrd = no gate): // 1. Semantic — cosine NN over the embedding (skips not-yet-embedded chunks). - // 2. Lexical — ts_rank_cd over the generated search_vector. websearch_to_tsquery parses the - // raw question; a stopword-only query yields an empty tsquery → zero lexical rows → the - // fusion degrades to vector-only. Lexical does NOT require an embedding, so it still - // retrieves before the batch embedder has filled vectors in. - const string sql = """ + // 2. Lexical — ts_rank_cd over the generated search_vector (no embedding required). + const string vectorSql = """ SELECT id AS ChunkId, chapter_id AS ChapterId, chapter_ord AS ChapterOrd, @@ -71,7 +56,9 @@ AND embedding IS NOT NULL AND (@maxChapterOrd::int IS NULL OR chapter_ord <= @maxChapterOrd::int) ORDER BY embedding <=> CAST(@q AS vector) LIMIT @pool; + """; + const string lexicalSql = """ SELECT id AS ChunkId, chapter_id AS ChapterId, chapter_ord AS ChapterOrd, @@ -88,11 +75,49 @@ ORDER BY Score DESC LIMIT @pool; """; + return await RetrieveHybridAsync( + query, k, + vectorSql + "\n\n" + lexicalSql, + withVector => new { q = withVector.Vector, query, editionId, pool = withVector.Pool, maxChapterOrd }, + ct); + } + + public async Task> RetrieveUserBookAsync( + Guid userId, Guid userBookId, string query, int k, int? maxChapterOrd, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(query)) + return []; + + return await RetrieveHybridAsync( + query, k, + BuildUserBookSql(), + withVector => new { q = withVector.Vector, query, userId, userBookId, pool = withVector.Pool, maxChapterOrd }, + ct); + } + + /// + /// Shared hybrid retrieval: embeds the query, runs the two given retrievers (vector NN + lexical + /// FTS, in one round-trip), fuses them with RRF, and materializes the top-k. The two callers only + /// differ in table + WHERE filter (catalog edition vs isolated user book), supplied via + /// and — so RRF, the pgvector literal format, + /// pool sizing, and the timeout stay identical between paths. + /// + private async Task> RetrieveHybridAsync( + string query, int k, string sql, + Func<(string Vector, int Pool), object> parameters, CancellationToken ct) + { + if (k <= 0) + k = IRagService.DefaultK; + + var queryVector = await _embedder.EmbedAsync(query, ct); + var vectorLiteral = FormatVector(queryVector); + var pool = Math.Max(k, MinCandidatePool); + using var connection = _connectionFactory(); using var multi = await connection.QueryMultipleAsync( new CommandDefinition( sql, - new { q = vectorLiteral, query, editionId, pool, maxChapterOrd }, + parameters((vectorLiteral, pool)), cancellationToken: ct, commandTimeout: QueryTimeoutSeconds)); @@ -115,7 +140,7 @@ ORDER BY Score DESC .Select(f => { var r = byId[f.Item]; - // Score is now the RRF fusion score (not cosine) — see RetrievedChunk.Score. + // Score is the RRF fusion score (not cosine) — see RetrievedChunk.Score. return new RetrievedChunk( r.ChunkId, r.ChapterId, r.ChapterOrd, r.Ord, r.Text, r.CharStart, r.CharEnd, f.Score); }) @@ -136,6 +161,55 @@ private sealed class Row public double Score { get; init; } } + /// + /// The two-statement hybrid SQL for USER-book retrieval over the isolated user_chapter_chunk + /// table. Both retrievers (vector NN + lexical FTS) filter on BOTH user_id AND + /// user_book_id — a user must NEVER retrieve another user's chunks (per-user isolation, + /// defense in depth; user_id is the denormalized owner). user_chapter_id is surfaced as + /// ChapterId for reader deep-links. public static so the isolation invariant (both filters + /// present) is unit-testable without a live pgvector connection. + /// + public static string BuildUserBookSql() + { + const string vectorSql = """ + SELECT id AS ChunkId, + user_chapter_id AS ChapterId, + chapter_ord AS ChapterOrd, + ord AS Ord, + text AS Text, + char_start AS CharStart, + char_end AS CharEnd, + 1 - (embedding <=> CAST(@q AS vector)) AS Score + FROM user_chapter_chunk + WHERE user_id = @userId + AND user_book_id = @userBookId + AND embedding IS NOT NULL + AND (@maxChapterOrd::int IS NULL OR chapter_ord <= @maxChapterOrd::int) + ORDER BY embedding <=> CAST(@q AS vector) + LIMIT @pool; + """; + + const string lexicalSql = """ + SELECT id AS ChunkId, + user_chapter_id AS ChapterId, + chapter_ord AS ChapterOrd, + ord AS Ord, + text AS Text, + char_start AS CharStart, + char_end AS CharEnd, + ts_rank_cd(search_vector, tsq) AS Score + FROM user_chapter_chunk, websearch_to_tsquery('english', @query) AS tsq + WHERE user_id = @userId + AND user_book_id = @userBookId + AND (@maxChapterOrd::int IS NULL OR chapter_ord <= @maxChapterOrd::int) + AND search_vector @@ tsq + ORDER BY Score DESC + LIMIT @pool; + """; + + return vectorSql + "\n\n" + lexicalSql; + } + /// /// Formats an embedding as a pgvector text literal [v0,v1,…] using invariant culture /// (so a comma-decimal locale can't corrupt the literal). Cast to vector in SQL. diff --git a/backend/src/Api/Endpoints/UserBookAskEndpoints.cs b/backend/src/Api/Endpoints/UserBookAskEndpoints.cs new file mode 100644 index 00000000..1c5c479c --- /dev/null +++ b/backend/src/Api/Endpoints/UserBookAskEndpoints.cs @@ -0,0 +1,88 @@ +using Api.Extensions; +using Application.Auth; +using Application.Rag; +using Contracts.Books; +using Microsoft.Extensions.DependencyInjection; +using TextStack.Ai.Rag; + +namespace Api.Endpoints; + +/// +/// "Ask this book" over a USER-uploaded book (Phase 2). Owner-scoped: the authenticated user asks a +/// question over their OWN uploaded document → per-user isolated retrieval +/// () → grounded answer with citations +/// (, shared with the catalog path). No spoiler gate — +/// it's the user's own book — so the full book is in scope. Returns the same +/// contract as the catalog endpoint. CurrentChapterId on the request +/// is accepted but ignored (no gate). 404 when the book isn't this user's. +/// +public static class UserBookAskEndpoints +{ + private const int PreviewChars = 200; + + public static void MapUserBookAskEndpoints(this WebApplication app) + { + app.MapPost("/me/books/{id:guid}/ask", Ask) + .WithTags("User Books RAG") + .RequireRateLimiting("rag.ask"); + } + + private static async Task Ask( + Guid id, + AskRequest request, + HttpContext httpContext, + AuthService authService, + IServiceProvider services, + CancellationToken ct) + { + var userId = httpContext.GetUserId(authService); + if (userId == null) return Results.Unauthorized(); + + if (string.IsNullOrWhiteSpace(request.Question)) + return Results.BadRequest(new { error = "Question is required." }); + + // Both services pull in the embedder, which throws without an OpenAI key — surface a clean 503. + UserBookRagContextService context; + RagAskService ask; + try + { + context = services.GetRequiredService(); + ask = services.GetRequiredService(); + } + catch (InvalidOperationException) + { + return Results.Problem("Ask is not configured (no OpenAI key).", statusCode: 503); + } + + try + { + var k = request.K is > 0 ? request.K.Value : IRagService.DefaultK; + + // Ownership-scoped context build. Null => not this user's book (or taken down) → 404. + var ctx = await context.BuildAsync(userId.Value, id, request.Question, k, ct); + if (ctx is null) return Results.NotFound("Book not found"); + + // Full-book retrieval (no gate), no private-notes corpus. lastReadOrd is 0 (unused for + // user books). AskFromChunksAsync handles the empty-chunks case (book not indexed yet) by + // returning an "insufficient" answer without an LLM call. + var answer = await ask.AskFromChunksAsync(request.Question, ctx.Chunks, [], lastReadOrd: 0, ct); + + var citations = answer.Citations.Select(c => new AskCitation( + c.Marker, c.Chunk.ChunkId, c.Chunk.ChapterId, c.Chunk.ChapterOrd, + c.Chunk.CharStart, c.Chunk.CharEnd, Preview(c.Chunk.Text))).ToList(); + + return Results.Ok(new AskResponse(answer.Answer, citations, answer.LastReadOrd, answer.Insufficient)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return Results.StatusCode(StatusCodes.Status504GatewayTimeout); + } + catch (Exception) + { + return Results.Problem("Ask is temporarily unavailable.", statusCode: 503); + } + } + + private static string Preview(string text) => + text.Length <= PreviewChars ? text : text[..PreviewChars] + "…"; +} diff --git a/backend/src/Api/Endpoints/UserBookIndexEndpoints.cs b/backend/src/Api/Endpoints/UserBookIndexEndpoints.cs new file mode 100644 index 00000000..5c05951b --- /dev/null +++ b/backend/src/Api/Endpoints/UserBookIndexEndpoints.cs @@ -0,0 +1,132 @@ +using Api.Extensions; +using Application.Auth; +using Application.Rag; +using Contracts.Books; +using Domain.Enums; +using Infrastructure.Persistence; +using Infrastructure.Rag; +using Microsoft.EntityFrameworkCore; + +namespace Api.Endpoints; + +/// +/// On-demand per-book RAG indexing for USER-uploaded books (Phase 2 "Ask this book"). Mirrors the +/// catalog but owner-scoped to the authenticated user: +/// +/// POST /me/books/{id}/index — atomically claim (NotIndexed/Failed → Indexing) the +/// user's own book, clear stale chunks, chunk it, return 202; idempotent no-op (200) if already +/// Indexing/Ready. 404 if the book isn't this user's. +/// GET /me/books/{id}/index — current { status, chunkCount, embeddedCount } for polling. +/// +/// The shared embedding worker fills the vectors and flips Indexing → Ready. +/// +public static class UserBookIndexEndpoints +{ + public static void MapUserBookIndexEndpoints(this WebApplication app) + { + var group = app.MapGroup("/me/books").WithTags("User Books RAG"); + + group.MapPost("/{id:guid}/index", TriggerIndex) + .RequireRateLimiting("rag.index"); + + group.MapGet("/{id:guid}/index", GetIndexStatus); + } + + private static async Task TriggerIndex( + Guid id, + HttpContext httpContext, + AuthService authService, + AppDbContext db, + BookChunkingService chunking, + CancellationToken ct) + { + var userId = httpContext.GetUserId(authService); + if (userId == null) return Results.Unauthorized(); + + // Validate the book exists AND belongs to this user (404 otherwise — never leak existence of + // another user's book). Read the prior status to drive the (pure, tested) claim decision. + var prior = await db.UserBooks + .Where(b => b.Id == id && b.UserId == userId.Value && b.TakedownAt == null) + .Select(b => (RagIndexStatus?)b.RagStatus) + .FirstOrDefaultAsync(ct); + if (prior is null) return Results.NotFound("Book not found"); + + // Atomic claim / dedup (mirrors RagIndexLogic.CanClaim + the catalog SQL). The user_id guard is + // redundant with the lookup above but keeps the claim itself owner-scoped. + var claimed = await db.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE user_books + SET rag_status = 1, rag_error = NULL + WHERE id = {id} AND user_id = {userId.Value} AND rag_status IN (0, 3); + """, ct); + + if (claimed == 0) + { + var current = await ReadStatusAsync(db, id, ct); + return current is null ? Results.NotFound("Book not found") : Results.Ok(current); + } + + // Clear any pre-existing chunks before re-chunking (Failed re-claims or a prior partial run), + // so chunking never doubles up rows. Harmless no-op on a fresh book. + if (RagIndexLogic.ShouldClearChunksBeforeChunking(prior.Value)) + { + await db.Database.ExecuteSqlInterpolatedAsync( + // Scope to the owner too (defense in depth) — every user_chapter_chunk + // statement filters both, even though id is owner-validated above. + $"DELETE FROM user_chapter_chunk WHERE user_book_id = {id} AND user_id = {userId.Value};", ct); + } + + var chunkCount = await chunking.ChunkUserBookAsync(db, id, ct); + + var book = await db.UserBooks.FirstAsync(b => b.Id == id, ct); + if (chunkCount == 0) + { + book.RagStatus = RagIndexStatus.Failed; + book.RagChunkCount = 0; + book.RagEmbeddedCount = 0; + book.RagError = "No chapters to index"; + book.RagIndexedAt = null; + await db.SaveChangesAsync(ct); + return Results.Ok(new BookIndexStatusDto("Failed", 0, 0)); + } + + book.RagStatus = RagIndexStatus.Indexing; + book.RagChunkCount = chunkCount; + book.RagEmbeddedCount = 0; + book.RagError = null; + book.RagIndexedAt = null; + await db.SaveChangesAsync(ct); + + return Results.Json(new BookIndexStatusDto("Indexing", chunkCount, 0), statusCode: StatusCodes.Status202Accepted); + } + + private static async Task GetIndexStatus( + Guid id, + HttpContext httpContext, + AuthService authService, + AppDbContext db, + CancellationToken ct) + { + var userId = httpContext.GetUserId(authService); + if (userId == null) return Results.Unauthorized(); + + var exists = await db.UserBooks + .AnyAsync(b => b.Id == id && b.UserId == userId.Value && b.TakedownAt == null, ct); + if (!exists) return Results.NotFound("Book not found"); + + var status = await ReadStatusAsync(db, id, ct); + return status is null ? Results.NotFound("Book not found") : Results.Ok(status); + } + + private static async Task ReadStatusAsync( + AppDbContext db, Guid id, CancellationToken ct) + { + var row = await db.UserBooks + .Where(b => b.Id == id) + .Select(b => new { b.RagStatus, b.RagChunkCount, b.RagEmbeddedCount }) + .FirstOrDefaultAsync(ct); + + return row is null + ? null + : new BookIndexStatusDto(row.RagStatus.ToString(), row.RagChunkCount, row.RagEmbeddedCount); + } +} diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index 4c6ca6db..da46e74e 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -152,6 +152,8 @@ // "Ask this book" orchestration (AI-025): context + LLM gateway → grounded answer with citations. builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); +// Phase 2 "Ask this book" for user uploads: per-user isolated retrieval context (no spoiler gate). +builder.Services.AddScoped(); // Similar books (AI-055): cosine NN over editions.embedding. Same raw Func // factory as RAG — a raw connection casts the vector server-side (no pgvector type registration). @@ -655,6 +657,8 @@ app.MapAdminRagEndpoints(); app.MapAskEndpoints(); app.MapBookIndexEndpoints(); +app.MapUserBookAskEndpoints(); +app.MapUserBookIndexEndpoints(); app.MapStudyBuddyEndpoints(); app.MapVocabularyEndpoints(); app.MapTtsEndpoints(); diff --git a/backend/src/Application/Rag/UserBookRagContextService.cs b/backend/src/Application/Rag/UserBookRagContextService.cs new file mode 100644 index 00000000..1049f543 --- /dev/null +++ b/backend/src/Application/Rag/UserBookRagContextService.cs @@ -0,0 +1,45 @@ +using Application.Common.Interfaces; +using Microsoft.EntityFrameworkCore; +using TextStack.Ai.Rag; + +namespace Application.Rag; + +/// +/// Builds RAG context for "Ask this book" over a USER-uploaded book (Phase 2 — sibling to +/// for the catalog). Differences from the catalog path: +/// +/// Ownership-scoped: the book must belong to userId; otherwise null (the +/// endpoint maps that to 404). +/// No spoiler gate: it's the user's own document, so the full book is retrievable +/// (maxChapterOrd: null) regardless of reading progress. +/// No private-notes corpus: user books have no Notes/Highlights edition corpus, so +/// context is chunks only. +/// Per-user isolation: retrieval filters on both user_id and +/// user_book_id in SQL (). +/// +/// +public sealed class UserBookRagContextService(IAppDbContext db, IRagService rag) +{ + /// + /// Resolves the user book (must be owned by and not taken down) and + /// retrieves the top chunks for over the whole book. + /// Returns null when the book doesn't exist / isn't this user's — the caller returns 404. An + /// owned-but-unindexed book yields an empty chunk list (→ "not enough indexed yet" answer). + /// + public async Task BuildAsync( + Guid userId, Guid userBookId, string query, int k, CancellationToken ct) + { + var owns = await db.UserBooks + .AnyAsync(b => b.Id == userBookId && b.UserId == userId && b.TakedownAt == null, ct); + if (!owns) + return null; + + // Full-book retrieval (no gate). The SQL itself also filters user_id + user_book_id, so even a + // mismatched id can't surface another user's chunks. + var chunks = await rag.RetrieveUserBookAsync(userId, userBookId, query, k, maxChapterOrd: null, ct); + return new UserBookRagContext(chunks); + } +} + +/// Retrieval context for one user + their uploaded book: chunks only (no private corpus). +public record UserBookRagContext(IReadOnlyList Chunks); diff --git a/backend/src/Application/UserBooks/MetadataService.cs b/backend/src/Application/UserBooks/MetadataService.cs index 0dbcf4fd..33c205c3 100644 --- a/backend/src/Application/UserBooks/MetadataService.cs +++ b/backend/src/Application/UserBooks/MetadataService.cs @@ -48,7 +48,8 @@ public class MetadataService(IAppDbContext db) book.Chapters.OrderBy(c => c.ChapterNumber).Select(c => new UserChapterSummaryDto(c.Id, c.ChapterNumber, c.Slug, c.Title, c.WordCount)).ToList(), null, - book.CreatedAt, book.UpdatedAt, book.CompletedAt); + book.CreatedAt, book.UpdatedAt, book.CompletedAt, + book.RagStatus.ToString(), book.RagChunkCount, book.RagEmbeddedCount); return (dto, null); } diff --git a/backend/src/Application/UserBooks/UserBookService.cs b/backend/src/Application/UserBooks/UserBookService.cs index 94d40313..57766818 100644 --- a/backend/src/Application/UserBooks/UserBookService.cs +++ b/backend/src/Application/UserBooks/UserBookService.cs @@ -266,6 +266,9 @@ public async Task> GetBooksAsync( b.CreatedAt, b.UpdatedAt, b.CompletedAt, + b.RagStatus, + b.RagChunkCount, + b.RagEmbeddedCount, Chapters = b.Chapters .OrderBy(c => c.ChapterNumber) .Select(c => new UserChapterSummaryDto(c.Id, c.ChapterNumber, c.Slug, c.Title, c.WordCount)) @@ -306,7 +309,10 @@ public async Task> GetBooksAsync( toc, book.CreatedAt, book.UpdatedAt, - book.CompletedAt + book.CompletedAt, + book.RagStatus.ToString(), + book.RagChunkCount, + book.RagEmbeddedCount ); } diff --git a/backend/src/Contracts/UserBooks/UserBookDtos.cs b/backend/src/Contracts/UserBooks/UserBookDtos.cs index 5a973202..4ae4a80d 100644 --- a/backend/src/Contracts/UserBooks/UserBookDtos.cs +++ b/backend/src/Contracts/UserBooks/UserBookDtos.cs @@ -45,7 +45,11 @@ public record UserBookDetailDto( IReadOnlyList? Toc, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, - DateTimeOffset? CompletedAt + DateTimeOffset? CompletedAt, + // Phase 2 on-demand RAG index state (mirrors the catalog BookDetailDto). + string RagStatus, + int RagChunkCount, + int RagEmbeddedCount ); public record UserChapterSummaryDto( diff --git a/backend/src/Domain/Entities/UserBook.cs b/backend/src/Domain/Entities/UserBook.cs index 0841c51c..5b74b26c 100644 --- a/backend/src/Domain/Entities/UserBook.cs +++ b/backend/src/Domain/Entities/UserBook.cs @@ -51,6 +51,14 @@ public class UserBook public string[] SuggestedTags { get; set; } = []; public DateTimeOffset? SuggestedTagsAt { get; set; } + // On-demand RAG index state (Phase 2 "Ask this book" for user uploads). Mirrors Edition's fields. + // A user trigger claims (NotIndexed/Failed → Indexing) → chunks → the embedding worker flips Ready. + public RagIndexStatus RagStatus { get; set; } = RagIndexStatus.NotIndexed; + public int RagChunkCount { get; set; } + public int RagEmbeddedCount { get; set; } + public DateTimeOffset? RagIndexedAt { get; set; } + public string? RagError { get; set; } + public User User { get; set; } = null!; public ICollection Chapters { get; set; } = []; public ICollection BookFiles { get; set; } = []; diff --git a/backend/src/Domain/Entities/UserChapterChunk.cs b/backend/src/Domain/Entities/UserChapterChunk.cs new file mode 100644 index 00000000..72e8e5ae --- /dev/null +++ b/backend/src/Domain/Entities/UserChapterChunk.cs @@ -0,0 +1,60 @@ +namespace Domain.Entities; + +/// +/// A retrieval unit for Phase 2 on-demand RAG over a USER-uploaded book ("Ask this book" for +/// ). Mirrors but lives in its OWN table +/// (user_chapter_chunk) — never polymorphic with the catalog chunks — and carries a +/// denormalized so retrieval can hard-filter WHERE user_id = @userId as +/// defense-in-depth on top of . A user must never retrieve another user's +/// chunks. Produced by BookChunkingService.ChunkUserBookAsync, embedded by the shared +/// embedding worker, queried by spoiler-free per-user retrieval. EF mapping in AppDbContext.Rag.cs. +/// +public class UserChapterChunk +{ + public Guid Id { get; set; } + + /// Owning user book. + public Guid UserBookId { get; set; } + + /// Source chapter within the user book. + public Guid UserChapterId { get; set; } + + /// + /// Denormalized owner id (copied from the book's ). Lets retrieval + /// filter WHERE user_id = @userId AND user_book_id = @userBookId in SQL — a per-user + /// isolation boundary that holds even if a caller passes a foreign book id. + /// + public Guid UserId { get; set; } + + /// Chunk order within its chapter (0-based). + public int Ord { get; set; } + + /// + /// Denormalized chapter order, copied from the chapter number. User books have no spoiler gate + /// (it's the user's own document), but keeping the column mirrors and + /// supports an optional ceiling. + /// + public int ChapterOrd { get; set; } + + /// The chunk's plain text (the unit that gets embedded). + public string Text { get; set; } = string.Empty; + + /// + /// 1536-d embedding (OpenAI text-embedding-3-small). float[] to keep Domain framework-free; + /// mapped to pgvector vector(1536). Nullable: the chunker inserts text, the worker fills this. + /// + public float[]? Embedding { get; set; } + + public int TokenCount { get; set; } + + /// Start offset of this chunk in the chapter's plain_text (inclusive). + public int CharStart { get; set; } + + /// End offset of this chunk in the chapter's plain_text (exclusive). + public int CharEnd { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public UserBook? UserBook { get; set; } + public UserChapter? UserChapter { get; set; } +} diff --git a/backend/src/Infrastructure/Migrations/20260619053612_AddUserBookRagIndex.Designer.cs b/backend/src/Infrastructure/Migrations/20260619053612_AddUserBookRagIndex.Designer.cs new file mode 100644 index 00000000..396bd8ce --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260619053612_AddUserBookRagIndex.Designer.cs @@ -0,0 +1,5256 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; +using Pgvector; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260619053612_AddUserBookRagIndex")] + partial class AddUserBookRagIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.AdminRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdminUserId") + .HasColumnType("uuid") + .HasColumnName("admin_user_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.HasKey("Id") + .HasName("pk_admin_refresh_tokens"); + + b.HasIndex("AdminUserId") + .HasDatabaseName("ix_admin_refresh_tokens_admin_user_id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_admin_refresh_tokens_expires_at"); + + b.HasIndex("Token") + .IsUnique() + .HasDatabaseName("ix_admin_refresh_tokens_token"); + + b.ToTable("admin_refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AdminSettings", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_admin_settings"); + + b.ToTable("admin_settings", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AdminUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_admin_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_admin_users_email"); + + b.ToTable("admin_users", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AgentRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Agent") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("agent"); + + b.Property("CostUsd") + .HasColumnType("numeric(10,6)") + .HasColumnName("cost_usd"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("Goal") + .IsRequired() + .HasColumnType("text") + .HasColumnName("goal"); + + b.Property("Iterations") + .HasColumnType("integer") + .HasColumnName("iterations"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("Output") + .HasColumnType("text") + .HasColumnName("output"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("StepsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("steps_json"); + + b.Property("TokensIn") + .HasColumnType("integer") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("integer") + .HasColumnName("tokens_out"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_agent_run"); + + b.HasIndex("UserId", "CreatedAt") + .HasDatabaseName("ix_agent_run_user_id_created_at") + .HasFilter("user_id IS NOT NULL"); + + b.ToTable("agent_run", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("CanonicalOverride") + .HasColumnType("text") + .HasColumnName("canonical_override"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExternalLinksJson") + .HasColumnType("jsonb") + .HasColumnName("external_links_json"); + + b.Property("Indexable") + .HasColumnType("boolean") + .HasColumnName("indexable"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("PhotoPath") + .HasColumnType("text") + .HasColumnName("photo_path"); + + b.Property("SeoDescription") + .HasColumnType("text") + .HasColumnName("seo_description"); + + b.Property("SeoFaqsJson") + .HasColumnType("text") + .HasColumnName("seo_faqs_json"); + + b.Property("SeoRelevanceText") + .HasColumnType("text") + .HasColumnName("seo_relevance_text"); + + b.Property("SeoSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("seo_source"); + + b.Property("SeoThemesJson") + .HasColumnType("text") + .HasColumnName("seo_themes_json"); + + b.Property("SeoTitle") + .HasColumnType("text") + .HasColumnName("seo_title"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_authors_site_id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique() + .HasDatabaseName("ix_authors_site_id_slug"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AutoPublishJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("GeneratedAuthorSeo") + .HasColumnType("boolean") + .HasColumnName("generated_author_seo"); + + b.Property("GeneratedEditionSeo") + .HasColumnType("boolean") + .HasColumnName("generated_edition_seo"); + + b.Property("LogOutput") + .HasColumnType("text") + .HasColumnName("log_output"); + + b.Property("Priority") + .HasColumnType("boolean") + .HasColumnName("priority"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_auto_publish_jobs"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_auto_publish_jobs_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_auto_publish_jobs_site_id"); + + b.ToTable("auto_publish_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.BookAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("bigint") + .HasColumnName("byte_size"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("content_type"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("OriginalPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_path"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("storage_path"); + + b.HasKey("Id") + .HasName("pk_book_assets"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_book_assets_edition_id"); + + b.HasIndex("EditionId", "OriginalPath") + .IsUnique() + .HasDatabaseName("ix_book_assets_edition_id_original_path"); + + b.ToTable("book_assets", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.BookCollection", b => + { + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("BookId") + .HasColumnType("uuid") + .HasColumnName("book_id"); + + b.Property("BookType") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("book_type"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.HasKey("CollectionId", "BookId", "BookType") + .HasName("pk_book_collections"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_book_collections_book_id"); + + b.ToTable("book_collections", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.BookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("original_file_name"); + + b.Property("Sha256") + .HasColumnType("text") + .HasColumnName("sha256"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("text") + .HasColumnName("storage_path"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at"); + + b.HasKey("Id") + .HasName("pk_book_files"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_book_files_edition_id"); + + b.HasIndex("Sha256") + .HasDatabaseName("ix_book_files_sha256"); + + b.ToTable("book_files", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.BookQualityJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContentChaptersCleaned") + .HasColumnType("integer") + .HasColumnName("content_chapters_cleaned"); + + b.Property("ContentChaptersRejected") + .HasColumnType("integer") + .HasColumnName("content_chapters_rejected"); + + b.Property("ContentChaptersSkipped") + .HasColumnType("integer") + .HasColumnName("content_chapters_skipped"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("IssuesFixed") + .HasColumnType("integer") + .HasColumnName("issues_fixed"); + + b.Property("IssuesFound") + .HasColumnType("integer") + .HasColumnName("issues_found"); + + b.Property("IssuesJson") + .HasColumnType("jsonb") + .HasColumnName("issues_json"); + + b.Property("LogOutput") + .HasColumnType("text") + .HasColumnName("log_output"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.HasKey("Id") + .HasName("pk_book_quality_jobs"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_book_quality_jobs_edition_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_book_quality_jobs_status"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_book_quality_jobs_user_book_id"); + + b.ToTable("book_quality_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Bookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Locator") + .IsRequired() + .HasColumnType("text") + .HasColumnName("locator"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_bookmarks"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_bookmarks_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_bookmarks_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_bookmarks_site_id"); + + b.HasIndex("UserId", "SiteId", "EditionId") + .HasDatabaseName("ix_bookmarks_user_id_site_id_edition_id"); + + b.ToTable("bookmarks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterNumber") + .HasColumnType("integer") + .HasColumnName("chapter_number"); + + b.Property("ContentQualityScore") + .HasColumnType("integer") + .HasColumnName("content_quality_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Html") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html"); + + b.Property("OriginalChapterNumber") + .HasColumnType("integer") + .HasColumnName("original_chapter_number"); + + b.Property("PartNumber") + .HasColumnType("integer") + .HasColumnName("part_number"); + + b.Property("PlainText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("plain_text"); + + b.Property("SearchVector") + .IsRequired() + .HasColumnType("tsvector") + .HasColumnName("search_vector"); + + b.Property("Slug") + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TotalParts") + .HasColumnType("integer") + .HasColumnName("total_parts"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WordCount") + .HasColumnType("integer") + .HasColumnName("word_count"); + + b.HasKey("Id") + .HasName("pk_chapters"); + + b.HasIndex("SearchVector") + .HasDatabaseName("ix_chapters_search_vector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); + + b.HasIndex("EditionId", "ChapterNumber") + .IsUnique() + .HasDatabaseName("ix_chapters_edition_id_chapter_number"); + + b.HasIndex("EditionId", "Slug") + .HasDatabaseName("ix_chapters_edition_id_slug"); + + b.ToTable("chapters", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ChapterChunk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("ChapterOrd") + .HasColumnType("integer") + .HasColumnName("chapter_ord"); + + b.Property("CharEnd") + .HasColumnType("integer") + .HasColumnName("char_end"); + + b.Property("CharStart") + .HasColumnType("integer") + .HasColumnName("char_start"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Embedding") + .HasColumnType("vector(1536)") + .HasColumnName("embedding"); + + b.Property("Ord") + .HasColumnType("integer") + .HasColumnName("ord"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("TokenCount") + .HasColumnType("integer") + .HasColumnName("token_count"); + + b.HasKey("Id") + .HasName("pk_chapter_chunk"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_chapter_chunk_chapter_id"); + + b.HasIndex("Embedding") + .HasDatabaseName("ix_chapter_chunk_embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("EditionId", "ChapterId", "Ord") + .HasDatabaseName("ix_chapter_chunk_edition_id_chapter_id_ord"); + + b.ToTable("chapter_chunk", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Color") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("default") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_collections_user_id"); + + b.HasIndex("UserId", "SortOrder") + .HasDatabaseName("ix_collections_user_id_sort_order"); + + b.ToTable("collections", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.DeviceAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeviceCodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("device_code_hash"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IntervalSeconds") + .HasColumnType("integer") + .HasColumnName("interval_seconds"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("UserCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("user_code"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_device_authorizations"); + + b.HasIndex("DeviceCodeHash") + .IsUnique() + .HasDatabaseName("ix_device_authorizations_device_code_hash"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_device_authorizations_expires_at"); + + b.HasIndex("UserCode") + .HasDatabaseName("ix_device_authorizations_user_code") + .HasFilter("status = 'pending'"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_device_authorizations_user_id"); + + b.ToTable("device_authorizations", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.DriftCentroid", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlertState") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("alert_state"); + + b.Property("AlertedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("alerted_at"); + + b.Property("Centroid") + .HasColumnType("vector(1536)") + .HasColumnName("centroid"); + + b.Property("ConsecutiveBreaches") + .HasColumnType("integer") + .HasColumnName("consecutive_breaches"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Day") + .HasColumnType("date") + .HasColumnName("day"); + + b.Property("DriftScore") + .HasColumnType("numeric(6,4)") + .HasColumnName("drift_score"); + + b.Property("Feature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("feature"); + + b.Property("SampleSize") + .HasColumnType("integer") + .HasColumnName("sample_size"); + + b.HasKey("Id") + .HasName("pk_drift_centroids"); + + b.HasIndex("Feature", "Day") + .IsUnique() + .HasDatabaseName("ix_drift_centroids_feature_day"); + + b.ToTable("drift_centroids", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Edition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CanonicalOverride") + .HasColumnType("text") + .HasColumnName("canonical_override"); + + b.Property("CoverPath") + .HasColumnType("text") + .HasColumnName("cover_path"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Embedding") + .HasColumnType("vector(1536)") + .HasColumnName("embedding"); + + b.Property("Indexable") + .HasColumnType("boolean") + .HasColumnName("indexable"); + + b.Property("IsPublicDomain") + .HasColumnType("boolean") + .HasColumnName("is_public_domain"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("RagChunkCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("rag_chunk_count"); + + b.Property("RagEmbeddedCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("rag_embedded_count"); + + b.Property("RagError") + .HasColumnType("text") + .HasColumnName("rag_error"); + + b.Property("RagIndexedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rag_indexed_at"); + + b.Property("RagStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("rag_status"); + + b.Property("SeoDescription") + .HasColumnType("text") + .HasColumnName("seo_description"); + + b.Property("SeoFaqsJson") + .HasColumnType("text") + .HasColumnName("seo_faqs_json"); + + b.Property("SeoRelevanceText") + .HasColumnType("text") + .HasColumnName("seo_relevance_text"); + + b.Property("SeoSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("seo_source"); + + b.Property("SeoThemesJson") + .HasColumnType("text") + .HasColumnName("seo_themes_json"); + + b.Property("SeoTitle") + .HasColumnType("text") + .HasColumnName("seo_title"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("SourceEditionId") + .HasColumnType("uuid") + .HasColumnName("source_edition_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("TocJson") + .HasColumnType("jsonb") + .HasColumnName("toc_json"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WorkId") + .HasColumnType("uuid") + .HasColumnName("work_id"); + + b.HasKey("Id") + .HasName("pk_editions"); + + b.HasIndex("Embedding") + .HasDatabaseName("ix_editions_embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_editions_site_id"); + + b.HasIndex("SourceEditionId") + .HasDatabaseName("ix_editions_source_edition_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_editions_status"); + + b.HasIndex("WorkId", "Language") + .IsUnique() + .HasDatabaseName("ix_editions_work_id_language"); + + b.HasIndex("SiteId", "Language", "Slug") + .IsUnique() + .HasDatabaseName("ix_editions_site_id_language_slug"); + + b.ToTable("editions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.EditionAuthor", b => + { + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("AuthorId") + .HasColumnType("uuid") + .HasColumnName("author_id"); + + b.Property("Order") + .HasColumnType("integer") + .HasColumnName("order"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("role"); + + b.HasKey("EditionId", "AuthorId") + .HasName("pk_edition_authors"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_edition_authors_author_id"); + + b.ToTable("edition_authors", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.EvalRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BreakdownJson") + .HasColumnType("jsonb") + .HasColumnName("breakdown_json"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Feature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("feature"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("git_sha"); + + b.Property("JudgeModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("judge_model_id"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("N") + .HasColumnType("integer") + .HasColumnName("n"); + + b.Property("RunType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("manual") + .HasColumnName("run_type"); + + b.Property("Score") + .HasColumnType("numeric(6,3)") + .HasColumnName("score"); + + b.HasKey("Id") + .HasName("pk_eval_runs"); + + b.HasIndex("Feature", "CreatedAt") + .HasDatabaseName("ix_eval_runs_feature_created_at"); + + b.ToTable("eval_runs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Indexable") + .HasColumnType("boolean") + .HasColumnName("indexable"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("SeoDescription") + .HasColumnType("text") + .HasColumnName("seo_description"); + + b.Property("SeoSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("seo_source"); + + b.Property("SeoTitle") + .HasColumnType("text") + .HasColumnName("seo_title"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_genres"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_genres_site_id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique() + .HasDatabaseName("ix_genres_site_id_slug"); + + b.ToTable("genres", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Highlight", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AnchorJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("anchor_json"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("color"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_reviewed_at"); + + b.Property("NoteText") + .HasColumnType("text") + .HasColumnName("note_text"); + + b.Property("SelectedText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("selected_text"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserChapterId") + .HasColumnType("uuid") + .HasColumnName("user_chapter_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_highlights"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_highlights_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_highlights_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_highlights_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_highlights_user_book_id"); + + b.HasIndex("UserChapterId") + .HasDatabaseName("ix_highlights_user_chapter_id"); + + b.HasIndex("UserId", "SiteId", "EditionId") + .HasDatabaseName("ix_highlights_user_id_site_id_edition_id") + .HasFilter("edition_id IS NOT NULL"); + + b.HasIndex("UserId", "SiteId", "UserBookId") + .HasDatabaseName("ix_highlights_user_id_site_id_user_book_id") + .HasFilter("user_book_id IS NOT NULL"); + + b.ToTable("highlights", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.IngestionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("integer") + .HasColumnName("attempt_count"); + + b.Property("BookFileId") + .HasColumnType("uuid") + .HasColumnName("book_file_id"); + + b.Property("Confidence") + .HasColumnType("double precision") + .HasColumnName("confidence"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("SourceEditionId") + .HasColumnType("uuid") + .HasColumnName("source_edition_id"); + + b.Property("SourceFormat") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("source_format"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetLanguage") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("target_language"); + + b.Property("TextSource") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("text_source"); + + b.Property("UnitsCount") + .HasColumnType("integer") + .HasColumnName("units_count"); + + b.Property("WarningsJson") + .HasColumnType("jsonb") + .HasColumnName("warnings_json"); + + b.Property("WorkId") + .HasColumnType("uuid") + .HasColumnName("work_id"); + + b.HasKey("Id") + .HasName("pk_ingestion_jobs"); + + b.HasIndex("BookFileId") + .HasDatabaseName("ix_ingestion_jobs_book_file_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_ingestion_jobs_created_at"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_ingestion_jobs_edition_id"); + + b.HasIndex("SourceEditionId") + .HasDatabaseName("ix_ingestion_jobs_source_edition_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_ingestion_jobs_status"); + + b.HasIndex("WorkId") + .HasDatabaseName("ix_ingestion_jobs_work_id"); + + b.ToTable("ingestion_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.LintResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterNumber") + .HasColumnType("integer") + .HasColumnName("chapter_number"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("code"); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("LineNumber") + .HasColumnType("integer") + .HasColumnName("line_number"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("pk_lint_results"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_lint_results_edition_id"); + + b.ToTable("lint_results", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.LlmTrace", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CostUsd") + .HasColumnType("numeric(10,6)") + .HasColumnName("cost_usd"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FeatureTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("feature_tag"); + + b.Property("LatencyMs") + .HasColumnType("integer") + .HasColumnName("latency_ms"); + + b.Property("MessagesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("messages_json"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("prompt_hash"); + + b.Property("ResponseText") + .HasColumnType("text") + .HasColumnName("response_text"); + + b.Property("SystemPrompt") + .HasColumnType("text") + .HasColumnName("system_prompt"); + + b.Property("TokensIn") + .HasColumnType("integer") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("integer") + .HasColumnName("tokens_out"); + + b.Property("ToolCallsJson") + .HasColumnType("jsonb") + .HasColumnName("tool_calls_json"); + + b.Property("TraceParentId") + .HasColumnType("uuid") + .HasColumnName("trace_parent_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_llm_traces"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_llm_traces_user_id") + .HasFilter("user_id IS NOT NULL"); + + b.HasIndex("FeatureTag", "CreatedAt") + .HasDatabaseName("ix_llm_traces_feature_tag_created_at"); + + b.ToTable("llm_traces", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ModelPromotion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("action"); + + b.Property("AdminUserId") + .HasColumnType("uuid") + .HasColumnName("admin_user_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FeatureTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("feature_tag"); + + b.Property("FromModelId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("from_model_id"); + + b.Property("FromModelRegistrationId") + .HasColumnType("uuid") + .HasColumnName("from_model_registration_id"); + + b.Property("FromProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("from_provider_key"); + + b.Property("ToModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("to_model_id"); + + b.Property("ToModelRegistrationId") + .HasColumnType("uuid") + .HasColumnName("to_model_registration_id"); + + b.Property("ToProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("to_provider_key"); + + b.HasKey("Id") + .HasName("pk_model_promotions"); + + b.HasIndex("FeatureTag", "CreatedAt") + .HasDatabaseName("ix_model_promotions_feature_tag_created_at"); + + b.ToTable("model_promotions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ModelRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FeatureTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("feature_tag"); + + b.Property("ModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("model_id"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("provider_key"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_models"); + + b.HasIndex("FeatureTag") + .IsUnique() + .HasDatabaseName("ix_models_feature_tag") + .HasFilter("status = 'Primary'"); + + b.HasIndex("FeatureTag", "Status") + .HasDatabaseName("ix_models_feature_tag_status"); + + b.HasIndex("FeatureTag", "ProviderKey", "ModelId") + .IsUnique() + .HasDatabaseName("ix_models_feature_tag_provider_key_model_id"); + + b.ToTable("models", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Note", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("HighlightId") + .HasColumnType("uuid") + .HasColumnName("highlight_id"); + + b.Property("Locator") + .IsRequired() + .HasColumnType("text") + .HasColumnName("locator"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_notes"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_notes_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_notes_edition_id"); + + b.HasIndex("HighlightId") + .IsUnique() + .HasDatabaseName("ix_notes_highlight_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_notes_site_id"); + + b.HasIndex("UserId", "SiteId", "EditionId") + .HasDatabaseName("ix_notes_user_id_site_id_edition_id"); + + b.ToTable("notes", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash"); + + b.Property("Used") + .HasColumnType("boolean") + .HasColumnName("used"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_password_reset_tokens"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_password_reset_tokens_token_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_password_reset_tokens_user_id"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.PendingVocabularyWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BookTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("book_title"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Definition") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("definition"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("Priority") + .HasColumnType("double precision") + .HasColumnName("priority"); + + b.Property("Sentence") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sentence"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("source"); + + b.Property("Translation") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("translation"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("word"); + + b.Property("ZipfRank") + .HasColumnType("integer") + .HasColumnName("zipf_rank"); + + b.Property("ZipfScore") + .HasColumnType("double precision") + .HasColumnName("zipf_score"); + + b.HasKey("Id") + .HasName("pk_pending_vocabulary_words"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_pending_vocabulary_words_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_pending_vocabulary_words_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_pending_vocabulary_words_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_pending_vocabulary_words_user_book_id"); + + b.HasIndex("UserId", "SiteId", "CreatedAt") + .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_created_at"); + + b.HasIndex("UserId", "SiteId", "Priority") + .IsDescending(false, false, true) + .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_priority"); + + b.HasIndex("UserId", "SiteId", "Word", "Language") + .IsUnique() + .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_word_language"); + + b.ToTable("pending_vocabulary_words", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.PodcastGenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AudioPath") + .HasColumnType("text") + .HasColumnName("audio_path"); + + b.Property("CostUsd") + .HasColumnType("numeric(10,6)") + .HasColumnName("cost_usd"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Lang") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("lang"); + + b.Property("ScriptJson") + .HasColumnType("jsonb") + .HasColumnName("script_json"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_podcast_generation_jobs"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_podcast_generation_jobs_edition_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_podcast_generation_jobs_status"); + + b.ToTable("podcast_generation_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ReadingGoal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("GoalType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("goal_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StreakMinMinutes") + .HasColumnType("integer") + .HasColumnName("streak_min_minutes"); + + b.Property("TargetValue") + .HasColumnType("integer") + .HasColumnName("target_value"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_reading_goals"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_reading_goals_site_id"); + + b.HasIndex("UserId", "SiteId") + .HasDatabaseName("ix_reading_goals_user_id_site_id"); + + b.HasIndex("UserId", "SiteId", "GoalType") + .IsUnique() + .HasDatabaseName("ix_reading_goals_user_id_site_id_goal_type"); + + b.ToTable("reading_goals", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ReadingProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Locator") + .IsRequired() + .HasColumnType("text") + .HasColumnName("locator"); + + b.Property("MaxChapterNumber") + .HasColumnType("integer") + .HasColumnName("max_chapter_number"); + + b.Property("Percent") + .HasColumnType("double precision") + .HasColumnName("percent"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_reading_progresses"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_reading_progresses_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_reading_progresses_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_reading_progresses_site_id"); + + b.HasIndex("UserId", "SiteId", "EditionId") + .IsUnique() + .HasDatabaseName("ix_reading_progresses_user_id_site_id_edition_id"); + + b.ToTable("reading_progresses", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ReadingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("EndPercent") + .HasColumnType("double precision") + .HasColumnName("end_percent"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StartPercent") + .HasColumnType("double precision") + .HasColumnName("start_percent"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WordsRead") + .HasColumnType("integer") + .HasColumnName("words_read"); + + b.HasKey("Id") + .HasName("pk_reading_sessions"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_reading_sessions_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_reading_sessions_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_reading_sessions_user_book_id"); + + b.HasIndex("UserId", "SiteId") + .HasDatabaseName("ix_reading_sessions_user_id_site_id"); + + b.HasIndex("UserId", "StartedAt") + .HasDatabaseName("ix_reading_sessions_user_id_started_at"); + + b.HasIndex("UserId", "EditionId", "StartedAt") + .IsUnique() + .HasDatabaseName("ix_reading_sessions_user_id_edition_id_started_at") + .HasFilter("edition_id IS NOT NULL"); + + b.HasIndex("UserId", "UserBookId", "StartedAt") + .IsUnique() + .HasDatabaseName("ix_reading_sessions_user_id_user_book_id_started_at") + .HasFilter("user_book_id IS NOT NULL"); + + b.ToTable("reading_sessions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SeoBackfillJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AfterSnapshot") + .HasColumnType("jsonb") + .HasColumnName("after_snapshot"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("ApprovedByUserId") + .HasColumnType("uuid") + .HasColumnName("approved_by_user_id"); + + b.Property("BeforeSnapshot") + .HasColumnType("jsonb") + .HasColumnName("before_snapshot"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("entity_type"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("GeneratedContent") + .HasColumnType("jsonb") + .HasColumnName("generated_content"); + + b.Property("InputSnapshot") + .HasColumnType("jsonb") + .HasColumnName("input_snapshot"); + + b.Property("RawOutputs") + .HasColumnType("jsonb") + .HasColumnName("raw_outputs"); + + b.Property("RenderedPrompts") + .HasColumnType("jsonb") + .HasColumnName("rendered_prompts"); + + b.Property("RequiresReview") + .HasColumnType("boolean") + .HasColumnName("requires_review"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.PrimitiveCollection("TargetFields") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("target_fields"); + + b.PrimitiveCollection("TemplateIds") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("template_ids"); + + b.PrimitiveCollection("TemplateVersions") + .IsRequired() + .HasColumnType("integer[]") + .HasColumnName("template_versions"); + + b.Property("TriggeredBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("triggered_by"); + + b.HasKey("Id") + .HasName("pk_seo_backfill_jobs"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("ix_seo_backfill_jobs_entity_type_entity_id"); + + b.HasIndex("Status", "CreatedAt") + .HasDatabaseName("ix_seo_backfill_jobs_status_created_at"); + + b.ToTable("seo_backfill_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SeoBackfillSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.PrimitiveCollection("EntityTypeFilter") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("entity_type_filter"); + + b.Property("IntervalSeconds") + .HasColumnType("integer") + .HasColumnName("interval_seconds"); + + b.Property("JobsPerRun") + .HasColumnType("integer") + .HasColumnName("jobs_per_run"); + + b.PrimitiveCollection("LanguageFilter") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("language_filter"); + + b.Property("SsgRebuildBatchMinutes") + .HasColumnType("integer") + .HasColumnName("ssg_rebuild_batch_minutes"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_seo_backfill_settings"); + + b.ToTable("seo_backfill_settings", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SeoTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("entity_type"); + + b.Property("FieldType") + .HasColumnType("integer") + .HasColumnName("field_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LanguageCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language_code"); + + b.Property("MaxTokens") + .HasColumnType("integer") + .HasColumnName("max_tokens"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OutputSchema") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("output_schema"); + + b.Property("PromptTemplate") + .IsRequired() + .HasColumnType("text") + .HasColumnName("prompt_template"); + + b.Property("Temperature") + .HasColumnType("double precision") + .HasColumnName("temperature"); + + b.Property("TrustLevel") + .HasColumnType("integer") + .HasColumnName("trust_level"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_seo_templates"); + + b.HasIndex("EntityType", "FieldType", "LanguageCode", "IsActive") + .HasDatabaseName("ix_seo_templates_entity_type_field_type_language_code_is_active"); + + b.ToTable("seo_templates", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.ShadowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("FeatureTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("feature_tag"); + + b.Property("PrimaryCostUsd") + .HasColumnType("numeric(10,6)") + .HasColumnName("primary_cost_usd"); + + b.Property("PrimaryLatencyMs") + .HasColumnType("integer") + .HasColumnName("primary_latency_ms"); + + b.Property("PrimaryModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("primary_model_id"); + + b.Property("PrimaryResponse") + .HasColumnType("text") + .HasColumnName("primary_response"); + + b.Property("PrimaryTokensIn") + .HasColumnType("integer") + .HasColumnName("primary_tokens_in"); + + b.Property("PrimaryTokensOut") + .HasColumnType("integer") + .HasColumnName("primary_tokens_out"); + + b.Property("PrimaryTraceId") + .HasColumnType("uuid") + .HasColumnName("primary_trace_id"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("prompt_hash"); + + b.Property("ShadowCostUsd") + .HasColumnType("numeric(10,6)") + .HasColumnName("shadow_cost_usd"); + + b.Property("ShadowLatencyMs") + .HasColumnType("integer") + .HasColumnName("shadow_latency_ms"); + + b.Property("ShadowModelId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("shadow_model_id"); + + b.Property("ShadowResponse") + .HasColumnType("text") + .HasColumnName("shadow_response"); + + b.Property("ShadowTokensIn") + .HasColumnType("integer") + .HasColumnName("shadow_tokens_in"); + + b.Property("ShadowTokensOut") + .HasColumnType("integer") + .HasColumnName("shadow_tokens_out"); + + b.Property("ShadowTraceId") + .HasColumnType("uuid") + .HasColumnName("shadow_trace_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_shadow_runs"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_shadow_runs_user_id") + .HasFilter("user_id IS NOT NULL"); + + b.HasIndex("FeatureTag", "CreatedAt") + .HasDatabaseName("ix_shadow_runs_feature_tag_created_at"); + + b.ToTable("shadow_runs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AdsEnabled") + .HasColumnType("boolean") + .HasColumnName("ads_enabled"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DefaultLanguage") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("default_language"); + + b.Property("FeaturesJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features_json"); + + b.Property("IndexingEnabled") + .HasColumnType("boolean") + .HasColumnName("indexing_enabled"); + + b.Property("PrimaryDomain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("primary_domain"); + + b.Property("SitemapEnabled") + .HasColumnType("boolean") + .HasColumnName("sitemap_enabled"); + + b.Property("Theme") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("theme"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_sites"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_sites_code"); + + b.HasIndex("PrimaryDomain") + .IsUnique() + .HasDatabaseName("ix_sites_primary_domain"); + + b.ToTable("sites", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SiteDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain"); + + b.Property("IsPrimary") + .HasColumnType("boolean") + .HasColumnName("is_primary"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.HasKey("Id") + .HasName("pk_site_domains"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_site_domains_domain"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_site_domains_site_id"); + + b.ToTable("site_domains", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthorSlugsJson") + .HasColumnType("jsonb") + .HasColumnName("author_slugs_json"); + + b.Property("BookSlugsJson") + .HasColumnType("jsonb") + .HasColumnName("book_slugs_json"); + + b.Property("Concurrency") + .HasColumnType("integer") + .HasColumnName("concurrency"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FailedCount") + .HasColumnType("integer") + .HasColumnName("failed_count"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("GenreSlugsJson") + .HasColumnType("jsonb") + .HasColumnName("genre_slugs_json"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("mode"); + + b.Property("RenderedCount") + .HasColumnType("integer") + .HasColumnName("rendered_count"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TimeoutMs") + .HasColumnType("integer") + .HasColumnName("timeout_ms"); + + b.Property("TotalRoutes") + .HasColumnType("integer") + .HasColumnName("total_routes"); + + b.HasKey("Id") + .HasName("pk_ssg_rebuild_jobs"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_ssg_rebuild_jobs_created_at"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_ssg_rebuild_jobs_site_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_ssg_rebuild_jobs_status"); + + b.ToTable("ssg_rebuild_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("RenderTimeMs") + .HasColumnType("integer") + .HasColumnName("render_time_ms"); + + b.Property("RenderedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rendered_at"); + + b.Property("Route") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("route"); + + b.Property("RouteType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("route_type"); + + b.Property("Success") + .HasColumnType("boolean") + .HasColumnName("success"); + + b.HasKey("Id") + .HasName("pk_ssg_rebuild_results"); + + b.HasIndex("JobId") + .HasDatabaseName("ix_ssg_rebuild_results_job_id"); + + b.HasIndex("JobId", "Route") + .IsUnique() + .HasDatabaseName("ix_ssg_rebuild_results_job_id_route"); + + b.ToTable("ssg_rebuild_results", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.TextStackImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("identifier"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("imported_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.HasKey("Id") + .HasName("pk_text_stack_imports"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_text_stack_imports_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_text_stack_imports_site_id"); + + b.HasIndex("SiteId", "Identifier") + .IsUnique() + .HasDatabaseName("ix_text_stack_imports_site_id_identifier"); + + b.ToTable("text_stack_imports", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AppleSubject") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("apple_subject"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("GoogleSubject") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("google_subject"); + + b.Property("IsGuest") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_guest"); + + b.Property("LastActiveAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_active_at"); + + b.Property("Name") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("NativeLanguage") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("native_language"); + + b.Property("PasswordHash") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("password_hash"); + + b.Property("Picture") + .HasColumnType("text") + .HasColumnName("picture"); + + b.Property("StorageUsedBytes") + .HasColumnType("bigint") + .HasColumnName("storage_used_bytes"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("AppleSubject") + .IsUnique() + .HasDatabaseName("ix_users_apple_subject") + .HasFilter("apple_subject IS NOT NULL"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("GoogleSubject") + .IsUnique() + .HasDatabaseName("ix_users_google_subject") + .HasFilter("google_subject IS NOT NULL"); + + b.HasIndex("IsGuest", "LastActiveAt") + .HasDatabaseName("ix_users_guest_cleanup") + .HasFilter("is_guest = true"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserAchievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AchievementCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("achievement_code"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("UnlockedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("unlocked_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_achievements"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_user_achievements_site_id"); + + b.HasIndex("UserId", "SiteId") + .HasDatabaseName("ix_user_achievements_user_id_site_id"); + + b.HasIndex("UserId", "SiteId", "AchievementCode") + .IsUnique() + .HasDatabaseName("ix_user_achievements_user_id_site_id_achievement_code"); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserBook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Author") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("author"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CoverPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("cover_path"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("Genre") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("genre"); + + b.Property("IsClip") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_clip"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("language"); + + b.Property("MetadataHistoryJson") + .HasColumnType("jsonb") + .HasColumnName("metadata_history_json"); + + b.Property("ProgressChapterSlug") + .HasColumnType("text") + .HasColumnName("progress_chapter_slug"); + + b.Property("ProgressLocator") + .HasColumnType("text") + .HasColumnName("progress_locator"); + + b.Property("ProgressPercent") + .HasColumnType("double precision") + .HasColumnName("progress_percent"); + + b.Property("ProgressUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("progress_updated_at"); + + b.Property("PublishedYear") + .HasColumnType("integer") + .HasColumnName("published_year"); + + b.Property("RagChunkCount") + .HasColumnType("integer") + .HasColumnName("rag_chunk_count"); + + b.Property("RagEmbeddedCount") + .HasColumnType("integer") + .HasColumnName("rag_embedded_count"); + + b.Property("RagError") + .HasColumnType("text") + .HasColumnName("rag_error"); + + b.Property("RagIndexedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rag_indexed_at"); + + b.Property("RagStatus") + .HasColumnType("integer") + .HasColumnName("rag_status"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at"); + + b.Property("SeoSource") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("auto") + .HasColumnName("seo_source"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("slug"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("source_url"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.PrimitiveCollection("SuggestedTags") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("suggested_tags") + .HasDefaultValueSql("ARRAY[]::text[]"); + + b.Property("SuggestedTagsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("suggested_tags_at"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("tags") + .HasDefaultValueSql("ARRAY[]::text[]"); + + b.Property("TakedownAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("takedown_at"); + + b.Property("TakedownReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("takedown_reason"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("TocJson") + .HasColumnType("jsonb") + .HasColumnName("toc_json"); + + b.Property("TotalWordCount") + .HasColumnType("integer") + .HasColumnName("total_word_count"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_books"); + + b.HasIndex("Status") + .HasDatabaseName("ix_user_books_status"); + + b.HasIndex("Tags") + .HasDatabaseName("ix_user_books_tags"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Tags"), "gin"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_books_user_id"); + + b.HasIndex("UserId", "IsClip") + .HasDatabaseName("ix_user_books_user_id_is_clip"); + + b.HasIndex("UserId", "Slug") + .IsUnique() + .HasDatabaseName("ix_user_books_user_id_slug"); + + b.ToTable("user_books", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserBookBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Locator") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("locator"); + + b.Property("Title") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.HasKey("Id") + .HasName("pk_user_book_bookmarks"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_user_book_bookmarks_chapter_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_user_book_bookmarks_user_book_id"); + + b.ToTable("user_book_bookmarks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserBookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasColumnName("file_size"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("original_file_name"); + + b.Property("Sha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("sha256"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("storage_path"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.HasKey("Id") + .HasName("pk_user_book_files"); + + b.HasIndex("Sha256") + .HasDatabaseName("ix_user_book_files_sha256"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_user_book_files_user_book_id"); + + b.ToTable("user_book_files", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserChapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterNumber") + .HasColumnType("integer") + .HasColumnName("chapter_number"); + + b.Property("ContentQualityScore") + .HasColumnType("integer") + .HasColumnName("content_quality_score"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Html") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html"); + + b.Property("PlainText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("plain_text"); + + b.Property("Slug") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("slug"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("WordCount") + .HasColumnType("integer") + .HasColumnName("word_count"); + + b.HasKey("Id") + .HasName("pk_user_chapters"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_user_chapters_user_book_id"); + + b.HasIndex("UserBookId", "ChapterNumber") + .IsUnique() + .HasDatabaseName("ix_user_chapters_user_book_id_chapter_number"); + + b.HasIndex("UserBookId", "Slug") + .IsUnique() + .HasDatabaseName("ix_user_chapters_user_book_id_slug"); + + b.ToTable("user_chapters", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserChapterChunk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterOrd") + .HasColumnType("integer") + .HasColumnName("chapter_ord"); + + b.Property("CharEnd") + .HasColumnType("integer") + .HasColumnName("char_end"); + + b.Property("CharStart") + .HasColumnType("integer") + .HasColumnName("char_start"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Embedding") + .HasColumnType("vector(1536)") + .HasColumnName("embedding"); + + b.Property("Ord") + .HasColumnType("integer") + .HasColumnName("ord"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("TokenCount") + .HasColumnType("integer") + .HasColumnName("token_count"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserChapterId") + .HasColumnType("uuid") + .HasColumnName("user_chapter_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_chapter_chunk"); + + b.HasIndex("Embedding") + .HasDatabaseName("ix_user_chapter_chunk_embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("UserChapterId") + .HasDatabaseName("ix_user_chapter_chunk_user_chapter_id"); + + b.HasIndex("UserId", "UserBookId") + .HasDatabaseName("ix_user_chapter_chunk_user_id_user_book_id"); + + b.HasIndex("UserBookId", "UserChapterId", "Ord") + .HasDatabaseName("ix_user_chapter_chunk_user_book_id_user_chapter_id_ord"); + + b.ToTable("user_chapter_chunk", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserIngestionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("integer") + .HasColumnName("attempt_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("SourceFormat") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source_format"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UnitsCount") + .HasColumnType("integer") + .HasColumnName("units_count"); + + b.Property("UserBookFileId") + .HasColumnType("uuid") + .HasColumnName("user_book_file_id"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.HasKey("Id") + .HasName("pk_user_ingestion_jobs"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_user_ingestion_jobs_created_at"); + + b.HasIndex("Status") + .HasDatabaseName("ix_user_ingestion_jobs_status"); + + b.HasIndex("UserBookFileId") + .HasDatabaseName("ix_user_ingestion_jobs_user_book_file_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_user_ingestion_jobs_user_book_id"); + + b.ToTable("user_ingestion_jobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserLibrary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_libraries"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_user_libraries_edition_id"); + + b.HasIndex("UserId", "EditionId") + .IsUnique() + .HasDatabaseName("ix_user_libraries_user_id_edition_id"); + + b.ToTable("user_libraries", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_refresh_tokens"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_user_refresh_tokens_expires_at"); + + b.HasIndex("Token") + .IsUnique() + .HasDatabaseName("ix_user_refresh_tokens_token"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_refresh_tokens_user_id"); + + b.ToTable("user_refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.UserVocabularySettings", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("AutoRetireEnabled") + .HasColumnType("boolean") + .HasColumnName("auto_retire_enabled"); + + b.Property("ClusteringEnabled") + .HasColumnType("boolean") + .HasColumnName("clustering_enabled"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DailyNewCap") + .HasColumnType("integer") + .HasColumnName("daily_new_cap"); + + b.Property("FrequencyFilterEnabled") + .HasColumnType("boolean") + .HasColumnName("frequency_filter_enabled"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("WeeklyReviewBudget") + .HasColumnType("integer") + .HasColumnName("weekly_review_budget"); + + b.HasKey("UserId", "SiteId") + .HasName("pk_user_vocabulary_settings"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_user_vocabulary_settings_site_id"); + + b.ToTable("user_vocabulary_settings", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("IsCorrect") + .HasColumnType("boolean") + .HasColumnName("is_correct"); + + b.Property("ResponseTimeMs") + .HasColumnType("integer") + .HasColumnName("response_time_ms"); + + b.Property("ReviewMode") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("review_mode"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("StageAfter") + .HasColumnType("integer") + .HasColumnName("stage_after"); + + b.Property("StageBefore") + .HasColumnType("integer") + .HasColumnName("stage_before"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VocabularyWordId") + .HasColumnType("uuid") + .HasColumnName("vocabulary_word_id"); + + b.HasKey("Id") + .HasName("pk_vocabulary_reviews"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_vocabulary_reviews_site_id"); + + b.HasIndex("VocabularyWordId") + .HasDatabaseName("ix_vocabulary_reviews_vocabulary_word_id"); + + b.HasIndex("UserId", "SiteId", "CreatedAt") + .HasDatabaseName("ix_vocabulary_reviews_user_id_site_id_created_at"); + + b.ToTable("vocabulary_reviews", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("BookTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("book_title"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("ClusterId") + .HasColumnType("uuid") + .HasColumnName("cluster_id"); + + b.Property("ConceptClusterId") + .HasColumnType("uuid") + .HasColumnName("concept_cluster_id"); + + b.Property("ConsecutiveCorrect") + .HasColumnType("integer") + .HasColumnName("consecutive_correct"); + + b.Property("CorrectReviews") + .HasColumnType("integer") + .HasColumnName("correct_reviews"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Definition") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("definition"); + + b.Property("Distractors") + .HasColumnType("text") + .HasColumnName("distractors"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("Embedding") + .HasColumnType("vector(1536)") + .HasColumnName("embedding"); + + b.Property("Explanation") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("explanation"); + + b.Property("Hint") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("hint"); + + b.Property("IntervalDays") + .HasColumnType("double precision") + .HasColumnName("interval_days"); + + b.Property("IsRetired") + .HasColumnType("boolean") + .HasColumnName("is_retired"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_reviewed_at"); + + b.Property("NextReviewAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_review_at"); + + b.Property("Priority") + .HasColumnType("double precision") + .HasColumnName("priority"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + b.Property("RetiredReason") + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("retired_reason"); + + b.Property("Sentence") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sentence"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("source"); + + b.Property("Stage") + .HasColumnType("integer") + .HasColumnName("stage"); + + b.Property("TotalReviews") + .HasColumnType("integer") + .HasColumnName("total_reviews"); + + b.Property("Translation") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("translation"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("word"); + + b.Property("ZipfRank") + .HasColumnType("integer") + .HasColumnName("zipf_rank"); + + b.Property("ZipfScore") + .HasColumnType("double precision") + .HasColumnName("zipf_score"); + + b.HasKey("Id") + .HasName("pk_vocabulary_words"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_vocabulary_words_chapter_id"); + + b.HasIndex("ClusterId") + .HasDatabaseName("ix_vocabulary_words_cluster_id"); + + b.HasIndex("ConceptClusterId") + .HasDatabaseName("ix_vocabulary_words_concept_cluster_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_vocabulary_words_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_vocabulary_words_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_vocabulary_words_user_book_id"); + + b.HasIndex("UserId", "SiteId") + .HasDatabaseName("ix_vocabulary_words_user_id_site_id"); + + b.HasIndex("UserId", "SiteId", "IsRetired", "NextReviewAt") + .HasDatabaseName("ix_vocabulary_words_user_id_site_id_is_retired_next_review_at"); + + b.HasIndex("UserId", "SiteId", "Word", "Language") + .IsUnique() + .HasDatabaseName("ix_vocabulary_words_user_id_site_id_word_language"); + + b.ToTable("vocabulary_words", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.WordCluster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BookTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("book_title"); + + b.Property("CohesionScore") + .HasColumnType("double precision") + .HasColumnName("cohesion_score"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DismissedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("dismissed_at"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("IsConfirmed") + .HasColumnType("boolean") + .HasColumnName("is_confirmed"); + + b.Property("IsDismissed") + .HasColumnType("boolean") + .HasColumnName("is_dismissed"); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasDefaultValue("book") + .HasColumnName("kind"); + + b.Property("MemberCount") + .HasColumnType("integer") + .HasColumnName("member_count"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Theme") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("theme"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_word_clusters"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_word_clusters_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_word_clusters_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_word_clusters_user_book_id"); + + b.HasIndex("UserId", "SiteId", "IsDismissed", "CreatedAt") + .IsDescending(false, false, false, true) + .HasDatabaseName("ix_word_clusters_user_id_site_id_is_dismissed_created_at"); + + b.ToTable("word_clusters", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.WordFrequency", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("Pos") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("pos"); + + b.Property("Rank") + .HasColumnType("integer") + .HasColumnName("rank"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("word"); + + b.Property("Zipf") + .HasColumnType("double precision") + .HasColumnName("zipf"); + + b.HasKey("Id") + .HasName("pk_word_frequencies"); + + b.HasIndex("Language", "Rank") + .HasDatabaseName("ix_word_frequencies_language_rank"); + + b.HasIndex("Language", "Word") + .IsUnique() + .HasDatabaseName("ix_word_frequencies_language_word"); + + b.ToTable("word_frequencies", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.WordLookup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BookTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("book_title"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("EditionId") + .HasColumnType("uuid") + .HasColumnName("edition_id"); + + b.Property("FirstTappedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_tapped_at"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)") + .HasColumnName("language"); + + b.Property("LastTappedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_tapped_at"); + + b.Property("LastTranslation") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("last_translation"); + + b.Property("Sentence") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("sentence"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("TapCount") + .HasColumnType("integer") + .HasColumnName("tap_count"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("word"); + + b.Property("ZipfRank") + .HasColumnType("integer") + .HasColumnName("zipf_rank"); + + b.HasKey("Id") + .HasName("pk_word_lookups"); + + b.HasIndex("ChapterId") + .HasDatabaseName("ix_word_lookups_chapter_id"); + + b.HasIndex("EditionId") + .HasDatabaseName("ix_word_lookups_edition_id"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_word_lookups_site_id"); + + b.HasIndex("UserBookId") + .HasDatabaseName("ix_word_lookups_user_book_id"); + + b.HasIndex("UserId", "SiteId", "LastTappedAt") + .IsDescending(false, false, true) + .HasDatabaseName("ix_word_lookups_user_id_site_id_last_tapped_at"); + + b.HasIndex("UserId", "SiteId", "Word", "Language") + .IsUnique() + .HasDatabaseName("ix_word_lookups_user_id_site_id_word_language"); + + b.ToTable("word_lookups", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Work", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("SiteId") + .HasColumnType("uuid") + .HasColumnName("site_id"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.HasKey("Id") + .HasName("pk_works"); + + b.HasIndex("SiteId") + .HasDatabaseName("ix_works_site_id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique() + .HasDatabaseName("ix_works_site_id_slug"); + + b.ToTable("works", (string)null); + }); + + modelBuilder.Entity("edition_genres", b => + { + b.Property("EditionsId") + .HasColumnType("uuid") + .HasColumnName("editions_id"); + + b.Property("GenresId") + .HasColumnType("uuid") + .HasColumnName("genres_id"); + + b.HasKey("EditionsId", "GenresId") + .HasName("pk_edition_genres"); + + b.HasIndex("GenresId") + .HasDatabaseName("ix_edition_genres_genres_id"); + + b.ToTable("edition_genres", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.AdminRefreshToken", b => + { + b.HasOne("Domain.Entities.AdminUser", "AdminUser") + .WithMany("RefreshTokens") + .HasForeignKey("AdminUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_admin_refresh_tokens_admin_users_admin_user_id"); + + b.Navigation("AdminUser"); + }); + + modelBuilder.Entity("Domain.Entities.AgentRun", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_agent_run_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Author", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_authors_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.AutoPublishJob", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auto_publish_jobs_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auto_publish_jobs_sites_site_id"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.BookAsset", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("Assets") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_assets_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.BookCollection", b => + { + b.HasOne("Domain.Entities.Collection", "Collection") + .WithMany("Books") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_collections_collections_collection_id"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Domain.Entities.BookFile", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("BookFiles") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_files_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.BookQualityJob", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_book_quality_jobs_editions_edition_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_book_quality_jobs_user_books_user_book_id"); + + b.Navigation("Edition"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.Bookmark", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany("Bookmarks") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bookmarks_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bookmarks_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_bookmarks_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Bookmarks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_bookmarks_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Chapter", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("Chapters") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chapters_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.ChapterChunk", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chapter_chunk_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_chapter_chunk_editions_edition_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.Collection", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_collections_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.DeviceAuthorization", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_device_authorizations_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Edition", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_editions_sites_site_id"); + + b.HasOne("Domain.Entities.Edition", "SourceEdition") + .WithMany("TranslatedEditions") + .HasForeignKey("SourceEditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_editions_editions_source_edition_id"); + + b.HasOne("Domain.Entities.Work", "Work") + .WithMany("Editions") + .HasForeignKey("WorkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_editions_works_work_id"); + + b.Navigation("Site"); + + b.Navigation("SourceEdition"); + + b.Navigation("Work"); + }); + + modelBuilder.Entity("Domain.Entities.EditionAuthor", b => + { + b.HasOne("Domain.Entities.Author", "Author") + .WithMany("EditionAuthors") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_edition_authors_authors_author_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("EditionAuthors") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_edition_authors_editions_edition_id"); + + b.Navigation("Author"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.Genre", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_genres_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.Highlight", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_highlights_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_highlights_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_highlights_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_highlights_user_books_user_book_id"); + + b.HasOne("Domain.Entities.UserChapter", "UserChapter") + .WithMany() + .HasForeignKey("UserChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_highlights_user_chapters_user_chapter_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Highlights") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_highlights_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + + b.Navigation("UserChapter"); + }); + + modelBuilder.Entity("Domain.Entities.IngestionJob", b => + { + b.HasOne("Domain.Entities.BookFile", "BookFile") + .WithMany("IngestionJobs") + .HasForeignKey("BookFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingestion_jobs_book_files_book_file_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany("IngestionJobs") + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ingestion_jobs_editions_edition_id"); + + b.HasOne("Domain.Entities.Edition", "SourceEdition") + .WithMany() + .HasForeignKey("SourceEditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_ingestion_jobs_editions_source_edition_id"); + + b.HasOne("Domain.Entities.Work", "Work") + .WithMany() + .HasForeignKey("WorkId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_ingestion_jobs_works_work_id"); + + b.Navigation("BookFile"); + + b.Navigation("Edition"); + + b.Navigation("SourceEdition"); + + b.Navigation("Work"); + }); + + modelBuilder.Entity("Domain.Entities.LintResult", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_lint_results_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.LlmTrace", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_llm_traces_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Note", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany("Notes") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notes_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notes_editions_edition_id"); + + b.HasOne("Domain.Entities.Highlight", "Highlight") + .WithOne("Note") + .HasForeignKey("Domain.Entities.Note", "HighlightId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_notes_highlights_highlight_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_notes_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Notes") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notes_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Highlight"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.PasswordResetToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_password_reset_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.PendingVocabularyWord", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_pending_vocabulary_words_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_pending_vocabulary_words_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_pending_vocabulary_words_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_pending_vocabulary_words_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pending_vocabulary_words_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.PodcastGenerationJob", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_podcast_generation_jobs_editions_edition_id"); + + b.Navigation("Edition"); + }); + + modelBuilder.Entity("Domain.Entities.ReadingGoal", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_reading_goals_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_goals_users_user_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.ReadingProgress", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany("ReadingProgresses") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_progresses_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_progresses_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_reading_progresses_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("ReadingProgresses") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_progresses_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.ReadingSession", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_reading_sessions_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_reading_sessions_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_reading_sessions_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reading_sessions_users_user_id"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.ShadowRun", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_shadow_runs_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.SiteDomain", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany("Domains") + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_site_domains_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildJob", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_ssg_rebuild_jobs_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildResult", b => + { + b.HasOne("Domain.Entities.SsgRebuildJob", "Job") + .WithMany("Results") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ssg_rebuild_results_ssg_rebuild_jobs_job_id"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("Domain.Entities.TextStackImport", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_text_stack_imports_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_text_stack_imports_sites_site_id"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Domain.Entities.UserAchievement", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_achievements_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_achievements_users_user_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserBook", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("UserBooks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_books_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserBookBookmark", b => + { + b.HasOne("Domain.Entities.UserChapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_book_bookmarks_user_chapters_chapter_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_book_bookmarks_user_books_user_book_id"); + + b.Navigation("Chapter"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.UserBookFile", b => + { + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany("BookFiles") + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_book_files_user_books_user_book_id"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.UserChapter", b => + { + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany("Chapters") + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_chapters_user_books_user_book_id"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.UserChapterChunk", b => + { + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_chapter_chunk_user_books_user_book_id"); + + b.HasOne("Domain.Entities.UserChapter", "UserChapter") + .WithMany() + .HasForeignKey("UserChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_chapter_chunk_user_chapters_user_chapter_id"); + + b.Navigation("UserBook"); + + b.Navigation("UserChapter"); + }); + + modelBuilder.Entity("Domain.Entities.UserIngestionJob", b => + { + b.HasOne("Domain.Entities.UserBookFile", "UserBookFile") + .WithMany() + .HasForeignKey("UserBookFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_ingestion_jobs_user_book_files_user_book_file_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany("IngestionJobs") + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_ingestion_jobs_user_books_user_book_id"); + + b.Navigation("UserBook"); + + b.Navigation("UserBookFile"); + }); + + modelBuilder.Entity("Domain.Entities.UserLibrary", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_libraries_editions_edition_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("UserLibraries") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_libraries_users_user_id"); + + b.Navigation("Edition"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserRefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_refresh_tokens_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.UserVocabularySettings", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_user_vocabulary_settings_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_vocabulary_settings_users_user_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyReview", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_vocabulary_reviews_sites_site_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_reviews_users_user_id"); + + b.HasOne("Domain.Entities.VocabularyWord", "VocabularyWord") + .WithMany("Reviews") + .HasForeignKey("VocabularyWordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_reviews_vocabulary_words_vocabulary_word_id"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("VocabularyWord"); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyWord", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_chapters_chapter_id"); + + b.HasOne("Domain.Entities.WordCluster", null) + .WithMany("Words") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_word_clusters_cluster_id"); + + b.HasOne("Domain.Entities.WordCluster", "ConceptCluster") + .WithMany("ConceptWords") + .HasForeignKey("ConceptClusterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_word_clusters_concept_cluster_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_vocabulary_words_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_vocabulary_words_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_words_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("ConceptCluster"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.WordCluster", b => + { + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_clusters_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_word_clusters_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_clusters_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_word_clusters_users_user_id"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.WordLookup", b => + { + b.HasOne("Domain.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_lookups_chapters_chapter_id"); + + b.HasOne("Domain.Entities.Edition", "Edition") + .WithMany() + .HasForeignKey("EditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_lookups_editions_edition_id"); + + b.HasOne("Domain.Entities.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_word_lookups_sites_site_id"); + + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_word_lookups_user_books_user_book_id"); + + b.HasOne("Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_word_lookups_users_user_id"); + + b.Navigation("Chapter"); + + b.Navigation("Edition"); + + b.Navigation("Site"); + + b.Navigation("User"); + + b.Navigation("UserBook"); + }); + + modelBuilder.Entity("Domain.Entities.Work", b => + { + b.HasOne("Domain.Entities.Site", "Site") + .WithMany("Works") + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_works_sites_site_id"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("edition_genres", b => + { + b.HasOne("Domain.Entities.Edition", null) + .WithMany() + .HasForeignKey("EditionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_edition_genres_editions_editions_id"); + + b.HasOne("Domain.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_edition_genres_genres_genres_id"); + }); + + modelBuilder.Entity("Domain.Entities.AdminUser", b => + { + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Author", b => + { + b.Navigation("EditionAuthors"); + }); + + modelBuilder.Entity("Domain.Entities.BookFile", b => + { + b.Navigation("IngestionJobs"); + }); + + modelBuilder.Entity("Domain.Entities.Chapter", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Notes"); + + b.Navigation("ReadingProgresses"); + }); + + modelBuilder.Entity("Domain.Entities.Collection", b => + { + b.Navigation("Books"); + }); + + modelBuilder.Entity("Domain.Entities.Edition", b => + { + b.Navigation("Assets"); + + b.Navigation("BookFiles"); + + b.Navigation("Chapters"); + + b.Navigation("EditionAuthors"); + + b.Navigation("IngestionJobs"); + + b.Navigation("TranslatedEditions"); + }); + + modelBuilder.Entity("Domain.Entities.Highlight", b => + { + b.Navigation("Note"); + }); + + modelBuilder.Entity("Domain.Entities.Site", b => + { + b.Navigation("Domains"); + + b.Navigation("Works"); + }); + + modelBuilder.Entity("Domain.Entities.SsgRebuildJob", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Highlights"); + + b.Navigation("Notes"); + + b.Navigation("ReadingProgresses"); + + b.Navigation("UserBooks"); + + b.Navigation("UserLibraries"); + }); + + modelBuilder.Entity("Domain.Entities.UserBook", b => + { + b.Navigation("BookFiles"); + + b.Navigation("Chapters"); + + b.Navigation("IngestionJobs"); + }); + + modelBuilder.Entity("Domain.Entities.VocabularyWord", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("Domain.Entities.WordCluster", b => + { + b.Navigation("ConceptWords"); + + b.Navigation("Words"); + }); + + modelBuilder.Entity("Domain.Entities.Work", b => + { + b.Navigation("Editions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Infrastructure/Migrations/20260619053612_AddUserBookRagIndex.cs b/backend/src/Infrastructure/Migrations/20260619053612_AddUserBookRagIndex.cs new file mode 100644 index 00000000..c9d472f5 --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260619053612_AddUserBookRagIndex.cs @@ -0,0 +1,148 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Pgvector; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddUserBookRagIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "rag_chunk_count", + table: "user_books", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "rag_embedded_count", + table: "user_books", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "rag_error", + table: "user_books", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "rag_indexed_at", + table: "user_books", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "rag_status", + table: "user_books", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "user_chapter_chunk", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + user_book_id = table.Column(type: "uuid", nullable: false), + user_chapter_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + ord = table.Column(type: "integer", nullable: false), + chapter_ord = table.Column(type: "integer", nullable: false), + text = table.Column(type: "text", nullable: false), + embedding = table.Column(type: "vector(1536)", nullable: true), + token_count = table.Column(type: "integer", nullable: false), + char_start = table.Column(type: "integer", nullable: false), + char_end = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_user_chapter_chunk", x => x.id); + table.ForeignKey( + name: "fk_user_chapter_chunk_user_books_user_book_id", + column: x => x.user_book_id, + principalTable: "user_books", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_user_chapter_chunk_user_chapters_user_chapter_id", + column: x => x.user_chapter_id, + principalTable: "user_chapters", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_user_chapter_chunk_embedding", + table: "user_chapter_chunk", + column: "embedding") + .Annotation("Npgsql:IndexMethod", "hnsw") + .Annotation("Npgsql:IndexOperators", new[] { "vector_cosine_ops" }); + + migrationBuilder.CreateIndex( + name: "ix_user_chapter_chunk_user_book_id_user_chapter_id_ord", + table: "user_chapter_chunk", + columns: new[] { "user_book_id", "user_chapter_id", "ord" }); + + migrationBuilder.CreateIndex( + name: "ix_user_chapter_chunk_user_chapter_id", + table: "user_chapter_chunk", + column: "user_chapter_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_chapter_chunk_user_id_user_book_id", + table: "user_chapter_chunk", + columns: new[] { "user_id", "user_book_id" }); + + // Lexical branch for hybrid retrieval (mirrors chapter_chunk.search_vector). Generated + // column stays out of the EF model — retrieval is hand-written Npgsql. 'english' is a + // constant (generated columns require an immutable expression). + migrationBuilder.Sql(@" + ALTER TABLE user_chapter_chunk + ADD COLUMN IF NOT EXISTS search_vector tsvector + GENERATED ALWAYS AS (to_tsvector('english', coalesce(text, ''))) STORED; + "); + + migrationBuilder.Sql(@" + CREATE INDEX IF NOT EXISTS ix_user_chapter_chunk_search_vector + ON user_chapter_chunk USING GIN (search_vector); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP INDEX IF EXISTS ix_user_chapter_chunk_search_vector;"); + + migrationBuilder.DropTable( + name: "user_chapter_chunk"); + + migrationBuilder.DropColumn( + name: "rag_chunk_count", + table: "user_books"); + + migrationBuilder.DropColumn( + name: "rag_embedded_count", + table: "user_books"); + + migrationBuilder.DropColumn( + name: "rag_error", + table: "user_books"); + + migrationBuilder.DropColumn( + name: "rag_indexed_at", + table: "user_books"); + + migrationBuilder.DropColumn( + name: "rag_status", + table: "user_books"); + } + } +} diff --git a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 3a086fe5..efcf799f 100644 --- a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -3034,6 +3034,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("published_year"); + b.Property("RagChunkCount") + .HasColumnType("integer") + .HasColumnName("rag_chunk_count"); + + b.Property("RagEmbeddedCount") + .HasColumnType("integer") + .HasColumnName("rag_embedded_count"); + + b.Property("RagError") + .HasColumnType("text") + .HasColumnName("rag_error"); + + b.Property("RagIndexedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rag_indexed_at"); + + b.Property("RagStatus") + .HasColumnType("integer") + .HasColumnName("rag_status"); + b.Property("ReadAt") .HasColumnType("timestamp with time zone") .HasColumnName("read_at"); @@ -3293,6 +3313,81 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_chapters", (string)null); }); + modelBuilder.Entity("Domain.Entities.UserChapterChunk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChapterOrd") + .HasColumnType("integer") + .HasColumnName("chapter_ord"); + + b.Property("CharEnd") + .HasColumnType("integer") + .HasColumnName("char_end"); + + b.Property("CharStart") + .HasColumnType("integer") + .HasColumnName("char_start"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Embedding") + .HasColumnType("vector(1536)") + .HasColumnName("embedding"); + + b.Property("Ord") + .HasColumnType("integer") + .HasColumnName("ord"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("TokenCount") + .HasColumnType("integer") + .HasColumnName("token_count"); + + b.Property("UserBookId") + .HasColumnType("uuid") + .HasColumnName("user_book_id"); + + b.Property("UserChapterId") + .HasColumnType("uuid") + .HasColumnName("user_chapter_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_chapter_chunk"); + + b.HasIndex("Embedding") + .HasDatabaseName("ix_user_chapter_chunk_embedding"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" }); + + b.HasIndex("UserChapterId") + .HasDatabaseName("ix_user_chapter_chunk_user_chapter_id"); + + b.HasIndex("UserId", "UserBookId") + .HasDatabaseName("ix_user_chapter_chunk_user_id_user_book_id"); + + b.HasIndex("UserBookId", "UserChapterId", "Ord") + .HasDatabaseName("ix_user_chapter_chunk_user_book_id_user_chapter_id_ord"); + + b.ToTable("user_chapter_chunk", (string)null); + }); + modelBuilder.Entity("Domain.Entities.UserIngestionJob", b => { b.Property("Id") @@ -4755,6 +4850,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("UserBook"); }); + modelBuilder.Entity("Domain.Entities.UserChapterChunk", b => + { + b.HasOne("Domain.Entities.UserBook", "UserBook") + .WithMany() + .HasForeignKey("UserBookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_chapter_chunk_user_books_user_book_id"); + + b.HasOne("Domain.Entities.UserChapter", "UserChapter") + .WithMany() + .HasForeignKey("UserChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_chapter_chunk_user_chapters_user_chapter_id"); + + b.Navigation("UserBook"); + + b.Navigation("UserChapter"); + }); + modelBuilder.Entity("Domain.Entities.UserIngestionJob", b => { b.HasOne("Domain.Entities.UserBookFile", "UserBookFile") diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Rag.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Rag.cs index 9cf82c41..5d84c7c2 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.Rag.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Rag.cs @@ -52,5 +52,41 @@ private static void ConfigureRag(ModelBuilder modelBuilder) .HasForeignKey(x => x.ChapterId) .OnDelete(DeleteBehavior.Cascade); }); + + // Phase 2: user-uploaded book chunks (table `user_chapter_chunk`). Isolated from + // `chapter_chunk` (NOT polymorphic) and carries a denormalized user_id so retrieval can + // hard-filter per user. Deleting a user book OR a user chapter cascades the chunks away. + modelBuilder.Entity(e => + { + e.ToTable("user_chapter_chunk"); + + e.Property(x => x.Embedding) + .HasColumnType("vector(1536)") + .HasConversion( + v => v == null ? null : new Vector(v), + v => v == null ? null : v.ToArray()); + + e.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); + + // Own HNSW index — cosine NN over the user chunks (independent of the catalog index). + e.HasIndex(x => x.Embedding) + .HasMethod("hnsw") + .HasOperators("vector_cosine_ops"); + + // Per-user isolation filter (user_id + user_book_id) is the hot path; index it. + e.HasIndex(x => new { x.UserId, x.UserBookId }); + // Ordered fetch of a chapter's chunks. + e.HasIndex(x => new { x.UserBookId, x.UserChapterId, x.Ord }); + + e.HasOne(x => x.UserBook) + .WithMany() + .HasForeignKey(x => x.UserBookId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasOne(x => x.UserChapter) + .WithMany() + .HasForeignKey(x => x.UserChapterId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.cs b/backend/src/Infrastructure/Persistence/AppDbContext.cs index bfa42f4a..5a0547f8 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.cs @@ -91,6 +91,9 @@ public Task BeginTransactionAsync(CancellationToken ct = // Phase 4 RAG. Intentionally not on IAppDbContext — retrieval uses raw Npgsql. public DbSet ChapterChunks => Set(); + // Phase 2 on-demand RAG over user uploads. Isolated per-user table; not on IAppDbContext. + public DbSet UserChapterChunks => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/backend/src/Infrastructure/Rag/BookChunkingService.cs b/backend/src/Infrastructure/Rag/BookChunkingService.cs index ef63930f..01a1b984 100644 --- a/backend/src/Infrastructure/Rag/BookChunkingService.cs +++ b/backend/src/Infrastructure/Rag/BookChunkingService.cs @@ -53,6 +53,93 @@ public async Task ChunkEditionAsync(AppDbContext db, Guid editionId, Cancel } } + /// + /// Phase 2: creates rows for every chapter of a USER-uploaded book + /// () into the isolated user_chapter_chunk table. Each row + /// carries the denormalized (the book's owner) so retrieval + /// can hard-filter per user. Best-effort like : logs and returns 0 + /// on failure. Returns the total chunk count. + /// + public async Task ChunkUserBookAsync(AppDbContext db, Guid userBookId, CancellationToken ct) + { + try + { + var ownerId = await db.UserBooks + .Where(b => b.Id == userBookId) + .Select(b => b.UserId) + .FirstOrDefaultAsync(ct); + + if (ownerId == Guid.Empty) + { + logger.LogWarning("RAG chunking: user book {UserBookId} not found", userBookId); + return 0; + } + + var chapters = await db.UserChapters + .Where(c => c.UserBookId == userBookId) + .OrderBy(c => c.ChapterNumber) + .Select(c => new { c.Id, c.ChapterNumber, c.PlainText }) + .ToListAsync(ct); + + var rows = BuildUserRows( + chunker, userBookId, ownerId, + chapters.Select(c => (c.Id, c.ChapterNumber, (string?)c.PlainText))); + + if (rows.Count > 0) + { + db.UserChapterChunks.AddRange(rows); + await db.SaveChangesAsync(ct); + logger.LogInformation( + "Created {Count} RAG chunks for user book {UserBookId}", rows.Count, userBookId); + } + + return rows.Count; + } + catch (Exception ex) + { + logger.LogWarning(ex, + "RAG chunking failed for user book {UserBookId}; chunks can be regenerated on retrigger", userBookId); + return 0; + } + } + + /// + /// Pure mapping: user-book chapters → ordered rows (null embedding) + /// via . Each row carries (denormalized owner) + /// + so the per-user isolation filter works in SQL. Extracted so the + /// row shape is unit-testable without a DB. + /// + public static List BuildUserRows( + IChunker chunker, + Guid userBookId, + Guid userId, + IEnumerable<(Guid Id, int ChapterNumber, string? PlainText)> chapters) + { + var rows = new List(); + foreach (var chapter in chapters) + { + foreach (var chunk in chunker.Chunk(chapter.PlainText ?? string.Empty)) + { + rows.Add(new UserChapterChunk + { + Id = Guid.NewGuid(), + UserBookId = userBookId, + UserChapterId = chapter.Id, + UserId = userId, + ChapterOrd = chapter.ChapterNumber, + Ord = chunk.Ord, + Text = chunk.Text, + TokenCount = chunk.TokenCount, + CharStart = chunk.CharStart, + CharEnd = chunk.CharEnd, + Embedding = null + }); + } + } + + return rows; + } + /// /// Pure mapping: chapters → ordered rows (null embedding) via /// . Extracted so the row shape (denormalized ChapterOrd, offsets, diff --git a/backend/src/Worker/Services/ChapterEmbeddingWorker.cs b/backend/src/Worker/Services/ChapterEmbeddingWorker.cs index 5670534d..42d05fd0 100644 --- a/backend/src/Worker/Services/ChapterEmbeddingWorker.cs +++ b/backend/src/Worker/Services/ChapterEmbeddingWorker.cs @@ -31,6 +31,7 @@ public sealed class ChapterEmbeddingWorker : BackgroundService // Chunks that fail even per-item are parked in-memory so they don't re-appear at the head // of every batch (oldest-first) and force the whole backlog through the slow per-item path. private readonly HashSet _poisoned = []; + private readonly HashSet _poisonedUser = []; public ChapterEmbeddingWorker( IDbContextFactory dbFactory, @@ -61,7 +62,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { + // One OpenAI drain across both corpora: catalog chunks first, then user-book chunks. + // Keeping a single worker (not a second one) preserves the rate-limited consumer. var processed = await EmbedNextBatchAsync(embedder, stoppingToken); + processed += await EmbedNextUserBatchAsync(embedder, stoppingToken); await Task.Delay(processed == 0 ? IdlePoll : InterBatchDelay, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) @@ -121,6 +125,110 @@ private async Task EmbedNextBatchAsync(IEmbeddingService embedder, Cancella return batch.Count; } + /// + /// Phase 2: the user-book analog of . Drains + /// user_chapter_chunk WHERE embedding IS NULL in batches using the SAME embedder, then + /// updates each touched user book's rag_embedded_count and flips it Ready. No mean-pool + /// recompute (that's a catalog-only feature). Best-effort, mirrors the catalog resilience. + /// + private async Task EmbedNextUserBatchAsync(IEmbeddingService embedder, CancellationToken ct) + { + await using var db = await _dbFactory.CreateDbContextAsync(ct); + + var poisoned = _poisonedUser.ToList(); + var batch = await db.UserChapterChunks + .Where(c => c.Embedding == null && !poisoned.Contains(c.Id)) + .OrderBy(c => c.CreatedAt) + .Take(BatchSize) + .ToListAsync(ct); + + if (batch.Count == 0) + return 0; + + try + { + var vectors = await embedder.EmbedBatchAsync(batch.Select(c => c.Text).ToList(), ct); + AssignUserEmbeddings(batch, vectors); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Batch embed failed for {Count} user chunks; falling back to per-item.", batch.Count); + await EmbedUserPerItemAsync(embedder, batch, ct); + } + + await db.SaveChangesAsync(ct); + + var touchedBooks = batch.Select(c => c.UserBookId).Distinct().ToList(); + await UpdateUserBookRagProgressAsync(db, touchedBooks, ct); + + return batch.Count; + } + + /// + /// For each touched user book, set rag_embedded_count to its current count of embedded + /// chunks; flip rag_status to Ready(2) with rag_indexed_at = now() when that equals + /// rag_chunk_count (and > 0) — the same predicate as RagIndexLogic.IsReady. + /// Best-effort per book. + /// + private async Task UpdateUserBookRagProgressAsync( + AppDbContext db, IReadOnlyList userBookIds, CancellationToken ct) + { + foreach (var userBookId in userBookIds) + { + try + { + await db.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE user_books b + SET rag_embedded_count = sub.embedded, + rag_status = CASE + WHEN sub.embedded = b.rag_chunk_count AND b.rag_chunk_count > 0 THEN 2 + ELSE b.rag_status + END, + rag_indexed_at = CASE + WHEN sub.embedded = b.rag_chunk_count AND b.rag_chunk_count > 0 THEN now() + ELSE b.rag_indexed_at + END + FROM ( + SELECT count(*) FILTER (WHERE embedding IS NOT NULL) AS embedded + FROM user_chapter_chunk + WHERE user_book_id = {userBookId} + ) sub + WHERE b.id = {userBookId}; + """, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Failed to update RAG progress for user book {UserBookId}; will retry next batch.", userBookId); + } + } + } + + private async Task EmbedUserPerItemAsync(IEmbeddingService embedder, List batch, CancellationToken ct) + { + foreach (var chunk in batch) + { + try + { + chunk.Embedding = await embedder.EmbedAsync(chunk.Text, ct); + } + catch (Exception ex) + { + _poisonedUser.Add(chunk.Id); + _logger.LogWarning(ex, "Failed to embed user chunk {ChunkId}; parked for this process lifetime.", chunk.Id); + } + } + } + + /// Assigns a batch of vectors onto user chunks by position (order-preserving). + public static void AssignUserEmbeddings(IReadOnlyList chunks, IReadOnlyList vectors) + { + if (vectors.Count != chunks.Count) + throw new InvalidOperationException($"Embedding count {vectors.Count} != user chunk count {chunks.Count}"); + for (var i = 0; i < chunks.Count; i++) + chunks[i].Embedding = vectors[i]; + } + /// /// For each touched edition, set rag_embedded_count to its current count of embedded /// chunks; and when that equals rag_chunk_count (and > 0) flip rag_status to diff --git a/tests/TextStack.AiEvals/RagEvalRunnerTests.cs b/tests/TextStack.AiEvals/RagEvalRunnerTests.cs index c97beee4..e27dabde 100644 --- a/tests/TextStack.AiEvals/RagEvalRunnerTests.cs +++ b/tests/TextStack.AiEvals/RagEvalRunnerTests.cs @@ -38,6 +38,10 @@ public Task> RetrieveAsync( private static Task> Ok(RetrievedChunk c) => Task.FromResult>([c]); + + public Task> RetrieveUserBookAsync( + Guid userId, Guid userBookId, string query, int k, int? maxChapterOrd, CancellationToken ct) => + throw new NotSupportedException(); } /// Whiffs recall (wrong chapter) and leaks every gate (returns a chunk past it). @@ -51,6 +55,10 @@ public Task> RetrieveAsync( var chunk = new RetrievedChunk(Guid.NewGuid(), Guid.NewGuid(), ord, 0, "irrelevant", 0, 10, 0.0); return Task.FromResult>([chunk]); } + + public Task> RetrieveUserBookAsync( + Guid userId, Guid userBookId, string query, int k, int? maxChapterOrd, CancellationToken ct) => + throw new NotSupportedException(); } /// Echoes back a one-citation answer pointing at the first supplied chunk. diff --git a/tests/TextStack.IntegrationTests/UserBookRagEndpointTests.cs b/tests/TextStack.IntegrationTests/UserBookRagEndpointTests.cs new file mode 100644 index 00000000..5dbdb518 --- /dev/null +++ b/tests/TextStack.IntegrationTests/UserBookRagEndpointTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using System.Net.Http.Json; + +namespace TextStack.IntegrationTests; + +/// +/// Integration tests for Phase 2 on-demand "Ask this book" over USER-uploaded books, against the +/// live API. Owner-scoped: the index/status/ask paths need a user session and a real owned book +/// (seeded via the clip endpoint), so they skip when auth/seed is unavailable. The unauth + non-owner +/// paths run without a key. Cross-user isolation (user A's chunks never returned for user B) is +/// asserted at the SQL level in RagServiceUserBookIsolationTests (integration can't seed a +/// second user here); these tests cover the endpoint contract + ownership 404s. +/// +public class UserBookRagEndpointTests : IClassFixture, IClassFixture +{ + private static readonly Guid SomeBook = Guid.Parse("11111111-2222-3333-4444-555555555555"); + + private readonly LiveApiFixture _anon; + private readonly AuthenticatedApiFixture _auth; + + public UserBookRagEndpointTests(LiveApiFixture anon, AuthenticatedApiFixture auth) + { + _anon = anon; + _auth = auth; + } + + private sealed record IndexStatus(string Status, int ChunkCount, int EmbeddedCount); + private sealed record AskResponseDto(string Answer, object[] Citations, int LastReadOrd, bool Insufficient); + private sealed record ClipResponse(Guid UserBookId, Guid JobId, string Status); + + private static readonly object SampleClip = new + { + title = "RAG Integration Clip", + author = "Test Author", + html = "

RAG Integration Clip

Real fluency comes from reading real books deeply, " + + "consistently, over a long period of time. This paragraph exists so the chunker has text.

", + language = "en" + }; + + // ----- Unauthenticated → 401 (before any DB/chunk/LLM work; no key needed) ----- + + [Fact] + public async Task PostIndex_NoAuth_Unauthorized() + { + var request = _anon.CreateRequest(HttpMethod.Post, $"/me/books/{SomeBook}/index"); + var response = await _anon.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(response.StatusCode is HttpStatusCode.NotFound, "endpoint not deployed"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetIndex_NoAuth_Unauthorized() + { + var request = _anon.CreateRequest(HttpMethod.Get, $"/me/books/{SomeBook}/index"); + var response = await _anon.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(response.StatusCode is HttpStatusCode.NotFound, "endpoint not deployed"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostAsk_NoAuth_Unauthorized() + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/me/books/{SomeBook}/ask") + { + Content = JsonContent.Create(new { question = "anything" }), + }; + request.Headers.Host = LiveApiFixture.TestHost; + var response = await _anon.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(response.StatusCode is HttpStatusCode.NotFound, "endpoint not deployed"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + // ----- Ownership: a book the caller does not own (random id) → 404, never leaked ----- + + [Fact] + public async Task PostIndex_AuthedForeignBook_Returns404() + { + Assert.SkipUnless(_auth.IsAuthenticated, "auth unavailable"); + + var request = _auth.CreateRequest(HttpMethod.Post, $"/me/books/{Guid.NewGuid()}/index"); + var response = await _auth.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(response.StatusCode == HttpStatusCode.InternalServerError, "host erroring"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetIndex_AuthedForeignBook_Returns404() + { + Assert.SkipUnless(_auth.IsAuthenticated, "auth unavailable"); + + var request = _auth.CreateRequest(HttpMethod.Get, $"/me/books/{Guid.NewGuid()}/index"); + var response = await _auth.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(response.StatusCode == HttpStatusCode.InternalServerError, "host erroring"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostAsk_AuthedForeignBook_Returns404() + { + Assert.SkipUnless(_auth.IsAuthenticated, "auth unavailable"); + + var request = _auth.CreateRequest(HttpMethod.Post, $"/me/books/{Guid.NewGuid()}/ask"); + request.Content = JsonContent.Create(new { question = "what is this book about?" }); + var response = await _auth.Client.SendAsync(request, TestContext.Current.CancellationToken); + + // 404 = not the caller's book. 503 = no OpenAI key (services resolve before ownership) — skip. + Assert.SkipWhen( + response.StatusCode is HttpStatusCode.InternalServerError or HttpStatusCode.ServiceUnavailable, + "host erroring / no key"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // ----- Owner happy path: seed a real owned book, index it, poll status, ask ----- + + [Fact] + public async Task PostIndex_OwnedBook_Returns202Or200WithStatus() + { + Assert.SkipUnless(_auth.IsAuthenticated, "auth unavailable"); + var bookId = await SeedOwnedBookAsync(); + Assert.SkipWhen(bookId is null, "could not seed an owned user book"); + + var request = _auth.CreateRequest(HttpMethod.Post, $"/me/books/{bookId}/index"); + var response = await _auth.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(IntegrationSkip.Unavailable(response), "endpoint unavailable (404/500)"); + Assert.True( + response.StatusCode is HttpStatusCode.Accepted or HttpStatusCode.OK, + $"expected 202 or 200, got {(int)response.StatusCode}"); + + var status = await response.Content.ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(status); + Assert.Contains(status!.Status, new[] { "NotIndexed", "Indexing", "Ready", "Failed" }); + Assert.True(status.ChunkCount >= 0); + Assert.True(status.EmbeddedCount >= 0); + } + + [Fact] + public async Task GetIndex_OwnedBook_ReturnsStatusShape() + { + Assert.SkipUnless(_auth.IsAuthenticated, "auth unavailable"); + var bookId = await SeedOwnedBookAsync(); + Assert.SkipWhen(bookId is null, "could not seed an owned user book"); + + var request = _auth.CreateRequest(HttpMethod.Get, $"/me/books/{bookId}/index"); + var response = await _auth.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(IntegrationSkip.Unavailable(response), "endpoint unavailable (404/500)"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var status = await response.Content.ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(status); + Assert.Contains(status!.Status, new[] { "NotIndexed", "Indexing", "Ready", "Failed" }); + } + + [Fact] + public async Task PostAsk_OwnedBook_ReturnsAnswerOrInsufficient() + { + Assert.SkipUnless(_auth.IsAuthenticated, "auth unavailable"); + var bookId = await SeedOwnedBookAsync(); + Assert.SkipWhen(bookId is null, "could not seed an owned user book"); + + var request = _auth.CreateRequest(HttpMethod.Post, $"/me/books/{bookId}/ask"); + request.Content = JsonContent.Create(new { question = "what does this book say about reading?" }); + var response = await _auth.Client.SendAsync(request, TestContext.Current.CancellationToken); + + // 503 = no key, 504 = timeout, 500 = erroring → skip rather than fail. + Assert.SkipWhen( + IntegrationSkip.Unavailable(response) + || response.StatusCode is HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout, + "ask not reachable (no key / corpus / timeout)"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var answer = await response.Content.ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(answer); + Assert.False(string.IsNullOrWhiteSpace(answer!.Answer)); + } + + /// + /// Seeds a clip (a UserBook owned by the test user) and returns its id; null if the seed fails. + /// The clip is processed async by the worker, but indexing reads UserChapter rows — so a + /// just-seeded clip may chunk to 0 (→ Failed) before extraction lands; the contract assertions + /// allow that. The point is owner-scoped 202/200/404 behavior, not embedding completion. + /// + private async Task SeedOwnedBookAsync() + { + var seed = _auth.CreateRequest(HttpMethod.Post, "/me/books/clip"); + seed.Content = JsonContent.Create(SampleClip); + var resp = await _auth.Client.SendAsync(seed, TestContext.Current.CancellationToken); + if (resp.StatusCode != HttpStatusCode.OK) + return null; + + var clip = await resp.Content.ReadFromJsonAsync( + cancellationToken: TestContext.Current.CancellationToken); + return clip?.UserBookId == Guid.Empty ? null : clip?.UserBookId; + } +} diff --git a/tests/TextStack.UnitTests/BookChunkingServiceTests.cs b/tests/TextStack.UnitTests/BookChunkingServiceTests.cs index 44d35b15..5a762ab3 100644 --- a/tests/TextStack.UnitTests/BookChunkingServiceTests.cs +++ b/tests/TextStack.UnitTests/BookChunkingServiceTests.cs @@ -85,4 +85,79 @@ public void BuildRows_EmptyChapterText_ProducesNoRows() Assert.Empty(rows); } + + // ----- Phase 2: user-book chunking (BuildUserRows mirrors BuildRows but isolated per user) ----- + + [Fact] + public void BuildUserRows_KnownChapters_ChunkCountMatchesChunker() + { + var userBookId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var chapterId = Guid.NewGuid(); + var chunker = new Chunker(maxTokens: 12, overlapTokens: 4); + + var expected = chunker.Chunk(ChapterOne).Count; + var rows = BookChunkingService.BuildUserRows( + chunker, userBookId, userId, [(chapterId, 0, ChapterOne)]); + + Assert.True(expected >= 2, "fixture must split into multiple chunks"); + Assert.Equal(expected, rows.Count); + } + + [Fact] + public void BuildUserRows_RowsCarryUserIdBookIdChapterAndNullEmbedding() + { + var userBookId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var chapterId = Guid.NewGuid(); + + var rows = BookChunkingService.BuildUserRows( + new Chunker(), userBookId, userId, [(chapterId, 3, "One short sentence here.")]); + + var row = Assert.Single(rows); + // The denormalized owner id is the isolation boundary — it MUST be stamped on every row. + Assert.Equal(userId, row.UserId); + Assert.Equal(userBookId, row.UserBookId); + Assert.Equal(chapterId, row.UserChapterId); + Assert.Equal(3, row.ChapterOrd); // denormalized from chapter number + Assert.Equal(0, row.Ord); + Assert.Null(row.Embedding); // filled later by the embedding worker + Assert.True(row.TokenCount > 0); + } + + [Fact] + public void BuildUserRows_AllRowsCarrySameUserAndBook() + { + var userBookId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var rows = BookChunkingService.BuildUserRows( + new Chunker(maxTokens: 12, overlapTokens: 4), + userBookId, userId, + [(Guid.NewGuid(), 0, ChapterOne), (Guid.NewGuid(), 1, ChapterOne)]); + + Assert.NotEmpty(rows); + Assert.All(rows, r => Assert.Equal(userId, r.UserId)); + Assert.All(rows, r => Assert.Equal(userBookId, r.UserBookId)); + } + + [Fact] + public void BuildUserRows_OffsetsSliceBackToChunkText() + { + var rows = BookChunkingService.BuildUserRows( + new Chunker(maxTokens: 12, overlapTokens: 4), + Guid.NewGuid(), Guid.NewGuid(), [(Guid.NewGuid(), 0, ChapterOne)]); + + foreach (var r in rows) + Assert.Equal(r.Text, ChapterOne.Substring(r.CharStart, r.CharEnd - r.CharStart)); + } + + [Fact] + public void BuildUserRows_EmptyChapterText_ProducesNoRows() + { + var rows = BookChunkingService.BuildUserRows( + new Chunker(), Guid.NewGuid(), Guid.NewGuid(), [(Guid.NewGuid(), 0, " ")]); + + Assert.Empty(rows); + } } diff --git a/tests/TextStack.UnitTests/RagContextServiceTests.cs b/tests/TextStack.UnitTests/RagContextServiceTests.cs index b507cd8a..00709d1b 100644 --- a/tests/TextStack.UnitTests/RagContextServiceTests.cs +++ b/tests/TextStack.UnitTests/RagContextServiceTests.cs @@ -33,6 +33,10 @@ public Task> RetrieveAsync( if (maxChapterOrd is null) GateWasNullOnAnyCall = true; return Task.FromResult>([]); } + + public Task> RetrieveUserBookAsync( + Guid userId, Guid userBookId, string query, int k, int? maxChapterOrd, CancellationToken ct) => + Task.FromResult>([]); } private static Mock> FakeSet(List data) where T : class diff --git a/tests/TextStack.UnitTests/RagServiceUserBookIsolationTests.cs b/tests/TextStack.UnitTests/RagServiceUserBookIsolationTests.cs new file mode 100644 index 00000000..eae9d13e --- /dev/null +++ b/tests/TextStack.UnitTests/RagServiceUserBookIsolationTests.cs @@ -0,0 +1,115 @@ +using TextStack.Ai.Rag; + +namespace TextStack.UnitTests; + +/// +/// Phase 2 per-user isolation guard. MUST scope its +/// retrieval to BOTH user_id and user_book_id — a user must never retrieve another +/// user's chunks. The retrieval runs raw SQL against pgvector (exercised end-to-end by integration +/// tests); here we lock the load-bearing SQL invariant (both isolation filters, on the isolated +/// table, in BOTH retrievers) so a regression that drops the user_id filter fails fast without a DB. +/// +public class RagServiceUserBookIsolationTests +{ + [Fact] + public void BuildUserBookSql_FiltersOnUserIdAndUserBookId_InBothRetrievers() + { + var sql = RagService.BuildUserBookSql(); + + // Isolated table — never the catalog chapter_chunk. + Assert.Contains("FROM user_chapter_chunk", sql); + Assert.DoesNotContain("FROM chapter_chunk", sql); + + // Both isolation predicates present (defense in depth: denormalized user_id AND user_book_id). + Assert.Contains("user_id = @userId", sql); + Assert.Contains("user_book_id = @userBookId", sql); + + // The filters must appear in BOTH the vector and the lexical retriever, not just one — a leak + // in either branch breaks isolation. The SQL is two statements; each must carry both filters. + var statements = sql.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.Equal(2, statements.Length); + Assert.All(statements, s => + { + Assert.Contains("user_id = @userId", s); + Assert.Contains("user_book_id = @userBookId", s); + }); + } + + [Fact] + public void BuildUserBookSql_SurfacesUserChapterIdAsChapterId_ForDeepLinks() + { + // Citations deep-link into the USER reader, so ChapterId must be the user_chapter_id. + var sql = RagService.BuildUserBookSql(); + Assert.Contains("user_chapter_id AS ChapterId", sql); + } + + [Fact] + public void BuildUserBookSql_IsolationFiltersAreBoundParams_NotInterpolated() + { + // The isolation boundary is only as strong as parameter binding — an interpolated id + // (string-concatenated) would be both an injection vector AND could be coaxed to a foreign + // id. Both filters must reference the @-prefixed Dapper params, and the SQL must contain no + // literal interpolation hole for the ids. + var sql = RagService.BuildUserBookSql(); + + Assert.Contains("user_id = @userId", sql); + Assert.Contains("user_book_id = @userBookId", sql); + + // No C#/SQL string interpolation braces leaked into the SQL (would mean an un-parameterized id). + Assert.DoesNotContain("{", sql); + Assert.DoesNotContain("}", sql); + + // The lexical branch must parameterize the user query into websearch_to_tsquery (not splice it). + Assert.Contains("websearch_to_tsquery('english', @query)", sql); + } + + [Fact] + public void BuildUserBookSql_BothBranches_GateChapterOrdButNeverDropIsolation() + { + // The optional chapter-ord ceiling is null for user books ("whole book"). Assert that the + // null gate is ANDed AFTER the isolation filters in BOTH statements — so a null ceiling can + // only ever widen WITHIN the one (user_id, user_book_id) scope, never across books/users. + var sql = RagService.BuildUserBookSql(); + var statements = sql.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + Assert.All(statements, s => + { + var userIdx = s.IndexOf("user_id = @userId", StringComparison.Ordinal); + var bookIdx = s.IndexOf("user_book_id = @userBookId", StringComparison.Ordinal); + var gateIdx = s.IndexOf("@maxChapterOrd", StringComparison.Ordinal); + + Assert.True(userIdx >= 0 && bookIdx >= 0, "both isolation filters present"); + // The ord gate must not be the first WHERE predicate without the isolation filters before it. + Assert.True(userIdx < gateIdx, "user_id filter precedes the (optional) chapter-ord gate"); + Assert.True(bookIdx < gateIdx, "user_book_id filter precedes the (optional) chapter-ord gate"); + }); + } + + [Fact] + public void CatalogRetrieveSql_NeverTouchesUserChunkTable_NoCrossCorpus() + { + // Cross-corpus guard: the user-book SQL must target ONLY user_chapter_chunk, and must never + // union/select the catalog chapter_chunk (which is NOT user-scoped). A regression that + // accidentally pointed user retrieval at chapter_chunk would leak every user's catalog-less + // assumptions AND drop the user_id filter (chapter_chunk has no user_id column). + var sql = RagService.BuildUserBookSql(); + + // Targets the isolated table exactly twice (vector + lexical), never the catalog table. + Assert.Equal(2, CountOccurrences(sql, "FROM user_chapter_chunk")); + Assert.DoesNotContain("FROM chapter_chunk", sql); + Assert.DoesNotContain("edition_id", sql); + Assert.DoesNotContain("UNION", sql); + } + + private static int CountOccurrences(string haystack, string needle) + { + var count = 0; + var i = 0; + while ((i = haystack.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) + { + count++; + i += needle.Length; + } + return count; + } +}