From 1c3be9528838c59eba69450b0f1722331077997d Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Thu, 18 Jun 2026 14:04:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(ai):=20DriftDetectionWorker=20+=20Drift=20?= =?UTF-8?q?tab=20=E2=80=94=20closes=20Phase=2012=20(AI-080,=20slice=205b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input-distribution drift detection — last Phase-12 DoD. - DriftDetectionWorker (Api BackgroundService, mirrors 5a): once/day per feature samples <=MaxSampleSize (50) recent prompts from llm_traces, embeds via IEmbeddingService, mean-pools a daily centroid, measures cosine drift (1-cosine) vs a rolling baseline of prior GOOD days. State machine (DriftCalculator, pure) ok->warning->alerting, emails admin once per streak when drift>=Threshold(0.15) for >=ConsecutiveDays(2). Cold start=baseline (never alerts); thin sample=insufficient. - drift_centroids table: idempotent unique (feature,day), vector(1536) centroid (never exposed), numeric(6,4) drift_score, string alert_state. - GET /admin/ai-quality/drift + read-only Drift tab: per-feature drift vs the 0.15 threshold line + alert badges + scheduled-eval trend (5a). - OFF by default (Drift:Enabled=false); cost-bounded by capped sample. Own advisory lock (unlock CancellationToken.None); never crashes host. - QA P1 fixed: rolling baseline excludes breaching days so sustained drift can't poison its own baseline; streak re-arms after recovery. architect -> backend+frontend -> adversarial QA (SHIP; P1 fixed). Migration AddDriftCentroids. 808 unit tests green (incl. non-tautological synthetic- regression DoD test); admin tsc+build clean. Phase 12 (RLOps) COMPLETE: shadow routing, admin viz, table-driven routing + promote/rollback, cost-aware routing + budgets, continuous eval, drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + apps/admin/src/api/client.ts | 33 + apps/admin/src/pages/AiQualityPage.tsx | 304 +- .../Api/Endpoints/AdminAiQualityEndpoints.cs | 28 + backend/src/Api/Program.cs | 2 + .../src/Api/Services/DriftDetectionWorker.cs | 353 ++ backend/src/Api/appsettings.json | 12 + backend/src/Application/Ai/DriftCalculator.cs | 99 + .../Common/Interfaces/IAppDbContext.cs | 1 + backend/src/Contracts/Admin/AiQualityDtos.cs | 11 + backend/src/Domain/Entities/DriftCentroid.cs | 53 + ...260618174658_AddDriftCentroids.Designer.cs | 5090 +++++++++++++++++ .../20260618174658_AddDriftCentroids.cs | 49 + .../Migrations/AppDbContextModelSnapshot.cs | 57 + .../Persistence/AppDbContext.Ai.cs | 23 + .../Persistence/AppDbContext.cs | 1 + tests/TextStack.AiEvals/CapturingDb.cs | 1 + .../AdminDriftEndpointTests.cs | 90 + .../DriftCalculatorTests.cs | 164 + .../DriftDetectionWorkerFlowTests.cs | 342 ++ .../DriftDetectionWorkerTests.cs | 79 + .../Fakes/FakeAppDbContext.cs | 98 + .../Fakes/FakeAsyncQuery.cs | 53 + tests/TextStack.UnitTests/Fakes/FakeDbSet.cs | 38 + 24 files changed, 6985 insertions(+), 2 deletions(-) create mode 100644 backend/src/Api/Services/DriftDetectionWorker.cs create mode 100644 backend/src/Application/Ai/DriftCalculator.cs create mode 100644 backend/src/Domain/Entities/DriftCentroid.cs create mode 100644 backend/src/Infrastructure/Migrations/20260618174658_AddDriftCentroids.Designer.cs create mode 100644 backend/src/Infrastructure/Migrations/20260618174658_AddDriftCentroids.cs create mode 100644 tests/TextStack.IntegrationTests/AdminDriftEndpointTests.cs create mode 100644 tests/TextStack.UnitTests/DriftCalculatorTests.cs create mode 100644 tests/TextStack.UnitTests/DriftDetectionWorkerFlowTests.cs create mode 100644 tests/TextStack.UnitTests/DriftDetectionWorkerTests.cs create mode 100644 tests/TextStack.UnitTests/Fakes/FakeAppDbContext.cs create mode 100644 tests/TextStack.UnitTests/Fakes/FakeAsyncQuery.cs create mode 100644 tests/TextStack.UnitTests/Fakes/FakeDbSet.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c3492b1f..9329532b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Phase 12 — DriftDetectionWorker + Drift tab — closes Phase 12 (AI-080, slice 5b) (2026-06-18) + +Input-distribution drift detection — the last Phase-12 DoD ("drift alert fires on a synthetic regression"). `DriftDetectionWorker` (Api host `BackgroundService`, mirrors 5a: startup delay + hourly check, own Postgres advisory lock released with `CancellationToken.None`, never crashes the host, **OFF by default** `Drift:Enabled=false`) once/day per configured feature: samples the most-recent ≤`MaxSampleSize` (50) prompts from `llm_traces` (last 24h, first user message), embeds them via `IEmbeddingService`, **mean-pools a daily centroid**, and measures **cosine drift** (`1 - cosine`) against a **rolling baseline** of the prior good days' centroids. A consecutive-day state machine (`DriftCalculator`, pure) escalates `ok → warning → alerting` and emails the admin **once per streak** when drift ≥ `Threshold` (0.15) for ≥`ConsecutiveDays` (2); cold start seeds a `baseline` row (never alerts), a thin sample writes `insufficient`. Persisted to a new **`drift_centroids`** table (idempotent unique `(feature, day)`, `vector(1536)` centroid, never exposed raw). New `GET /admin/ai-quality/drift` + a read-only **Drift tab** on the admin AI-quality page: per-feature drift charted against the 0.15 threshold line + alert-state badges + the scheduled-eval-score trend (from 5a). Cost-bounded by the capped sample (no `ISpendTracker` hook — `IEmbeddingService` exposes no cost). architect → backend + frontend (parallel) → adversarial QA (verdict SHIP; **P1 fixed**: the rolling baseline now excludes breaching days so a sustained drift can't poison its own baseline and the streak correctly re-arms after recovery). Migration `AddDriftCentroids`. 808 unit tests green (incl. a non-tautological synthetic-regression DoD test over a faithful in-memory DbContext); admin tsc + build clean. + +**Phase 12 (RLOps) complete** — shadow routing, admin visibility, table-driven routing + one-click promote/rollback, cost-aware routing + daily budgets, continuous eval, drift detection. Full MLOps for the LLM stack, in C#. + ### Phase 12 — ContinuousEvalWorker (AI-079, slice 5a) (2026-06-18) Automates the eval suite on a cadence so quality regressions on prod are caught without an admin clicking "run evals". `ContinuousEvalWorker` (Api host `BackgroundService`, ~10min startup delay + hourly check) runs `EvalSuiteRunner` for the configured features when due (no `run_type='scheduled'` row newer than `Eval:Scheduled:IntervalHours`, default 24h), persists with the new **`eval_runs.run_type`** column (`scheduled`/`manual`), and emails the admin (via `ResendEmailService`, no-op if unset) when a feature's score drops ≥ `RegressionDrop` (default 0.5 on the 1-5 scale) vs the **prior scheduled** run (`EvalRegressionDetector`, pure). **OFF by default** (`Eval:Scheduled:Enabled=false`) — it spends judge $ when on, so it also respects an optional `eval.judge` daily cap (fail-open). Concurrency-safe: the in-process overlap guard the admin trigger used is extracted into a shared singleton `IEvalRunGate` (so a scheduled run + an admin run can't collide), plus a **Postgres advisory lock** for multi-replica. The worker never crashes the host (every tick wrapped); the advisory lock is released with `CancellationToken.None` so host shutdown can't leak it onto a pooled connection (QA P1); the admin trigger releases the gate even on a synchronous setup failure (QA P2). New `GET /admin/ai-quality/drift/eval-trend` exposes the scheduled-only score trend (consumed by the Drift tab in slice 5b). Migration `AddEvalRunType` (backfills existing rows to `manual`). 780 unit tests green. Slice 5b (DriftDetectionWorker + Drift tab) follows. diff --git a/apps/admin/src/api/client.ts b/apps/admin/src/api/client.ts index 4e0b8381..f79a288d 100644 --- a/apps/admin/src/api/client.ts +++ b/apps/admin/src/api/client.ts @@ -613,6 +613,23 @@ export interface ShadowSamplesPage { total: number items: ShadowSample[] } +// Drift detection (RLOps) +export type DriftAlertState = 'baseline' | 'ok' | 'warning' | 'alerting' | 'insufficient' +export interface DriftPoint { + feature: string + day: string // date "YYYY-MM-DD" + driftScore: number | null + sampleSize: number + alertState: DriftAlertState +} +export interface ScheduledEvalPoint { + feature: string + modelId: string + score: number + n: number + gitSha: string + createdAt: string +} // Model registry export interface ModelRegistration { id: string @@ -1299,6 +1316,22 @@ export const adminApi = { return fetchJson(`/admin/ai-quality/shadow/samples${qs ? `?${qs}` : ''}`) }, + getDrift: async (params?: { feature?: string; days?: number }): Promise => { + const query = new URLSearchParams() + if (params?.feature) query.set('feature', params.feature) + if (params?.days) query.set('days', String(params.days)) + const qs = query.toString() + return fetchJson(`/admin/ai-quality/drift${qs ? `?${qs}` : ''}`) + }, + + getEvalTrend: async (params?: { feature?: string; limit?: number }): Promise => { + const query = new URLSearchParams() + if (params?.feature) query.set('feature', params.feature) + if (params?.limit) query.set('limit', String(params.limit)) + const qs = query.toString() + return fetchJson(`/admin/ai-quality/drift/eval-trend${qs ? `?${qs}` : ''}`) + }, + getModels: async (): Promise => { return fetchJson('/admin/ai-quality/models') }, diff --git a/apps/admin/src/pages/AiQualityPage.tsx b/apps/admin/src/pages/AiQualityPage.tsx index 163b6961..3a18d109 100644 --- a/apps/admin/src/pages/AiQualityPage.tsx +++ b/apps/admin/src/pages/AiQualityPage.tsx @@ -17,9 +17,12 @@ import { ShadowSample, ModelRegistration, ModelPromotionResult, + DriftPoint, + DriftAlertState, + ScheduledEvalPoint, } from '../api/client' -type Tab = 'summary' | 'traces' | 'transcripts' | 'evals' | 'shadow' | 'models' +type Tab = 'summary' | 'traces' | 'transcripts' | 'evals' | 'shadow' | 'models' | 'drift' const KNOWN_FEATURES = ['explain', 'translate', 'distractor', 'bookmeta', 'tagsuggestion', 'eval.judge'] @@ -29,7 +32,7 @@ export function AiQualityPage() {

AI Quality

- {(['summary', 'traces', 'transcripts', 'evals', 'shadow', 'models'] as Tab[]).map((t) => ( + {(['summary', 'traces', 'transcripts', 'evals', 'shadow', 'models', 'drift'] as Tab[]).map((t) => (
) } @@ -1413,6 +1417,302 @@ function ModelConfirmDialog({ ) } +// ─────────────────────────── Drift ─────────────────────────── + +const DRIFT_THRESHOLD = 0.15 + +function driftStateColor(state: DriftAlertState): string { + if (state === 'alerting') return '#dc2626' // red + if (state === 'warning') return '#d97706' // amber + if (state === 'ok') return '#059669' // green + return '#9ca3af' // baseline / insufficient — grey/muted +} + +function DriftBadge({ state }: { state: DriftAlertState }) { + const color = driftStateColor(state) + const muted = state === 'baseline' || state === 'insufficient' + return ( + + {state} + + ) +} + +// Inline drift-over-time line, daily driftScore vs the 0.15 alert threshold. +function DriftChart({ points }: { points: DriftPoint[] }) { + const w = 640 + const h = 120 + const padX = 8 + const padY = 8 + const scored = points.filter((p) => p.driftScore != null) + if (scored.length < 2) { + return ( +

+ Not enough scored days to chart yet (need ≥ 2). See the table below. +

+ ) + } + const vals = scored.map((p) => p.driftScore as number) + const maxVal = Math.max(...vals, DRIFT_THRESHOLD * 1.2) + const minVal = 0 + const range = maxVal - minVal || 1 + const step = points.length > 1 ? (w - padX * 2) / (points.length - 1) : 0 + const y = (v: number) => h - padY - ((v - minVal) / range) * (h - padY * 2) + const x = (i: number) => padX + i * step + const thresholdY = y(DRIFT_THRESHOLD) + + // Build the polyline only over days that have a score (skip nulls in coords). + const coords = points + .map((p, i) => (p.driftScore == null ? null : `${x(i).toFixed(1)},${y(p.driftScore).toFixed(1)}`)) + .filter((c): c is string => c != null) + .join(' ') + + return ( + + {/* threshold line at 0.15 — the alert line, bold red dashed */} + + + {points.map((p, i) => + p.driftScore == null ? null : ( + + {`${p.day}: ${p.driftScore.toFixed(3)} (${p.alertState}, n=${p.sampleSize})`} + + ), + )} + + ) +} + +function DriftTab() { + const [points, setPoints] = useState([]) + const [feature, setFeature] = useState('') + const [days, setDays] = useState(30) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + setLoading(true) + adminApi + .getDrift({ feature: feature || undefined, days }) + .then((d) => { + setPoints(d) + setError(null) + }) + .catch((e) => setError(e instanceof Error ? e.message : 'Failed to load')) + .finally(() => setLoading(false)) + }, [feature, days]) + + // Feature options: union of KNOWN_FEATURES and any features in the response. + const featureOptions = [...new Set([...KNOWN_FEATURES, ...points.map((p) => p.feature)])].sort() + const alertingDays = points.filter((p) => p.alertState === 'alerting').length + + return ( + <> +
+
+ +
+
+ {RANGES.map((r) => ( + + ))} +
+
+ +

+ Daily output-drift score per feature vs a 0.15 alert threshold. Higher = more divergence from the rolling baseline. +

+ + {error && } + + {loading ? ( +

Loading…

+ ) : points.length === 0 ? ( +

+ Drift detection is OFF by default — enable Drift:Enabled to start sampling. +

+ ) : ( + <> + {alertingDays > 0 && ( +
+ ⚠ {alertingDays} day{alertingDays === 1 ? '' : 's'} over the 0.15 drift threshold. +
+ )} + + + + + + {feature === '' && } + + + + + + + {points.map((p) => { + const over = p.driftScore != null && p.driftScore >= DRIFT_THRESHOLD + return ( + + + {feature === '' && } + + + + + ) + })} + +
DayFeatureDrift scoreSample sizeState
{p.day}{p.feature} + {p.driftScore != null ? p.driftScore.toFixed(3) : '—'} + {over && } + {p.sampleSize} + +
+ + )} + + + + ) +} + +function DriftEvalTrend({ feature }: { feature: string }) { + const [points, setPoints] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + setLoading(true) + adminApi + .getEvalTrend({ feature: feature || undefined }) + .then((d) => { + setPoints(d) + setError(null) + }) + .catch((e) => setError(e instanceof Error ? e.message : 'Failed to load')) + .finally(() => setLoading(false)) + }, [feature]) + + return ( +
+

Scheduled eval trend

+

+ Latest scheduled eval scores (1–5) over time — the quality signal drift is meant to catch early. +

+ + {error && } + + {loading ? ( +

Loading…

+ ) : points.length === 0 ? ( +

No scheduled eval runs yet — enable Eval:Scheduled:Enabled.

+ ) : ( + <> + + + + + + {feature === '' && } + + + + + + + + {points.map((p, i) => { + const prev = points[i + 1] + const regressed = prev && prev.feature === p.feature && p.score < prev.score - 0.1 + return ( + + + {feature === '' && } + + + + + + ) + })} + +
WhenFeatureScoreModelNGit SHA
{timeAgo(p.createdAt)}{p.feature} + {p.score.toFixed(2)} + {regressed && } + {p.modelId}{p.n} + {p.gitSha ? p.gitSha.slice(0, 7) : '—'} +
+ + )} +
+ ) +} + +// Eval score sparkline (1–5). API returns newest-first, so render reversed (oldest → newest). +function EvalSparkline({ points }: { points: ScheduledEvalPoint[] }) { + const w = 640 + const h = 80 + const pad = 6 + const ordered = [...points].reverse() + if (ordered.length < 2) return null + const min = 1 + const max = 5 + const range = max - min + const step = (w - pad * 2) / (ordered.length - 1) + const y = (v: number) => h - pad - ((Math.min(max, Math.max(min, v)) - min) / range) * (h - pad * 2) + const coords = ordered.map((p, i) => `${(pad + i * step).toFixed(1)},${y(p.score).toFixed(1)}`).join(' ') + return ( + + + {ordered.map((p, i) => ( + + {`${p.score.toFixed(2)} · ${p.feature} · ${new Date(p.createdAt).toLocaleString()}`} + + ))} + + ) +} + // ─────────────────────────── shared ─────────────────────────── function Pager({ diff --git a/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs b/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs index 3a159393..3d1b24bd 100644 --- a/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs +++ b/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs @@ -30,6 +30,7 @@ public static void MapAdminAiQualityEndpoints(this WebApplication app) group.MapGet("/agent-runs/{id:guid}", GetAgentRun); group.MapGet("/evals", GetEvals); group.MapGet("/drift/eval-trend", GetEvalTrend); + group.MapGet("/drift", GetDrift); group.MapPost("/evals/run", RunEvals); group.MapGet("/evals/status", GetEvalStatus); group.MapPost("/evals/toolcalls/run", RunToolCallEval); @@ -465,6 +466,33 @@ private static async Task GetEvalTrend( return Results.Ok((IReadOnlyList)points); } + // Phase 12 RLOps slice 5b: per-day input-drift series for the Drift tab. drift_centroids rows + // (optional feature filter), Day >= today-days, oldest-first for charting. Never returns the + // raw centroid vectors. Admin-auth inherited from the /admin/* middleware. + private static async Task GetDrift( + AppDbContext db, + [FromQuery] string? feature, + [FromQuery] int days = 30, + CancellationToken ct = default) + { + days = Math.Clamp(days, 1, 365); + var since = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-days); + + var query = db.DriftCentroids.Where(d => d.Day >= since); + if (!string.IsNullOrWhiteSpace(feature)) + { + var feat = feature.Trim(); + query = query.Where(d => d.Feature == feat); + } + + var points = await query + .OrderBy(d => d.Day) + .Select(d => new DriftPointDto(d.Feature, d.Day, d.DriftScore, d.SampleSize, d.AlertState)) + .ToListAsync(ct); + + return Results.Ok((IReadOnlyList)points); + } + private static async Task GetSummary( AppDbContext db, [FromQuery] DateTimeOffset? from, diff --git a/backend/src/Api/Program.cs b/backend/src/Api/Program.cs index 0d613d31..08a137ab 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -228,6 +228,8 @@ builder.Services.AddHostedService(); // Phase 12 RLOps slice 5a: scheduled continuous evals (OFF by default — Eval:Scheduled:Enabled). builder.Services.AddHostedService(); +// Phase 12 RLOps slice 5b: embedding-drift detection (OFF by default — Drift:Enabled). +builder.Services.AddHostedService(); // Rate limiting builder.Services.AddRateLimiter(options => diff --git a/backend/src/Api/Services/DriftDetectionWorker.cs b/backend/src/Api/Services/DriftDetectionWorker.cs new file mode 100644 index 00000000..8ea0efba --- /dev/null +++ b/backend/src/Api/Services/DriftDetectionWorker.cs @@ -0,0 +1,353 @@ +using System.Data.Common; +using System.Text.Json; +using Application.Ai; +using Application.Auth; +using Application.Common.Interfaces; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using TextStack.Ai.Core; + +namespace Api.Services; + +/// +/// Phase 12 RLOps slice 5b: embedding-drift detection. Periodically (default daily) samples a +/// feature's recent llm_traces prompts, embeds them into a daily centroid (mean-pooled), +/// measures cosine drift against the rolling baseline of prior daily centroids, and emails an +/// admin alert after N consecutive breaches. Persists one drift_centroids row per +/// (feature, day) — the unique index makes a same-day re-run idempotent and doubles as the +/// "is it due?" probe. +/// +/// OFF by default (Drift:Enabled=false) — it spends embedding $ when enabled. The bounded +/// daily sample (Drift:MaxSampleSize) is the cost control. Mirrors +/// : Api-host BackgroundService, ~10min startup delay, hourly +/// check, its OWN Postgres advisory lock (multi-replica safe), never crashes the host (every tick +/// wrapped; the lock is released under so shutdown can't leak it). +/// +public sealed class DriftDetectionWorker( + IServiceScopeFactory scopeFactory, + IConfiguration config, + ILogger logger) : BackgroundService +{ + // Fixed advisory-lock key — DIFFERENT from ContinuousEvalWorker's so the two workers never + // contend with each other; all replicas contend on this one key for the drift sweep. + private const long AdvisoryLockKey = 0x7E5C_0D11_FL; // "TeSC DrIF"-ish marker + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Drift detection worker started"); + + // Startup delay so a cold boot doesn't immediately fire embeddings (mirror other workers). + try { await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken); } + catch (OperationCanceledException) { return; } + + var checkHours = Math.Max(config.GetValue("Drift:CheckIntervalHours", 1), 1); + using var timer = new PeriodicTimer(TimeSpan.FromHours(checkHours)); + + do + { + try + { + await TickAsync(stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Drift detection tick failed"); + } + } + while (await WaitNextAsync(timer, stoppingToken)); + } + + private static async Task WaitNextAsync(PeriodicTimer timer, CancellationToken ct) + { + try { return await timer.WaitForNextTickAsync(ct); } + catch (OperationCanceledException) { return false; } + } + + // internal (not private) so a unit test can drive a single tick and prove the disabled + // short-circuit does zero DB/scope/lock work. + internal async Task TickAsync(CancellationToken ct) + { + if (!config.GetValue("Drift:Enabled", false)) + return; + + using var scope = scopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + var db = sp.GetRequiredService(); + + var features = config.GetSection("Drift:Features").Get() ?? ["explain"]; + if (features.Length == 0) + return; + + DbConnection? conn = null; + var advisoryHeld = false; + try + { + // Cross-replica guard: only one replica runs the drift sweep per tick. + conn = await OpenConnectionAsync(db, ct); + advisoryHeld = await TryAdvisoryLockAsync(conn, ct); + if (!advisoryHeld) + { + logger.LogInformation("Drift sweep skipped: advisory lock held by another replica"); + return; + } + + var embedder = sp.GetRequiredService(); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + foreach (var feature in features) + { + // Per-feature isolation: one feature's failure must not skip the others. + try + { + await ProcessFeatureAsync(sp, db, embedder, feature, today, DateTimeOffset.UtcNow, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Drift detection failed for feature {Feature}", feature); + } + } + } + finally + { + // Release the session-level advisory lock on the SAME connection that took it. Use + // CancellationToken.None (NOT the tick token, which may be cancelled on shutdown) so the + // unlock SQL always runs — otherwise the lock leaks onto the pooled connection. + if (advisoryHeld && conn is not null) + await UnlockAsync(conn); + } + } + + /// + /// Compute + persist one feature's drift row for (UTC). Idempotent: + /// returns immediately if a row already exists for (feature, today). Public so an integration + /// test can drive it directly with a fake embedder instead of the full timer. + /// + public async Task ProcessFeatureAsync( + IServiceProvider sp, + IAppDbContext db, + IEmbeddingService embedder, + string feature, + DateOnly today, + DateTimeOffset now, + CancellationToken ct) + { + // Idempotency: skip features that already have today's row (also the "is it due?" check). + var alreadyToday = await db.DriftCentroids + .AnyAsync(d => d.Feature == feature && d.Day == today, ct); + if (alreadyToday) + return; + + var maxSample = Math.Max(1, config.GetValue("Drift:MaxSampleSize", 50)); + var minSample = Math.Max(1, config.GetValue("Drift:MinSampleSize", 10)); + var threshold = config.GetValue("Drift:Threshold", 0.15); + var consecutiveDays = Math.Max(1, config.GetValue("Drift:ConsecutiveDays", 2)); + var baselineWindow = Math.Max(1, config.GetValue("Drift:BaselineWindowDays", 7)); + + // 1. Sample: newest successful traces in the last 24h for this feature. + var since = now - TimeSpan.FromHours(24); + var rawMessages = await db.LlmTraces + .Where(t => t.FeatureTag == feature && t.CreatedAt >= since && t.Error == null) + .OrderByDescending(t => t.CreatedAt) + .Take(maxSample) + .Select(t => t.MessagesJson) + .ToListAsync(ct); + + var prompts = rawMessages + .Select(FirstUserMessage) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s!) + .ToList(); + + if (prompts.Count < minSample) + { + // Too few samples to trust a centroid — record an `insufficient` row and skip the math. + db.DriftCentroids.Add(new DriftCentroid + { + Id = Guid.NewGuid(), + Feature = feature, + Day = today, + Centroid = null, + SampleSize = prompts.Count, + DriftScore = null, + ConsecutiveBreaches = 0, + AlertState = "insufficient", + CreatedAt = now, + }); + await db.SaveChangesAsync(ct); + logger.LogInformation( + "Drift {Feature}: insufficient sample ({Count} < {Min})", feature, prompts.Count, minSample); + return; + } + + // 2. Embed each sampled prompt → today's centroid. + var vectors = new List(prompts.Count); + foreach (var p in prompts) + vectors.Add(await embedder.EmbedAsync(p, ct)); + var todayCentroid = DriftCalculator.MeanPool(vectors); + + // 3. Baseline = mean-pool of the N most-recent KNOWN-GOOD prior daily centroids (EXCLUDING + // today; only rows with a centroid; EXCLUDING breaching `warning`/`alerting` days). The + // `Take(N)` is applied AFTER filtering out breaching days, so it reaches back PAST a drift + // to the last good days — a sustained drift never poisons or re-seeds its own baseline, and + // the streak can still reset (so the spec's "re-arm after reset" is reachable). No good + // prior at all (true cold start) → seed a `baseline` row (drift 0, never alerts). + var priorCentroids = await db.DriftCentroids + .Where(d => d.Feature == feature + && d.Day < today + && d.Centroid != null + && d.AlertState != "warning" + && d.AlertState != "alerting") + .OrderByDescending(d => d.Day) + .Take(baselineWindow) + .Select(d => d.Centroid!) + .ToListAsync(ct); + + if (priorCentroids.Count == 0) + { + db.DriftCentroids.Add(new DriftCentroid + { + Id = Guid.NewGuid(), + Feature = feature, + Day = today, + Centroid = todayCentroid, + SampleSize = prompts.Count, + DriftScore = 0, + ConsecutiveBreaches = 0, + AlertState = "baseline", + CreatedAt = now, + }); + await db.SaveChangesAsync(ct); + logger.LogInformation("Drift {Feature}: baseline seeded ({Count} samples)", feature, prompts.Count); + return; + } + + var baseline = DriftCalculator.MeanPool(priorCentroids); + var driftScore = DriftCalculator.CosineDrift(todayCentroid, baseline); + + // 4. Load the prior row's streak state and run the state machine. + var priorRow = await db.DriftCentroids + .Where(d => d.Feature == feature && d.Day < today) + .OrderByDescending(d => d.Day) + .Select(d => new { d.ConsecutiveBreaches, d.AlertState }) + .FirstOrDefaultAsync(ct); + + var priorBreaches = priorRow?.ConsecutiveBreaches ?? 0; + var alreadyAlerted = priorRow?.AlertState == "alerting"; + + var decision = DriftCalculator.Decide( + priorBreaches, driftScore, threshold, consecutiveDays, alreadyAlerted); + + var row = new DriftCentroid + { + Id = Guid.NewGuid(), + Feature = feature, + Day = today, + Centroid = todayCentroid, + SampleSize = prompts.Count, + DriftScore = driftScore, + ConsecutiveBreaches = decision.ConsecutiveBreaches, + AlertState = decision.AlertState, + AlertedAt = decision.ShouldAlert ? now : null, + CreatedAt = now, + }; + db.DriftCentroids.Add(row); + await db.SaveChangesAsync(ct); + + logger.LogInformation( + "Drift {Feature}: score {Score:F4} state {State} breaches {Breaches}", + feature, driftScore, decision.AlertState, decision.ConsecutiveBreaches); + + // 5. Fire-and-forget alert on the moment a streak first reaches alerting. Swallow+log. + if (decision.ShouldAlert) + { + try + { + var email = sp.GetRequiredService(); + var subject = $"[TextStack] AI input drift: {feature} (drift {driftScore:0.000})"; + var body = + $"

Embedding drift detected for feature {feature}.

" + + $"
  • Drift score: {driftScore:0.000} (threshold {threshold:0.000})
  • " + + $"
  • Consecutive breaching days: {decision.ConsecutiveBreaches}
  • " + + $"
  • Sample size: {prompts.Count}
" + + "

Input distribution has shifted from the rolling baseline — review the feature's recent prompts.

"; + await email.SendAdminAlertAsync(subject, body, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send drift alert for feature {Feature}", feature); + } + } + } + + /// Extracts the first "user" message's Content from a serialized + /// LlmMessage[] (the llm_traces.MessagesJson shape). Null if absent/unparseable — + /// a malformed trace is skipped, not fatal. + internal static string? FirstUserMessage(string messagesJson) + { + try + { + using var doc = JsonDocument.Parse(messagesJson); + if (doc.RootElement.ValueKind != JsonValueKind.Array) + return null; + + foreach (var msg in doc.RootElement.EnumerateArray()) + { + if (msg.ValueKind != JsonValueKind.Object) + continue; + if (msg.TryGetProperty("Role", out var role) + && role.ValueKind == JsonValueKind.String + && string.Equals(role.GetString(), "user", StringComparison.OrdinalIgnoreCase) + && msg.TryGetProperty("Content", out var content) + && content.ValueKind == JsonValueKind.String) + { + return content.GetString(); + } + } + } + catch (JsonException) + { + // Malformed trace — skip it. + } + return null; + } + + private static async Task OpenConnectionAsync(IAppDbContext db, CancellationToken ct) + { + var conn = db.Database.GetDbConnection(); + if (conn.State != System.Data.ConnectionState.Open) + await conn.OpenAsync(ct); + return conn; + } + + private static async Task TryAdvisoryLockAsync(DbConnection conn, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT pg_try_advisory_lock(@key)"; + var p = cmd.CreateParameter(); + p.ParameterName = "key"; + p.Value = AdvisoryLockKey; + cmd.Parameters.Add(p); + var result = await cmd.ExecuteScalarAsync(ct); + return result is bool b && b; + } + + private static async Task UnlockAsync(DbConnection conn) + { + try + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT pg_advisory_unlock(@key)"; + var p = cmd.CreateParameter(); + p.ParameterName = "key"; + p.Value = AdvisoryLockKey; + cmd.Parameters.Add(p); + // CancellationToken.None on purpose: releasing the lock must not be cancellable by the + // shutdown token, or the lock leaks onto the pooled connection. + await cmd.ExecuteScalarAsync(CancellationToken.None); + } + catch + { + // Connection drop releases the session lock anyway; best-effort unlock. + } + } +} diff --git a/backend/src/Api/appsettings.json b/backend/src/Api/appsettings.json index 08cf4e21..3f4025a6 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -45,6 +45,18 @@ "DailyBudgetUsd": 0 } }, + "Drift": { + "__comment": "OFF by default; spends embedding $ when enabled (DriftDetectionWorker, slice 5b).", + "Enabled": false, + "CheckIntervalHours": 1, + "IntervalHours": 24, + "Features": [ "explain" ], + "MaxSampleSize": 50, + "MinSampleSize": 10, + "Threshold": 0.15, + "ConsecutiveDays": 2, + "BaselineWindowDays": 7 + }, "Translate": { "CachePath": "data/translate-cache", "CacheTtlDays": 30 diff --git a/backend/src/Application/Ai/DriftCalculator.cs b/backend/src/Application/Ai/DriftCalculator.cs new file mode 100644 index 00000000..d2c645a6 --- /dev/null +++ b/backend/src/Application/Ai/DriftCalculator.cs @@ -0,0 +1,99 @@ +namespace Application.Ai; + +/// Result of the drift alert state machine for one day's centroid. +/// New running count of consecutive breaching days. +/// "ok" | "warning" | "alerting". +/// True only the moment a streak first reaches alerting +/// AND no alert has already fired for this streak (debounce — alert once per streak). +public readonly record struct DriftDecision( + int ConsecutiveBreaches, + string AlertState, + bool ShouldAlert); + +/// +/// Pure embedding-drift math (Phase 12 RLOps slice 5b). No EF, no embeddings, no config — every +/// member is a static pure function so the whole thing is unit-testable. The DriftDetectionWorker +/// supplies the vectors (sampled + embedded prompts) and persistence; this just does the algebra: +/// mean-pool a day's vectors into a centroid, cosine-drift today vs the baseline, and run the +/// consecutive-breach alert state machine. +/// +public static class DriftCalculator +{ + /// Element-wise mean of the vectors (the day's centroid). Empty input → empty array. + /// Mixed lengths are clamped to the shortest (defensive — all embeddings are 1536-d in practice). + public static float[] MeanPool(IReadOnlyList vectors) + { + if (vectors.Count == 0) + return []; + + var dim = int.MaxValue; + foreach (var v in vectors) + dim = Math.Min(dim, v.Length); + if (dim == 0) + return []; + + var sum = new double[dim]; + foreach (var v in vectors) + for (var i = 0; i < dim; i++) + sum[i] += v[i]; + + var mean = new float[dim]; + for (var i = 0; i < dim; i++) + mean[i] = (float)(sum[i] / vectors.Count); + return mean; + } + + /// Cosine drift = 1 − cosine similarity. Identical vectors → 0, orthogonal → 1, + /// opposite → 2. Returns 0 if either vector is zero-norm or lengths mismatch (no signal, + /// fail-safe so a degenerate centroid never raises a false alert). + public static double CosineDrift(float[] a, float[] b) + { + if (a.Length == 0 || a.Length != b.Length) + return 0; + + double dot = 0, normA = 0, normB = 0; + for (var i = 0; i < a.Length; i++) + { + dot += (double)a[i] * b[i]; + normA += (double)a[i] * a[i]; + normB += (double)b[i] * b[i]; + } + + if (normA <= 0 || normB <= 0) + return 0; + + var cosine = dot / (Math.Sqrt(normA) * Math.Sqrt(normB)); + return 1.0 - cosine; + } + + /// + /// Consecutive-breach alert state machine. A day breaches when its + /// is at or above ; that increments the streak (else it resets to 0). + /// State is alerting once the streak reaches , + /// warning on any active streak (≥1), else ok. An alert fires only the moment the + /// streak first crosses into alerting and no alert has already fired for it + /// () — so a sustained drift pages once, not daily. + /// + public static DriftDecision Decide( + int priorConsecutiveBreaches, + double driftScore, + double threshold, + int consecutiveDaysToAlert, + bool alreadyAlertedThisStreak) + { + var breaching = driftScore >= threshold; + var breaches = breaching ? priorConsecutiveBreaches + 1 : 0; + var daysToAlert = Math.Max(1, consecutiveDaysToAlert); + + string state; + if (breaches >= daysToAlert) + state = "alerting"; + else if (breaches >= 1) + state = "warning"; + else + state = "ok"; + + var shouldAlert = state == "alerting" && !alreadyAlertedThisStreak; + return new DriftDecision(breaches, state, shouldAlert); + } +} diff --git a/backend/src/Application/Common/Interfaces/IAppDbContext.cs b/backend/src/Application/Common/Interfaces/IAppDbContext.cs index 16fdb326..80494ed2 100644 --- a/backend/src/Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/Application/Common/Interfaces/IAppDbContext.cs @@ -64,6 +64,7 @@ public interface IAppDbContext DbSet ModelPromotions { get; } DbSet EvalRuns { get; } DbSet AgentRuns { get; } + DbSet DriftCentroids { get; } DbSet PodcastGenerationJobs { get; } Task SaveChangesAsync(CancellationToken ct = default); diff --git a/backend/src/Contracts/Admin/AiQualityDtos.cs b/backend/src/Contracts/Admin/AiQualityDtos.cs index a71f6b6d..09d74b8b 100644 --- a/backend/src/Contracts/Admin/AiQualityDtos.cs +++ b/backend/src/Contracts/Admin/AiQualityDtos.cs @@ -126,6 +126,17 @@ public record ScheduledEvalPointDto( string GitSha, DateTimeOffset CreatedAt); +/// One day's drift point for the Drift tab (Phase 12 RLOps slice 5b), from +/// drift_centroids. Day-ordered; the raw centroid vector is intentionally NOT exposed. +/// DriftScore is null on an insufficient row, 0 on the seed baseline row. +/// AlertState: baseline | ok | warning | alerting | insufficient. +public record DriftPointDto( + string Feature, + DateOnly Day, + double? DriftScore, + int SampleSize, + string AlertState); + // ── Shadow-run comparison + models registry (Phase 12 RLOps) ────────────────── /// One primary↔shadow pairing rolled up over the window (from shadow_runs). diff --git a/backend/src/Domain/Entities/DriftCentroid.cs b/backend/src/Domain/Entities/DriftCentroid.cs new file mode 100644 index 00000000..e10b3eae --- /dev/null +++ b/backend/src/Domain/Entities/DriftCentroid.cs @@ -0,0 +1,53 @@ +namespace Domain.Entities; + +/// +/// One day's embedding centroid for a single AI feature (table drift_centroids), +/// written by the Phase 12 RLOps DriftDetectionWorker (slice 5b). The worker samples the +/// feature's recent llm_traces user prompts, embeds them, mean-pools to a daily +/// , then measures cosine drift against the rolling baseline of prior +/// daily centroids and emails an admin alert after N consecutive breaches. +/// +/// One row per (, ) — the unique index makes a same-day +/// re-run idempotent and doubles as the "is it due?" check (no row for today = due). Plain POCO; +/// EF mapping (incl. the pgvector vector(1536) column) lives in AppDbContext.Ai.cs. +/// +public class DriftCentroid +{ + public Guid Id { get; set; } + + /// The AI feature this centroid is for (matches llm_traces.feature_tag). + public required string Feature { get; set; } + + /// The UTC day this centroid summarizes (one row per feature per day). + public DateOnly Day { get; set; } + + /// + /// Mean-pooled embedding of the day's sampled prompts (1536-d, OpenAI + /// text-embedding-3-small). Modeled as float[] to keep Domain framework-free; + /// mapped to pgvector vector(1536) in EF. Null on a insufficient row + /// (too few samples to compute a centroid). + /// + public float[]? Centroid { get; set; } + + /// How many prompts were sampled for this centroid. + public int SampleSize { get; set; } + + /// Cosine drift (1 − cosine similarity) of today's centroid vs the baseline. + /// Null when no math ran (insufficient sample). 0 on the seed/baseline row. + public double? DriftScore { get; set; } + + /// Running count of consecutive days the drift breached the threshold (resets to 0 + /// on a non-breaching day). Drives the consecutive-days-to-alert gate. + public int ConsecutiveBreaches { get; set; } + + /// Alert state for this day: baseline (seed, no priors) | ok | + /// warning (≥1 breach, not yet alerting) | alerting (≥N consecutive breaches) | + /// insufficient (too few samples). + public required string AlertState { get; set; } + + /// When an admin alert was sent for this row (only set when the row crossed into + /// alerting and fired). Null otherwise. + public DateTimeOffset? AlertedAt { get; set; } + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/src/Infrastructure/Migrations/20260618174658_AddDriftCentroids.Designer.cs b/backend/src/Infrastructure/Migrations/20260618174658_AddDriftCentroids.Designer.cs new file mode 100644 index 00000000..f9967b25 --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260618174658_AddDriftCentroids.Designer.cs @@ -0,0 +1,5090 @@ +// +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("20260618174658_AddDriftCentroids")] + partial class AddDriftCentroids + { + /// + 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("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("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("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("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", "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.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.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/20260618174658_AddDriftCentroids.cs b/backend/src/Infrastructure/Migrations/20260618174658_AddDriftCentroids.cs new file mode 100644 index 00000000..1f375754 --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260618174658_AddDriftCentroids.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Pgvector; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddDriftCentroids : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "drift_centroids", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + feature = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + day = table.Column(type: "date", nullable: false), + centroid = table.Column(type: "vector(1536)", nullable: true), + sample_size = table.Column(type: "integer", nullable: false), + drift_score = table.Column(type: "numeric(6,4)", nullable: true), + consecutive_breaches = table.Column(type: "integer", nullable: false), + alert_state = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + alerted_at = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_drift_centroids", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_drift_centroids_feature_day", + table: "drift_centroids", + columns: new[] { "feature", "day" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "drift_centroids"); + } + } +} diff --git a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 6149691e..a1b4f057 100644 --- a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -891,6 +891,63 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs index 4f990add..7b6bf4dd 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs @@ -1,5 +1,6 @@ using Domain.Entities; using Microsoft.EntityFrameworkCore; +using Pgvector; namespace Infrastructure.Persistence; @@ -108,5 +109,27 @@ private static void ConfigureAi(ModelBuilder modelBuilder) // via the migration default; the Drift tab + worker filter on RunType='scheduled'. e.Property(x => x.RunType).HasMaxLength(20).HasDefaultValue("manual"); }); + + modelBuilder.Entity(e => + { + // One row per (feature, day): makes a same-day re-run idempotent AND doubles as the + // worker's "is it due?" probe (no row for today's UTC date = due). + e.HasIndex(x => new { x.Feature, x.Day }).IsUnique(); + + e.Property(x => x.Feature).HasMaxLength(64); + + // float[] (framework-free Domain) <-> pgvector vector(1536), mirroring + // chapter_chunk.embedding. Nullable: an `insufficient` row has no centroid. + e.Property(x => x.Centroid) + .HasColumnType("vector(1536)") + .HasConversion( + v => v == null ? null : new Vector(v), + v => v == null ? null : v.ToArray()); + + // baseline | ok | warning | alerting | insufficient — stored as a plain string (no enum). + e.Property(x => x.AlertState).HasMaxLength(20); + + e.Property(x => x.DriftScore).HasColumnType("numeric(6,4)"); + }); } } diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.cs b/backend/src/Infrastructure/Persistence/AppDbContext.cs index 647bf890..bfa42f4a 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.cs @@ -85,6 +85,7 @@ public Task BeginTransactionAsync(CancellationToken ct = public DbSet ModelPromotions => Set(); public DbSet EvalRuns => Set(); public DbSet AgentRuns => Set(); + public DbSet DriftCentroids => Set(); public DbSet PodcastGenerationJobs => Set(); // Phase 4 RAG. Intentionally not on IAppDbContext — retrieval uses raw Npgsql. diff --git a/tests/TextStack.AiEvals/CapturingDb.cs b/tests/TextStack.AiEvals/CapturingDb.cs index 6d3d2d3a..5d70235b 100644 --- a/tests/TextStack.AiEvals/CapturingDb.cs +++ b/tests/TextStack.AiEvals/CapturingDb.cs @@ -99,5 +99,6 @@ public override EntityEntry Add(EvalRun entity) public DbSet ShadowRuns => throw new NotSupportedException(); public DbSet Models => throw new NotSupportedException(); public DbSet ModelPromotions => throw new NotSupportedException(); + public DbSet DriftCentroids => throw new NotSupportedException(); public DbSet PodcastGenerationJobs => throw new NotSupportedException(); } diff --git a/tests/TextStack.IntegrationTests/AdminDriftEndpointTests.cs b/tests/TextStack.IntegrationTests/AdminDriftEndpointTests.cs new file mode 100644 index 00000000..d701f9c1 --- /dev/null +++ b/tests/TextStack.IntegrationTests/AdminDriftEndpointTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using System.Text.Json; + +namespace TextStack.IntegrationTests; + +/// +/// Integration tests for the input-drift endpoint (Phase 12 RLOps slice 5b), against the live API +/// on the admin host. GET /admin/ai-quality/drift returns one point per (feature, day) from +/// drift_centroids, Day-ordered, NEVER exposing the raw centroid vectors. Drift detection is +/// OFF by default, so the regression guarantee is: admin-gated (401 without auth) and a well-formed +/// (possibly empty) array when authed; each present row carries the drift-point shape. +/// +/// To run: `docker compose up` (API on :8080) with `ENABLE_TEST_AUTH=true`; runs in CI. The +/// synthetic-regression demo (a stable baseline then off-distribution days → an `alerting` row + +/// one admin email) lives in the UnitTests project (DriftDetectionWorkerFlowTests) since it drives +/// the worker's per-feature method directly with a fake embedder — no live OpenAI / DB seeding here. +/// +public class AdminDriftEndpointTests : IClassFixture +{ + private readonly AuthenticatedApiFixture _fixture; + + public AdminDriftEndpointTests(AuthenticatedApiFixture fixture) => _fixture = fixture; + + [Fact] + public async Task GetDrift_NoAuth_Unauthorized() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/admin/ai-quality/drift"); + request.Headers.Host = AuthenticatedApiFixture.AdminHost; + + var response = await _fixture.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 GetDrift_Authed_ReturnsDriftPointArray() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable"); + + var request = _fixture.CreateAdminRequest(HttpMethod.Get, "/admin/ai-quality/drift"); + var response = await _fixture.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(IntegrationSkip.Unavailable(response), "endpoint not deployed"); + Assert.SkipWhen( + response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + "test user is not admin"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var doc = JsonDocument.Parse( + await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var root = doc.RootElement; + // Drift OFF by default → empty array is the expected steady state. Either way it's a + // well-formed array; every present row carries the drift-point shape (and NO centroid). + Assert.Equal(JsonValueKind.Array, root.ValueKind); + + foreach (var row in root.EnumerateArray()) + { + Assert.True(row.TryGetProperty("feature", out var feature)); + Assert.Equal(JsonValueKind.String, feature.ValueKind); + Assert.True(row.TryGetProperty("day", out _)); + Assert.True(row.TryGetProperty("driftScore", out _)); // may be null + Assert.True(row.TryGetProperty("sampleSize", out _)); + Assert.True(row.TryGetProperty("alertState", out _)); + // The raw centroid vector must never be exposed. + Assert.False(row.TryGetProperty("centroid", out _)); + } + } + + [Fact] + public async Task GetDrift_FilteredByMissingFeature_ReturnsEmpty() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable"); + + var request = _fixture.CreateAdminRequest( + HttpMethod.Get, "/admin/ai-quality/drift?feature=__no_such_feature__&days=7"); + var response = await _fixture.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.SkipWhen(IntegrationSkip.Unavailable(response), "endpoint not deployed"); + Assert.SkipWhen( + response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + "test user is not admin"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var doc = JsonDocument.Parse( + await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(0, doc.RootElement.GetArrayLength()); + } +} diff --git a/tests/TextStack.UnitTests/DriftCalculatorTests.cs b/tests/TextStack.UnitTests/DriftCalculatorTests.cs new file mode 100644 index 00000000..d11324d7 --- /dev/null +++ b/tests/TextStack.UnitTests/DriftCalculatorTests.cs @@ -0,0 +1,164 @@ +using Application.Ai; + +namespace TextStack.UnitTests; + +/// +/// Pure embedding-drift math (Phase 12 RLOps slice 5b): mean-pool, cosine drift, and the +/// consecutive-breach alert state machine. No EF / embeddings — all algebra. +/// +public class DriftCalculatorTests +{ + // ── MeanPool ────────────────────────────────────────────────────────────── + + [Fact] + public void MeanPool_KnownVectors_ReturnsElementWiseMean() + { + var result = DriftCalculator.MeanPool([ + [0f, 2f, 4f], + [2f, 4f, 6f], + ]); + + Assert.Equal([1f, 3f, 5f], result); + } + + [Fact] + public void MeanPool_SingleVector_ReturnsItUnchanged() + { + var result = DriftCalculator.MeanPool([[1f, 2f, 3f]]); + Assert.Equal([1f, 2f, 3f], result); + } + + [Fact] + public void MeanPool_Empty_ReturnsEmpty() + { + Assert.Empty(DriftCalculator.MeanPool([])); + } + + [Fact] + public void MeanPool_RaggedLengths_ClampsToShortestNoThrow() + { + // Defensive guard: a model swap mid-window could yield mixed dims. Must clamp, never throw. + var result = DriftCalculator.MeanPool([ + [2f, 4f, 6f, 8f], + [4f, 8f], + ]); + Assert.Equal([3f, 6f], result); + } + + [Fact] + public void MeanPool_ContainsEmptyVector_ReturnsEmptyNoDivideError() + { + // dim collapses to 0 → empty centroid (the worker then treats it as a degenerate centroid; + // CosineDrift on an empty vector returns 0 → no false alert). No throw / NaN. + Assert.Empty(DriftCalculator.MeanPool([[1f, 2f], []])); + } + + // ── CosineDrift ─────────────────────────────────────────────────────────── + + [Fact] + public void CosineDrift_IdenticalVectors_ReturnsZero() + { + var v = new[] { 1f, 2f, 3f }; + Assert.Equal(0, DriftCalculator.CosineDrift(v, (float[])v.Clone()), 6); + } + + [Fact] + public void CosineDrift_OrthogonalVectors_ReturnsOne() + { + Assert.Equal(1, DriftCalculator.CosineDrift([1f, 0f], [0f, 1f]), 6); + } + + [Fact] + public void CosineDrift_OppositeVectors_ReturnsTwo() + { + Assert.Equal(2, DriftCalculator.CosineDrift([1f, 0f], [-1f, 0f]), 6); + } + + [Fact] + public void CosineDrift_ZeroNormVector_ReturnsZeroGuard() + { + Assert.Equal(0, DriftCalculator.CosineDrift([0f, 0f], [1f, 2f]), 6); + } + + [Fact] + public void CosineDrift_LengthMismatch_ReturnsZeroGuard() + { + Assert.Equal(0, DriftCalculator.CosineDrift([1f, 2f], [1f, 2f, 3f]), 6); + } + + // ── Decide (state machine) ──────────────────────────────────────────────── + + [Fact] + public void Decide_BelowThreshold_ResetsToOk() + { + // Prior streak of 3, but today's drift is below threshold → reset to 0 / ok. + var d = DriftCalculator.Decide( + priorConsecutiveBreaches: 3, driftScore: 0.05, threshold: 0.15, + consecutiveDaysToAlert: 2, alreadyAlertedThisStreak: true); + + Assert.Equal(0, d.ConsecutiveBreaches); + Assert.Equal("ok", d.AlertState); + Assert.False(d.ShouldAlert); + } + + [Fact] + public void Decide_FirstBreach_Warning_NoAlert() + { + var d = DriftCalculator.Decide( + priorConsecutiveBreaches: 0, driftScore: 0.2, threshold: 0.15, + consecutiveDaysToAlert: 2, alreadyAlertedThisStreak: false); + + Assert.Equal(1, d.ConsecutiveBreaches); + Assert.Equal("warning", d.AlertState); + Assert.False(d.ShouldAlert); // need 2 consecutive days + } + + [Fact] + public void Decide_SecondConsecutiveBreach_AlertsOnce() + { + var d = DriftCalculator.Decide( + priorConsecutiveBreaches: 1, driftScore: 0.2, threshold: 0.15, + consecutiveDaysToAlert: 2, alreadyAlertedThisStreak: false); + + Assert.Equal(2, d.ConsecutiveBreaches); + Assert.Equal("alerting", d.AlertState); + Assert.True(d.ShouldAlert); // crossed into alerting → page once + } + + [Fact] + public void Decide_AlreadyAlertedThisStreak_DoesNotReAlert() + { + // Streak continues into a 3rd day; alert already fired → still alerting, but no re-page. + var d = DriftCalculator.Decide( + priorConsecutiveBreaches: 2, driftScore: 0.2, threshold: 0.15, + consecutiveDaysToAlert: 2, alreadyAlertedThisStreak: true); + + Assert.Equal(3, d.ConsecutiveBreaches); + Assert.Equal("alerting", d.AlertState); + Assert.False(d.ShouldAlert); + } + + [Fact] + public void Decide_AtExactThreshold_CountsAsBreach() + { + var d = DriftCalculator.Decide( + priorConsecutiveBreaches: 0, driftScore: 0.15, threshold: 0.15, + consecutiveDaysToAlert: 2, alreadyAlertedThisStreak: false); + + Assert.Equal(1, d.ConsecutiveBreaches); + Assert.Equal("warning", d.AlertState); + } + + [Fact] + public void Decide_ResetAfterAlerting_ClearsStreakAndAlertEligibility() + { + // Was alerting (streak 2, already alerted); today drops below threshold → ok, streak 0. + var d = DriftCalculator.Decide( + priorConsecutiveBreaches: 2, driftScore: 0.0, threshold: 0.15, + consecutiveDaysToAlert: 2, alreadyAlertedThisStreak: true); + + Assert.Equal(0, d.ConsecutiveBreaches); + Assert.Equal("ok", d.AlertState); + Assert.False(d.ShouldAlert); + } +} diff --git a/tests/TextStack.UnitTests/DriftDetectionWorkerFlowTests.cs b/tests/TextStack.UnitTests/DriftDetectionWorkerFlowTests.cs new file mode 100644 index 00000000..db5c49e4 --- /dev/null +++ b/tests/TextStack.UnitTests/DriftDetectionWorkerFlowTests.cs @@ -0,0 +1,342 @@ +using Api.Services; +using Application.Auth; +using Domain.Entities; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using TextStack.Ai.Core; +using TextStack.UnitTests.Fakes; + +namespace TextStack.UnitTests; + +/// +/// DriftDetectionWorker.ProcessFeatureAsync flow coverage (Phase 12 RLOps slice 5b) over an +/// in-memory + deterministic fake embedder (NO real OpenAI): +/// insufficient-sample path, baseline cold-start, and the synthetic-regression demo (a stable +/// baseline then off-distribution days that breach the threshold for 2 consecutive days → an +/// `alerting` row + exactly one admin email). +/// +public class DriftDetectionWorkerFlowTests +{ + private const string Feature = "explain"; + private static readonly DateOnly Day0 = new(2026, 6, 1); + + // Deterministic embedder: maps a text to a unit vector along one axis chosen by a tag prefix. + // "A:" → axis 0, "B:" → axis 1. Lets a test place "today" near or far from the baseline. + private sealed class FakeEmbedder : IEmbeddingService + { + public int Dimensions => 3; + public Task EmbedAsync(string text, CancellationToken ct) + { + var v = text.StartsWith("B:", StringComparison.Ordinal) + ? new[] { 0f, 1f, 0f } + : new[] { 1f, 0f, 0f }; + return Task.FromResult(v); + } + public Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken ct) => + throw new NotSupportedException(); + } + + private sealed class CountingEmailService : IEmailService + { + public int AlertCount { get; private set; } + public string? LastSubject { get; private set; } + public Task SendPasswordResetEmailAsync(string toEmail, string token, CancellationToken ct) => + Task.CompletedTask; + public Task SendAdminAlertAsync(string subject, string htmlBody, CancellationToken ct) + { + AlertCount++; + LastSubject = subject; + return Task.CompletedTask; + } + } + + // Minimal IServiceProvider: only IEmailService is resolvable (the only sp.GetRequiredService in + // ProcessFeatureAsync — embedder + db are passed as args). + private sealed class SingleServiceProvider(object svc) : IServiceProvider + { + public object? GetService(Type serviceType) => + serviceType.IsInstanceOfType(svc) ? svc : null; + } + + private static IConfiguration DefaultConfig() => + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Drift:MaxSampleSize"] = "50", + ["Drift:MinSampleSize"] = "10", + ["Drift:Threshold"] = "0.15", + ["Drift:ConsecutiveDays"] = "2", + ["Drift:BaselineWindowDays"] = "7", + }).Build(); + + // ProcessFeatureAsync never touches the scope factory (only TickAsync does), so a throwing one is fine. + private sealed class ThrowingScopeFactory : Microsoft.Extensions.DependencyInjection.IServiceScopeFactory + { + public Microsoft.Extensions.DependencyInjection.IServiceScope CreateScope() => + throw new NotSupportedException(); + } + + private static DriftDetectionWorker Worker(IConfiguration config) => + new(new ThrowingScopeFactory(), config, NullLogger.Instance); + + private static Domain.Entities.LlmTrace MakeTrace(string content, DateTimeOffset at) => new() + { + Id = Guid.NewGuid(), + FeatureTag = Feature, + ModelId = "gpt-4.1-nano", + PromptHash = "h", + MessagesJson = "[{\"Role\":\"user\",\"Content\":\"" + content + "\"}]", + CreatedAt = at, + Error = null, + }; + + [Fact] + public async Task ProcessFeature_TooFewSamples_WritesInsufficientNoEmbed() + { + var db = new FakeAppDbContext(); + var now = Day0.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + // 5 traces < MinSampleSize(10). + for (var i = 0; i < 5; i++) + db.TraceStore.Add(MakeTrace("A:hello", now.AddMinutes(-i))); + + var email = new CountingEmailService(); + var worker = Worker(DefaultConfig()); + + await worker.ProcessFeatureAsync( + new SingleServiceProvider(email), db, new FakeEmbedder(), Feature, + Day0, now, CancellationToken.None); + + var row = Assert.Single(db.DriftStore); + Assert.Equal("insufficient", row.AlertState); + Assert.Null(row.DriftScore); + Assert.Null(row.Centroid); + Assert.Equal(5, row.SampleSize); + Assert.Equal(0, email.AlertCount); + } + + [Fact] + public async Task ProcessFeature_NoPriors_SeedsBaselineRowZeroDrift() + { + var db = new FakeAppDbContext(); + var now = Day0.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace("A:hello", now.AddMinutes(-i))); + + var email = new CountingEmailService(); + var worker = Worker(DefaultConfig()); + + await worker.ProcessFeatureAsync( + new SingleServiceProvider(email), db, new FakeEmbedder(), Feature, + Day0, now, CancellationToken.None); + + var row = Assert.Single(db.DriftStore); + Assert.Equal("baseline", row.AlertState); + Assert.Equal(0d, row.DriftScore); + Assert.NotNull(row.Centroid); + Assert.Equal(0, email.AlertCount); + } + + [Fact] + public async Task ProcessFeature_AlreadyHasTodayRow_NoOp() + { + var db = new FakeAppDbContext(); + var now = Day0.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + db.DriftStore.Add(new DriftCentroid + { + Id = Guid.NewGuid(), Feature = Feature, Day = Day0, + AlertState = "ok", SampleSize = 12, CreatedAt = now, + }); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace("A:hello", now.AddMinutes(-i))); + + var worker = Worker(DefaultConfig()); + // Throwing embedder proves the idempotency skip bails BEFORE any embedding work/cost — if the + // AnyAsync(today) short-circuit regressed, EmbedAsync would throw and fail the test. + await worker.ProcessFeatureAsync( + new SingleServiceProvider(new CountingEmailService()), db, new ThrowingEmbedder(), Feature, + Day0, now, CancellationToken.None); + + // Idempotent: still exactly the one pre-existing row, no new SaveChanges. + Assert.Single(db.DriftStore); + Assert.Equal(0, db.SaveCalls); + } + + [Fact] + public async Task ProcessFeature_SyntheticRegression_TwoOffDistributionDays_AlertsOnce() + { + var db = new FakeAppDbContext(); + var email = new CountingEmailService(); + var worker = Worker(DefaultConfig()); + var sp = new SingleServiceProvider(email); + var embedder = new FakeEmbedder(); + + // Seed a stable baseline: 5 prior days all on axis 0 ("A:"), drift 0. + for (var d = 0; d < 5; d++) + { + var day = Day0.AddDays(d); + var at = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + db.TraceStore.Clear(); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace("A:stable", at.AddMinutes(-i))); + await worker.ProcessFeatureAsync(sp, db, embedder, Feature, day, at, CancellationToken.None); + } + + Assert.Equal(0, email.AlertCount); // stable baseline never alerts + Assert.All(db.DriftStore, r => Assert.True(r.AlertState is "baseline" or "ok")); + + // Day 5: off-distribution (axis 1, "B:") → drift ≈ 1.0 ≥ 0.15 → first breach → warning. + var day5 = Day0.AddDays(5); + var at5 = day5.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + db.TraceStore.Clear(); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace("B:shifted", at5.AddMinutes(-i))); + await worker.ProcessFeatureAsync(sp, db, embedder, Feature, day5, at5, CancellationToken.None); + + var row5 = db.DriftStore.Single(r => r.Day == day5); + Assert.Equal("warning", row5.AlertState); + Assert.True(row5.DriftScore >= 0.15); + Assert.Equal(1, row5.ConsecutiveBreaches); + Assert.Equal(0, email.AlertCount); // not yet 2 consecutive + + // Day 6: still off-distribution → 2nd consecutive breach → alerting + exactly one email. + var day6 = Day0.AddDays(6); + var at6 = day6.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + db.TraceStore.Clear(); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace("B:shifted", at6.AddMinutes(-i))); + await worker.ProcessFeatureAsync(sp, db, embedder, Feature, day6, at6, CancellationToken.None); + + var row6 = db.DriftStore.Single(r => r.Day == day6); + Assert.Equal("alerting", row6.AlertState); + Assert.Equal(2, row6.ConsecutiveBreaches); + Assert.NotNull(row6.AlertedAt); + Assert.Equal(1, email.AlertCount); + Assert.Contains(Feature, email.LastSubject); + + // Day 7: still breaching → stays alerting but NO second email (debounced per streak). + var day7 = Day0.AddDays(7); + var at7 = day7.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + db.TraceStore.Clear(); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace("B:shifted", at7.AddMinutes(-i))); + await worker.ProcessFeatureAsync(sp, db, embedder, Feature, day7, at7, CancellationToken.None); + + var row7 = db.DriftStore.Single(r => r.Day == day7); + Assert.Equal("alerting", row7.AlertState); + Assert.Equal(3, row7.ConsecutiveBreaches); + Assert.Equal(1, email.AlertCount); // still 1 — no re-alert + } + + // Re-arm after a reset: a sustained streak alerts once, a non-breaching day resets it, then a + // NEW streak must alert AGAIN. Attacks invariant 5's "re-arms after reset+new streak" clause + // end-to-end (the unit Decide test covers the math; this drives the worker's prior-row read). + [Fact] + public async Task ProcessFeature_StreakResetThenNewStreak_AlertsTwice() + { + var db = new FakeAppDbContext(); + var email = new CountingEmailService(); + var worker = Worker(DefaultConfig()); + var sp = new SingleServiceProvider(email); + var embedder = new FakeEmbedder(); + + async Task RunDay(DateOnly day, string tag) + { + var at = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + db.TraceStore.Clear(); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace(tag, at.AddMinutes(-i))); + await worker.ProcessFeatureAsync(sp, db, embedder, Feature, day, at, CancellationToken.None); + } + + // Day 0: baseline on axis 0. + await RunDay(Day0, "A:stable"); + // Days 1-2: off-distribution (axis 1) → 2 consecutive breaches → ALERT #1 on day 2. + await RunDay(Day0.AddDays(1), "B:shifted"); + await RunDay(Day0.AddDays(2), "B:shifted"); + Assert.Equal(1, email.AlertCount); + Assert.Equal("alerting", db.DriftStore.Single(r => r.Day == Day0.AddDays(2)).AlertState); + + // Day 3: back on-distribution → drift ~0 → reset to ok, streak cleared, alert re-armed. + await RunDay(Day0.AddDays(3), "A:stable"); + var resetRow = db.DriftStore.Single(r => r.Day == Day0.AddDays(3)); + Assert.Equal("ok", resetRow.AlertState); + Assert.Equal(0, resetRow.ConsecutiveBreaches); + Assert.Equal(1, email.AlertCount); // no new alert on reset + + // Days 4-5: off-distribution AGAIN → 2 consecutive breaches → ALERT #2 (re-armed). + await RunDay(Day0.AddDays(4), "B:shifted"); + Assert.Equal(1, email.AlertCount); // day 4 is only the 1st breach of the new streak + await RunDay(Day0.AddDays(5), "B:shifted"); + var alertRow2 = db.DriftStore.Single(r => r.Day == Day0.AddDays(5)); + Assert.Equal("alerting", alertRow2.AlertState); + Assert.Equal(2, alertRow2.ConsecutiveBreaches); + Assert.Equal(2, email.AlertCount); // re-armed → fired a 2nd time + } + + // Sustained drift longer than the baseline window must NOT silently re-baseline to drift 0: + // because breaching days are excluded from the baseline, Take(window) reaches back to the last + // good day before the drift, so every drifted day keeps comparing against the known-good baseline + // and stays `alerting` (one email, never reset). Guards the fix for baseline self-poisoning. + [Fact] + public async Task ProcessFeature_DriftLongerThanBaselineWindow_StaysAlertingOneEmail() + { + var db = new FakeAppDbContext(); + var email = new CountingEmailService(); + var worker = Worker(DefaultConfig()); // BaselineWindowDays = 7 + var sp = new SingleServiceProvider(email); + var embedder = new FakeEmbedder(); + + async Task RunDay(DateOnly day, string tag) + { + var at = day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + db.TraceStore.Clear(); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace(tag, at.AddMinutes(-i))); + await worker.ProcessFeatureAsync(sp, db, embedder, Feature, day, at, CancellationToken.None); + } + + // 1 baseline day, then 10 straight off-distribution days (> 7-day window). + await RunDay(Day0, "A:stable"); + for (var d = 1; d <= 10; d++) + await RunDay(Day0.AddDays(d), "B:shifted"); + + // Every drifted day still breaches against the preserved good baseline. + for (var d = 2; d <= 10; d++) + { + var row = db.DriftStore.Single(r => r.Day == Day0.AddDays(d)); + Assert.Equal("alerting", row.AlertState); + Assert.True(row.DriftScore >= 0.15, $"day {d} drift {row.DriftScore} should breach"); + } + Assert.Equal(1, email.AlertCount); // exactly one email for the whole sustained streak + } + + // Never-crash: a throwing embedder inside ProcessFeatureAsync surfaces as an exception — the + // TickAsync per-feature catch (covered structurally) swallows it. Here we assert the throw is the + // ONLY failure mode (no partial/corrupt row is persisted) so a later retry is clean/idempotent. + [Fact] + public async Task ProcessFeature_EmbedderThrows_NoPartialRowPersisted() + { + var db = new FakeAppDbContext(); + var now = Day0.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + for (var i = 0; i < 12; i++) + db.TraceStore.Add(MakeTrace("A:hello", now.AddMinutes(-i))); + + var worker = Worker(DefaultConfig()); + + await Assert.ThrowsAnyAsync(() => worker.ProcessFeatureAsync( + new SingleServiceProvider(new CountingEmailService()), db, new ThrowingEmbedder(), Feature, + Day0, now, CancellationToken.None)); + + // Embedding happens before any Add — nothing should have been written for today. + Assert.Empty(db.DriftStore); + Assert.Equal(0, db.SaveCalls); + } + + private sealed class ThrowingEmbedder : IEmbeddingService + { + public int Dimensions => 3; + public Task EmbedAsync(string text, CancellationToken ct) => + throw new InvalidOperationException("embedding backend down"); + public Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken ct) => + throw new NotSupportedException(); + } +} diff --git a/tests/TextStack.UnitTests/DriftDetectionWorkerTests.cs b/tests/TextStack.UnitTests/DriftDetectionWorkerTests.cs new file mode 100644 index 00000000..1de5637f --- /dev/null +++ b/tests/TextStack.UnitTests/DriftDetectionWorkerTests.cs @@ -0,0 +1,79 @@ +using Api.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace TextStack.UnitTests; + +/// +/// DriftDetectionWorker unit coverage (Phase 12 RLOps slice 5b): the disabled short-circuit does +/// zero DB/scope/lock work, and the prompt-extraction helper handles the llm_traces message shape. +/// The full sample→embed→baseline→alert flow is covered by the integration regression-demo test. +/// +public class DriftDetectionWorkerTests +{ + // A scope factory that explodes the moment it's used — proves the disabled path bails before + // any scope/DB/advisory-lock work. + private sealed class ExplodingScopeFactory : IServiceScopeFactory + { + public IServiceScope CreateScope() => + throw new InvalidOperationException("scope created while worker was disabled"); + } + + private static IConfiguration Config(bool enabled) => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Drift:Enabled"] = enabled ? "true" : "false", + }) + .Build(); + + [Fact] + public async Task TickAsync_Disabled_ShortCircuitsBeforeAnyScopeOrDb() + { + var worker = new DriftDetectionWorker( + new ExplodingScopeFactory(), Config(enabled: false), + NullLogger.Instance); + + // Must NOT throw — the exploding scope factory would fire if the disabled path touched it. + await worker.TickAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task TickAsync_EnabledButScopeThrows_Propagates() + { + var worker = new DriftDetectionWorker( + new ExplodingScopeFactory(), Config(enabled: true), + NullLogger.Instance); + + await Assert.ThrowsAsync( + () => worker.TickAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public void FirstUserMessage_PicksFirstUserContent() + { + const string json = """ + [ + {"Role":"system","Content":"you are helpful"}, + {"Role":"user","Content":"explain the word ephemeral"}, + {"Role":"user","Content":"second user msg"} + ] + """; + Assert.Equal("explain the word ephemeral", DriftDetectionWorker.FirstUserMessage(json)); + } + + [Fact] + public void FirstUserMessage_NoUserRole_ReturnsNull() + { + const string json = """[{"Role":"system","Content":"sys only"}]"""; + Assert.Null(DriftDetectionWorker.FirstUserMessage(json)); + } + + [Fact] + public void FirstUserMessage_Malformed_ReturnsNull() + { + Assert.Null(DriftDetectionWorker.FirstUserMessage("not json")); + Assert.Null(DriftDetectionWorker.FirstUserMessage("{}")); + } +} diff --git a/tests/TextStack.UnitTests/Fakes/FakeAppDbContext.cs b/tests/TextStack.UnitTests/Fakes/FakeAppDbContext.cs new file mode 100644 index 00000000..63c907c3 --- /dev/null +++ b/tests/TextStack.UnitTests/Fakes/FakeAppDbContext.cs @@ -0,0 +1,98 @@ +using Application.Common.Interfaces; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; + +namespace TextStack.UnitTests.Fakes; + +/// +/// In-memory for the DriftDetectionWorker per-feature flow tests. Only +/// the two sets the worker reads/writes (, ) are +/// backed by lists; everything else throws so a stray access is loud. +/// is a no-op count (FakeDbSet.Add already appended to the backing list). +/// +internal sealed class FakeAppDbContext : IAppDbContext +{ + public List DriftStore { get; } = []; + public List TraceStore { get; } = []; + public int SaveCalls { get; private set; } + + private readonly FakeDbSet _drift; + private readonly FakeDbSet _traces; + + public FakeAppDbContext() + { + _drift = new FakeDbSet(DriftStore); + _traces = new FakeDbSet(TraceStore); + } + + public DbSet DriftCentroids => _drift; + public DbSet LlmTraces => _traces; + + public Task SaveChangesAsync(CancellationToken ct = default) + { + SaveCalls++; + return Task.FromResult(0); + } + + public DatabaseFacade Database => throw new NotSupportedException(); + public Task BeginTransactionAsync(CancellationToken ct = default) => + throw new NotSupportedException(); + + public DbSet Sites => throw new NotSupportedException(); + public DbSet SiteDomains => throw new NotSupportedException(); + public DbSet Works => throw new NotSupportedException(); + public DbSet Editions => throw new NotSupportedException(); + public DbSet Chapters => throw new NotSupportedException(); + public DbSet BookFiles => throw new NotSupportedException(); + public DbSet IngestionJobs => throw new NotSupportedException(); + public DbSet Users => throw new NotSupportedException(); + public DbSet UserLibraries => throw new NotSupportedException(); + public DbSet ReadingProgresses => throw new NotSupportedException(); + public DbSet Bookmarks => throw new NotSupportedException(); + public DbSet Notes => throw new NotSupportedException(); + public DbSet AdminUsers => throw new NotSupportedException(); + public DbSet AdminRefreshTokens => throw new NotSupportedException(); + public DbSet UserRefreshTokens => throw new NotSupportedException(); + public DbSet Authors => throw new NotSupportedException(); + public DbSet EditionAuthors => throw new NotSupportedException(); + public DbSet Genres => throw new NotSupportedException(); + public DbSet TextStackImports => throw new NotSupportedException(); + public DbSet SsgRebuildJobs => throw new NotSupportedException(); + public DbSet SsgRebuildResults => throw new NotSupportedException(); + public DbSet BookAssets => throw new NotSupportedException(); + public DbSet LintResults => throw new NotSupportedException(); + public DbSet UserBooks => throw new NotSupportedException(); + public DbSet UserChapters => throw new NotSupportedException(); + public DbSet UserBookFiles => throw new NotSupportedException(); + public DbSet UserIngestionJobs => throw new NotSupportedException(); + public DbSet UserBookBookmarks => throw new NotSupportedException(); + public DbSet AdminSettings => throw new NotSupportedException(); + public DbSet Highlights => throw new NotSupportedException(); + public DbSet ReadingSessions => throw new NotSupportedException(); + public DbSet ReadingGoals => throw new NotSupportedException(); + public DbSet UserAchievements => throw new NotSupportedException(); + public DbSet VocabularyWords => throw new NotSupportedException(); + public DbSet VocabularyReviews => throw new NotSupportedException(); + public DbSet UserVocabularySettings => throw new NotSupportedException(); + public DbSet PendingVocabularyWords => throw new NotSupportedException(); + public DbSet WordLookups => throw new NotSupportedException(); + public DbSet WordFrequencies => throw new NotSupportedException(); + public DbSet WordClusters => throw new NotSupportedException(); + public DbSet AutoPublishJobs => throw new NotSupportedException(); + public DbSet PasswordResetTokens => throw new NotSupportedException(); + public DbSet DeviceAuthorizations => throw new NotSupportedException(); + public DbSet BookQualityJobs => throw new NotSupportedException(); + public DbSet SeoTemplates => throw new NotSupportedException(); + public DbSet SeoBackfillJobs => throw new NotSupportedException(); + public DbSet SeoBackfillSettings => throw new NotSupportedException(); + public DbSet Collections => throw new NotSupportedException(); + public DbSet BookCollections => throw new NotSupportedException(); + public DbSet ShadowRuns => throw new NotSupportedException(); + public DbSet Models => throw new NotSupportedException(); + public DbSet ModelPromotions => throw new NotSupportedException(); + public DbSet EvalRuns => throw new NotSupportedException(); + public DbSet AgentRuns => throw new NotSupportedException(); + public DbSet PodcastGenerationJobs => throw new NotSupportedException(); +} diff --git a/tests/TextStack.UnitTests/Fakes/FakeAsyncQuery.cs b/tests/TextStack.UnitTests/Fakes/FakeAsyncQuery.cs new file mode 100644 index 00000000..ae33c655 --- /dev/null +++ b/tests/TextStack.UnitTests/Fakes/FakeAsyncQuery.cs @@ -0,0 +1,53 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; + +namespace TextStack.UnitTests.Fakes; + +/// +/// Minimal in-memory async query plumbing so EF Core async LINQ operators +/// (ToListAsync / AnyAsync / FirstOrDefaultAsync) work over a plain +/// without pulling EF Core InMemory (not a repo dependency). Standard +/// "test async enumerable" shape — executes the underlying synchronous LINQ provider and wraps +/// the result as an async sequence / task. +/// +internal sealed class TestAsyncEnumerable(IEnumerable enumerable) + : EnumerableQuery(enumerable), IAsyncEnumerable, IQueryable +{ + public TestAsyncEnumerable(Expression expression) : this(new EnumerableQuery(expression)) { } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken ct = default) => + new TestAsyncEnumerator(this.AsEnumerable().GetEnumerator()); + + IQueryProvider IQueryable.Provider => new TestAsyncQueryProvider(this); +} + +internal sealed class TestAsyncEnumerator(IEnumerator inner) : IAsyncEnumerator +{ + public T Current => inner.Current; + public ValueTask MoveNextAsync() => new(inner.MoveNext()); + public ValueTask DisposeAsync() { inner.Dispose(); return default; } +} + +internal sealed class TestAsyncQueryProvider(IQueryProvider inner) : IAsyncQueryProvider +{ + public IQueryable CreateQuery(Expression expression) => new TestAsyncEnumerable(expression); + + public IQueryable CreateQuery(Expression expression) => + new TestAsyncEnumerable(expression); + + public object? Execute(Expression expression) => inner.Execute(expression); + + public TResult Execute(Expression expression) => inner.Execute(expression); + + public TResult ExecuteAsync(Expression expression, CancellationToken ct = default) + { + // TResult is Task; execute synchronously then wrap as a completed Task. + var expectedResultType = typeof(TResult).GetGenericArguments()[0]; + var executionResult = ((IQueryProvider)this).Execute(expression); + + return (TResult)typeof(Task) + .GetMethod(nameof(Task.FromResult))! + .MakeGenericMethod(expectedResultType) + .Invoke(null, [executionResult])!; + } +} diff --git a/tests/TextStack.UnitTests/Fakes/FakeDbSet.cs b/tests/TextStack.UnitTests/Fakes/FakeDbSet.cs new file mode 100644 index 00000000..23948f59 --- /dev/null +++ b/tests/TextStack.UnitTests/Fakes/FakeDbSet.cs @@ -0,0 +1,38 @@ +using System.Collections; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace TextStack.UnitTests.Fakes; + +/// +/// In-memory backed by a with working async LINQ +/// (via ). Supports the operations DriftDetectionWorker uses: +/// Where/OrderBy/Select/Take + AnyAsync/FirstOrDefaultAsync/ToListAsync +/// and Add. No change-tracking — Add just appends to the backing list (the worker +/// reads back via the same list after SaveChanges). +/// +internal sealed class FakeDbSet(List store) : DbSet, IQueryable, IAsyncEnumerable + where T : class +{ + private readonly TestAsyncEnumerable _query = new(store); + + public List Store => store; + + public override Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry Add(T entity) + { + store.Add(entity); + return null!; // worker discards the returned entry + } + + public override Microsoft.EntityFrameworkCore.Metadata.IEntityType EntityType => + throw new NotSupportedException(); + + IEnumerator IEnumerable.GetEnumerator() => store.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => store.GetEnumerator(); + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken ct = default) => + ((IAsyncEnumerable)_query).GetAsyncEnumerator(ct); + + public Type ElementType => ((IQueryable)_query).ElementType; + public Expression Expression => ((IQueryable)_query).Expression; + public IQueryProvider Provider => ((IQueryable)_query).Provider; +}