From 8ef5ee638ef53fd59a9344fbb8dec96d0974f9b7 Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Thu, 18 Jun 2026 13:30:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(ai):=20ContinuousEvalWorker=20=E2=80=94=20?= =?UTF-8?q?scheduled=20prod=20evals=20(AI-079,=20Phase=2012=20slice=205a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automates the eval suite on a cadence so quality regressions are caught without an admin trigger. - ContinuousEvalWorker (Api BackgroundService, ~10min startup + hourly check): runs EvalSuiteRunner when due (no scheduled run newer than Eval:Scheduled:IntervalHours, default 24h), persists with new run_type column, alerts admin on score drop >= RegressionDrop (default 0.5) vs prior scheduled run (EvalRegressionDetector, pure). - OFF by default (Eval:Scheduled:Enabled=false); respects optional eval.judge daily cap (fail-open). - Concurrency: shared IEvalRunGate singleton (extracted from the admin trigger's static guard) + Postgres advisory lock for multi-replica. Never crashes host. - QA: P1 advisory-unlock uses CancellationToken.None (no leak on shutdown); P2 admin trigger releases gate on synchronous setup failure. - GET /admin/ai-quality/drift/eval-trend (scheduled trend, for 5b Drift tab). - Migration AddEvalRunType (backfills existing rows to 'manual'). architect -> backend -> adversarial QA (SHIP; P1+P2 fixed). 780 unit tests green; solution clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + .../TextStack.Ai.EvalSuite/EvalSuiteRunner.cs | 4 +- .../Api/Endpoints/AdminAiQualityEndpoints.cs | 78 +- backend/src/Api/Program.cs | 6 + .../src/Api/Services/ContinuousEvalWorker.cs | 248 + backend/src/Api/appsettings.json | 11 +- .../Application/Ai/EvalRegressionDetector.cs | 44 + backend/src/Application/Ai/IEvalRunGate.cs | 30 + backend/src/Contracts/Admin/AiQualityDtos.cs | 14 +- backend/src/Domain/Entities/EvalRun.cs | 6 + .../20260618172015_AddEvalRunType.Designer.cs | 5033 +++++++++++++++++ .../20260618172015_AddEvalRunType.cs | 30 + .../Migrations/AppDbContextModelSnapshot.cs | 8 + .../Persistence/AppDbContext.Ai.cs | 3 + .../TextStack.AiEvals/EvalSuiteRunnerTests.cs | 47 + .../AdminEvalTrendEndpointTests.cs | 85 + .../ContinuousEvalWorkerDueTests.cs | 127 + .../EvalRegressionDetectorTests.cs | 97 + tests/TextStack.UnitTests/EvalRunGateTests.cs | 90 + 19 files changed, 5944 insertions(+), 21 deletions(-) create mode 100644 backend/src/Api/Services/ContinuousEvalWorker.cs create mode 100644 backend/src/Application/Ai/EvalRegressionDetector.cs create mode 100644 backend/src/Application/Ai/IEvalRunGate.cs create mode 100644 backend/src/Infrastructure/Migrations/20260618172015_AddEvalRunType.Designer.cs create mode 100644 backend/src/Infrastructure/Migrations/20260618172015_AddEvalRunType.cs create mode 100644 tests/TextStack.IntegrationTests/AdminEvalTrendEndpointTests.cs create mode 100644 tests/TextStack.UnitTests/ContinuousEvalWorkerDueTests.cs create mode 100644 tests/TextStack.UnitTests/EvalRegressionDetectorTests.cs create mode 100644 tests/TextStack.UnitTests/EvalRunGateTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f1bd4297..c3492b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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. + ### Phase 12 — cost-aware routing + per-feature daily budget (AI-078, slice 4) (2026-06-18) Per-feature daily USD budgets with cost-aware enforcement — the DoD "cost-aware routing cuts spend" lever. diff --git a/backend/src/Ai/TextStack.Ai.EvalSuite/EvalSuiteRunner.cs b/backend/src/Ai/TextStack.Ai.EvalSuite/EvalSuiteRunner.cs index c1349ded..635dbcaa 100644 --- a/backend/src/Ai/TextStack.Ai.EvalSuite/EvalSuiteRunner.cs +++ b/backend/src/Ai/TextStack.Ai.EvalSuite/EvalSuiteRunner.cs @@ -31,7 +31,8 @@ public async Task> RunAsync( bool persist, IAppDbContext? db, string? gitSha, - CancellationToken ct) + CancellationToken ct, + string runType = "manual") { var defs = EvalDefinitions.Build(keys); var chatConfig = new ChatConfiguration(new LlmServiceChatClient(judgeClient, defaultFeatureTag: "eval.judge")); @@ -85,6 +86,7 @@ public async Task> RunAsync( N = summary.N, BreakdownJson = Breakdown(entry.Rubric, summary), GitSha = gitSha, + RunType = runType, CreatedAt = DateTimeOffset.UtcNow, }); } diff --git a/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs b/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs index 18499b6c..3a159393 100644 --- a/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs +++ b/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs @@ -29,6 +29,7 @@ public static void MapAdminAiQualityEndpoints(this WebApplication app) group.MapGet("/agent-runs", GetAgentRuns); group.MapGet("/agent-runs/{id:guid}", GetAgentRun); group.MapGet("/evals", GetEvals); + group.MapGet("/drift/eval-trend", GetEvalTrend); group.MapPost("/evals/run", RunEvals); group.MapGet("/evals/status", GetEvalStatus); group.MapPost("/evals/toolcalls/run", RunToolCallEval); @@ -236,37 +237,51 @@ private static async Task RunToolCallEval( } // In-app eval runner state (one run at a time). Triggered from the admin Evals tab. + // The single-slot guard is now the shared IEvalRunGate (slice 5a) so an admin run and a + // scheduled ContinuousEvalWorker run can't collide; these fields are only display state. private static volatile bool _evalRunning; private static DateTimeOffset? _evalStartedAt; private static string? _evalLastError; - private static readonly object _evalLock = new(); private static IResult RunEvals( [FromBody] RunEvalsRequest? body, IServiceScopeFactory scopeFactory, IConfiguration config, + Application.Ai.IEvalRunGate gate, ILogger logger) { - lock (_evalLock) + if (!gate.TryEnter()) + return Results.Conflict(new { error = "An eval run is already in progress" }); + + // The gate is now held; any synchronous failure before the background task's + // finally takes over MUST release it, else evals are blocked until restart (QA P2). + string judgeKey, judgeModelId; + string? gitSha; + IReadOnlyList? features; + try { - if (_evalRunning) - return Results.Conflict(new { error = "An eval run is already in progress" }); _evalRunning = true; _evalStartedAt = DateTimeOffset.UtcNow; _evalLastError = null; - } - // Judge defaults to local Ollama (free); generation always goes through the - // gateway (routes by FeatureTag → OpenAI/Ollama exactly like prod). - // The OpenAI judge runs the dedicated 'openai-judge' provider (Eval:JudgeModel, - // default gpt-4.1) — stronger + independent of the nano generation model. - var useOpenAiJudge = string.Equals(body?.Judge, "openai", StringComparison.OrdinalIgnoreCase); - var judgeKey = useOpenAiJudge ? "openai-judge" : "ollama"; - var judgeModelId = useOpenAiJudge - ? config["Eval:JudgeModel"] ?? "gpt-4.1" - : config["Ollama:Model"] ?? "gemma4:e2b"; - var gitSha = Environment.GetEnvironmentVariable("GIT_SHA"); - var features = body?.Features; + // Judge defaults to local Ollama (free); generation always goes through the + // gateway (routes by FeatureTag → OpenAI/Ollama exactly like prod). + // The OpenAI judge runs the dedicated 'openai-judge' provider (Eval:JudgeModel, + // default gpt-4.1) — stronger + independent of the nano generation model. + var useOpenAiJudge = string.Equals(body?.Judge, "openai", StringComparison.OrdinalIgnoreCase); + judgeKey = useOpenAiJudge ? "openai-judge" : "ollama"; + judgeModelId = useOpenAiJudge + ? config["Eval:JudgeModel"] ?? "gpt-4.1" + : config["Ollama:Model"] ?? "gemma4:e2b"; + gitSha = Environment.GetEnvironmentVariable("GIT_SHA"); + features = body?.Features; + } + catch + { + _evalRunning = false; + gate.Exit(); + throw; + } _ = Task.Run(async () => { @@ -278,7 +293,7 @@ private static IResult RunEvals( var gateway = sp.GetRequiredService(); var judge = sp.GetRequiredKeyedService(judgeKey); var db = sp.GetRequiredService(); - await runner.RunAsync(_ => gateway, judge, judgeModelId, features, persist: true, db, gitSha, CancellationToken.None); + await runner.RunAsync(_ => gateway, judge, judgeModelId, features, persist: true, db, gitSha, CancellationToken.None, runType: "manual"); } catch (Exception ex) { @@ -288,6 +303,7 @@ private static IResult RunEvals( finally { _evalRunning = false; + gate.Exit(); } }); @@ -417,12 +433,38 @@ private static async Task GetEvals( .OrderByDescending(r => r.CreatedAt) .Take(limit) .Select(r => new EvalRunDto( - r.Id, r.Feature, r.ModelId, r.JudgeModelId, r.Score, r.N, r.BreakdownJson, r.GitSha, r.CreatedAt)) + r.Id, r.Feature, r.ModelId, r.JudgeModelId, r.Score, r.N, r.BreakdownJson, r.GitSha, r.RunType, r.CreatedAt)) .ToListAsync(ct); return Results.Ok(runs); } + // Phase 12 RLOps slice 5a: scheduled-only eval trend for the Drift tab (slice 5b UI). + // Last N RunType='scheduled' rows (optionally per feature), newest-first. + private static async Task GetEvalTrend( + AppDbContext db, + [FromQuery] string? feature, + [FromQuery] int limit = 100, + CancellationToken ct = default) + { + limit = Math.Clamp(limit, 1, 1000); + var query = db.EvalRuns.Where(r => r.RunType == "scheduled"); + if (!string.IsNullOrWhiteSpace(feature)) + { + var feat = feature.Trim(); + query = query.Where(r => r.Feature == feat); + } + + var points = await query + .OrderByDescending(r => r.CreatedAt) + .Take(limit) + .Select(r => new ScheduledEvalPointDto( + r.Feature, r.ModelId, (double)r.Score, r.N, r.GitSha ?? "", r.CreatedAt)) + .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 1f51b7b8..0d613d31 100644 --- a/backend/src/Api/Program.cs +++ b/backend/src/Api/Program.cs @@ -81,6 +81,10 @@ // Application layer builder.Services.AddApplication(); builder.Services.AddSingleton(); +// Scheduled-eval support (Phase 12 RLOps slice 5a): shared single-slot overlap gate + +// pure regression detector, consumed by both the admin trigger and ContinuousEvalWorker. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -222,6 +226,8 @@ // AI-058: weekly semantic concept clustering (groups vocab by meaning across all books) builder.Services.AddHostedService(); +// Phase 12 RLOps slice 5a: scheduled continuous evals (OFF by default — Eval:Scheduled:Enabled). +builder.Services.AddHostedService(); // Rate limiting builder.Services.AddRateLimiter(options => diff --git a/backend/src/Api/Services/ContinuousEvalWorker.cs b/backend/src/Api/Services/ContinuousEvalWorker.cs new file mode 100644 index 00000000..bd5bbd0d --- /dev/null +++ b/backend/src/Api/Services/ContinuousEvalWorker.cs @@ -0,0 +1,248 @@ +using System.Data.Common; +using Application.Ai; +using Application.Auth; +using Application.Common.Interfaces; +using Microsoft.EntityFrameworkCore; +using TextStack.Ai.Core; +using TextStack.Ai.EvalSuite; + +namespace Api.Services; + +/// +/// Phase 12 RLOps slice 5a: scheduled continuous evals. Periodically (default daily) re-runs +/// the SAME the admin Evals tab uses, persists with +/// RunType="scheduled", then compares against the prior scheduled run and emails an admin +/// alert on any regression. OFF by default (Eval:Scheduled:Enabled=false) — it spends judge +/// $ when enabled. Multi-replica-safe via a Postgres advisory lock; in-process-safe via +/// ; cost-capped via . A failure logs + the +/// worker keeps looping — it never crashes the host. +/// +public sealed class ContinuousEvalWorker( + IServiceScopeFactory scopeFactory, + IConfiguration config, + IEvalRunGate gate, + EvalRegressionDetector regressionDetector, + ILogger logger) : BackgroundService +{ + // Fixed advisory-lock key (arbitrary but stable) — all replicas contend on the same key so + // only one runs the scheduled eval per tick. + private const long AdvisoryLockKey = 0x7E5C_0E5A_1L; // "TeSC EvAl"-ish marker + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Continuous eval worker started"); + + // Startup delay so a cold boot doesn't immediately fire an eval (mirror other workers). + try { await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken); } + catch (OperationCanceledException) { return; } + + var checkHours = Math.Max(config.GetValue("Eval:Scheduled: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, "Continuous eval 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: prove the disabled + // short-circuit does zero DB/scope/gate work, and that the loop's catch survives a throw. + internal async Task TickAsync(CancellationToken ct) + { + if (!config.GetValue("Eval:Scheduled:Enabled", false)) + return; + + using var scope = scopeFactory.CreateScope(); + var sp = scope.ServiceProvider; + var db = sp.GetRequiredService(); + + // "Is it due?" — no scheduled row newer than (now − IntervalHours). + var intervalHours = Math.Max(config.GetValue("Eval:Scheduled:IntervalHours", 24), 1); + var now = DateTimeOffset.UtcNow; + var lastScheduledAt = await db.EvalRuns + .Where(r => r.RunType == "scheduled") + .OrderByDescending(r => r.CreatedAt) + .Select(r => (DateTimeOffset?)r.CreatedAt) + .FirstOrDefaultAsync(ct); + if (!IsDue(lastScheduledAt, now, intervalHours)) + return; + + // Cost safety: skip if today's judge spend already exceeds the cap (0 = no cap). Fail-open. + var budget = config.GetValue("Eval:Scheduled:DailyBudgetUsd", 0m); + if (budget > 0m) + { + decimal spent; + try { spent = sp.GetRequiredService().SpentTodayUsd("eval.judge"); } + catch { spent = 0m; } // fail-open: a tracker glitch must not block the scheduled run + if (spent > budget) + { + logger.LogInformation( + "Scheduled eval skipped: judge spend ${Spent} > cap ${Budget}", spent, budget); + return; + } + } + + // In-process overlap guard (shared with the admin trigger). + if (!gate.TryEnter()) + { + logger.LogInformation("Scheduled eval skipped: another eval run is in progress"); + return; + } + + DbConnection? conn = null; + var advisoryHeld = false; + try + { + // Cross-replica guard: only one replica runs the scheduled eval per due-window. + conn = await OpenConnectionAsync(db, ct); + advisoryHeld = await TryAdvisoryLockAsync(conn, ct); + if (!advisoryHeld) + { + logger.LogInformation("Scheduled eval skipped: advisory lock held by another replica"); + return; + } + + await RunAndAlertAsync(sp, db, ct); + } + finally + { + // Release the session-level advisory lock on the SAME connection that took it. Use + // CancellationToken.None, NOT the (possibly already-cancelled on shutdown) tick token — + // otherwise the unlock SQL is skipped and the lock leaks onto a pooled connection, + // poisoning it for later requests until the session is physically dropped. + if (advisoryHeld && conn is not null) + await UnlockAsync(conn); + gate.Exit(); + } + } + + /// Pure due-check: a scheduled run is due when there's no prior scheduled run, or + /// the last one is at least old. Extracted for unit testing. + public static bool IsDue(DateTimeOffset? lastScheduledAt, DateTimeOffset now, int intervalHours) + { + if (lastScheduledAt is not { } last) + return true; + return now - last >= TimeSpan.FromHours(intervalHours); + } + + private async Task RunAndAlertAsync(IServiceProvider sp, IAppDbContext db, CancellationToken ct) + { + var features = config.GetSection("Eval:Scheduled:Features").Get() ?? ["explain"]; + var judgeModelId = config["Eval:JudgeModel"] ?? "gpt-4.1"; + + // Snapshot prior scheduled scores BEFORE the new run is written. + var prior = await LoadLatestScheduledScoresAsync(db, features, ct); + + var runner = sp.GetRequiredService(); + var gateway = sp.GetRequiredService(); + var judge = sp.GetRequiredKeyedService("openai-judge"); + var gitSha = Environment.GetEnvironmentVariable("GIT_SHA"); + + var results = await runner.RunAsync( + _ => gateway, judge, judgeModelId, features, persist: true, db, gitSha, ct, runType: "scheduled"); + + logger.LogInformation("Scheduled eval completed: {Count} feature(s)", results.Count); + + var current = results + .Select(r => (r.Feature, r.ModelId, (double)Math.Round((decimal)r.Summary.MeanOverall, 3))) + .ToList(); + + var drop = config.GetValue("Eval:Scheduled:RegressionDrop", 0.5); + var regressions = regressionDetector.Detect(prior, current, drop); + if (regressions.Count == 0) + return; + + // Fire-and-forget alert; swallow+log so an email hiccup never affects the run. + try + { + var email = sp.GetRequiredService(); + var lines = string.Join(", ", regressions.Select(r => + $"{r.Feature} ({r.ModelId}) {r.PriorScore:0.00}→{r.CurrentScore:0.00}, −{r.Drop:0.00}")); + var subject = $"[TextStack] Scheduled eval regression: {lines}"; + var body = "

The scheduled eval run flagged a quality regression:

    " + + string.Join("", regressions.Select(r => + $"
  • {r.Feature} ({r.ModelId}): {r.PriorScore:0.00} → {r.CurrentScore:0.00} " + + $"(drop {r.Drop:0.00})
  • ")) + + "
"; + await email.SendAdminAlertAsync(subject, body, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send scheduled-eval regression alert"); + } + } + + /// Latest scheduled per-(feature, model) score, for the regression baseline. + private static async Task> LoadLatestScheduledScoresAsync( + IAppDbContext db, IEnumerable features, CancellationToken ct) + { + var featureSet = features.ToHashSet(); + // Small table; fetch recent scheduled rows then keep the newest per (feature, model). + var rows = await db.EvalRuns + .Where(r => r.RunType == "scheduled") + .OrderByDescending(r => r.CreatedAt) + .Take(500) + .Select(r => new { r.Feature, r.ModelId, r.Score, r.CreatedAt }) + .ToListAsync(ct); + + return rows + .Where(r => featureSet.Contains(r.Feature)) + .GroupBy(r => (r.Feature, r.ModelId)) + .Select(g => (g.Key.Feature, g.Key.ModelId, (double)g.First().Score)) + .ToList(); + } + + 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 b8ab039f..08cf4e21 100644 --- a/backend/src/Api/appsettings.json +++ b/backend/src/Api/appsettings.json @@ -34,7 +34,16 @@ } }, "Eval": { - "JudgeModel": "gpt-4.1" + "JudgeModel": "gpt-4.1", + "Scheduled": { + "__comment": "OFF by default; spends judge $ when enabled (ContinuousEvalWorker, slice 5a).", + "Enabled": false, + "CheckIntervalHours": 1, + "IntervalHours": 24, + "Features": [ "explain" ], + "RegressionDrop": 0.5, + "DailyBudgetUsd": 0 + } }, "Translate": { "CachePath": "data/translate-cache", diff --git a/backend/src/Application/Ai/EvalRegressionDetector.cs b/backend/src/Application/Ai/EvalRegressionDetector.cs new file mode 100644 index 00000000..88e6fbfb --- /dev/null +++ b/backend/src/Application/Ai/EvalRegressionDetector.cs @@ -0,0 +1,44 @@ +namespace Application.Ai; + +/// One flagged regression: a feature+model whose score dropped at least the +/// configured threshold versus its prior scheduled run. +public readonly record struct EvalRegression( + string Feature, + string ModelId, + double PriorScore, + double CurrentScore, + double Drop); + +/// +/// Pure regression check (Phase 12 RLOps slice 5a): compares the prior scheduled eval scores +/// against the just-written current scores. For each (feature, model) present in BOTH sets, a +/// drop of dropThreshold or more (prior − current) is a regression. A feature with no +/// prior is never flagged (first scheduled run can't regress). No deps — unit-testable. +/// +public sealed class EvalRegressionDetector +{ + public IReadOnlyList Detect( + IReadOnlyList<(string Feature, string ModelId, double Score)> prior, + IReadOnlyList<(string Feature, string ModelId, double Score)> current, + double dropThreshold) + { + // Index prior by (feature, model). If duplicates exist, the first wins (callers pass + // one row per pair, but be defensive). + var priorByKey = new Dictionary<(string, string), double>(); + foreach (var p in prior) + priorByKey.TryAdd((p.Feature, p.ModelId), p.Score); + + var regressions = new List(); + foreach (var c in current) + { + if (!priorByKey.TryGetValue((c.Feature, c.ModelId), out var priorScore)) + continue; // no prior for this feature+model → can't regress + + var drop = priorScore - c.Score; + if (drop >= dropThreshold) + regressions.Add(new EvalRegression(c.Feature, c.ModelId, priorScore, c.Score, drop)); + } + + return regressions; + } +} diff --git a/backend/src/Application/Ai/IEvalRunGate.cs b/backend/src/Application/Ai/IEvalRunGate.cs new file mode 100644 index 00000000..36776057 --- /dev/null +++ b/backend/src/Application/Ai/IEvalRunGate.cs @@ -0,0 +1,30 @@ +namespace Application.Ai; + +/// +/// In-process overlap guard: at most ONE eval run at a time across the whole API process +/// (Phase 12 RLOps slice 5a). Both the admin "Run evals" trigger and the scheduled +/// ContinuousEvalWorker check this so an admin-triggered run and a scheduled run can't +/// collide (they share the same EvalSuiteRunner persistence path). Registered as a singleton. +/// +/// NOTE: this is process-local only. Cross-replica overlap is additionally guarded by a +/// Postgres advisory lock in the worker; the gate covers the single-process case. +/// +public interface IEvalRunGate +{ + /// Try to acquire the single eval slot. Returns false if a run is already in + /// progress. On success the caller MUST call in a finally. + bool TryEnter(); + + /// Release the eval slot. Idempotent-safe to call once per successful TryEnter. + void Exit(); +} + +/// Interlocked flag impl of — a single 0/1 slot. +public sealed class EvalRunGate : IEvalRunGate +{ + private int _running; // 0 = free, 1 = running + + public bool TryEnter() => Interlocked.CompareExchange(ref _running, 1, 0) == 0; + + public void Exit() => Interlocked.Exchange(ref _running, 0); +} diff --git a/backend/src/Contracts/Admin/AiQualityDtos.cs b/backend/src/Contracts/Admin/AiQualityDtos.cs index ecabc30e..a71f6b6d 100644 --- a/backend/src/Contracts/Admin/AiQualityDtos.cs +++ b/backend/src/Contracts/Admin/AiQualityDtos.cs @@ -102,7 +102,8 @@ public record RunEvalsRequest(string[]? Features, string? Judge); /// State of the in-app eval runner (one run at a time). public record EvalStatusDto(bool Running, DateTimeOffset? StartedAt, string? LastError); -/// One persisted eval run for the Evals tab history. +/// One persisted eval run for the Evals tab history. RunType is +/// "manual" (admin-triggered) or "scheduled" (ContinuousEvalWorker). public record EvalRunDto( Guid Id, string Feature, @@ -112,6 +113,17 @@ public record EvalRunDto( int N, string? BreakdownJson, string? GitSha, + string RunType, + DateTimeOffset CreatedAt); + +/// One point on the scheduled-eval trend (Drift tab, slice 5b). Only +/// RunType='scheduled' rows, newest-first. +public record ScheduledEvalPointDto( + string Feature, + string ModelId, + double Score, + int N, + string GitSha, DateTimeOffset CreatedAt); // ── Shadow-run comparison + models registry (Phase 12 RLOps) ────────────────── diff --git a/backend/src/Domain/Entities/EvalRun.cs b/backend/src/Domain/Entities/EvalRun.cs index 90d6222f..473b43c9 100644 --- a/backend/src/Domain/Entities/EvalRun.cs +++ b/backend/src/Domain/Entities/EvalRun.cs @@ -20,5 +20,11 @@ public class EvalRun public int N { get; set; } public string? BreakdownJson { get; set; } public string? GitSha { get; set; } + + /// How this run was triggered: "manual" (admin Evals tab) or + /// "scheduled" (ContinuousEvalWorker, Phase 12 RLOps slice 5a). Drives the + /// Drift tab's scheduled-only trend + the worker's "is it due" / regression checks. + public string RunType { get; set; } = "manual"; + public DateTimeOffset CreatedAt { get; set; } } diff --git a/backend/src/Infrastructure/Migrations/20260618172015_AddEvalRunType.Designer.cs b/backend/src/Infrastructure/Migrations/20260618172015_AddEvalRunType.Designer.cs new file mode 100644 index 00000000..2f7d553b --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260618172015_AddEvalRunType.Designer.cs @@ -0,0 +1,5033 @@ +// +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("20260618172015_AddEvalRunType")] + partial class AddEvalRunType + { + /// + 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.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/20260618172015_AddEvalRunType.cs b/backend/src/Infrastructure/Migrations/20260618172015_AddEvalRunType.cs new file mode 100644 index 00000000..9a484b4f --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260618172015_AddEvalRunType.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddEvalRunType : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "run_type", + table: "eval_runs", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "manual"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "run_type", + table: "eval_runs"); + } + } +} diff --git a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 3b91c2d0..6149691e 100644 --- a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -1096,6 +1096,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs index c1a88c2e..4f990add 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs @@ -104,6 +104,9 @@ private static void ConfigureAi(ModelBuilder modelBuilder) e.Property(x => x.GitSha).HasMaxLength(64); e.Property(x => x.Score).HasColumnType("numeric(6,3)"); e.Property(x => x.BreakdownJson).HasColumnType("jsonb"); + // RunType (slice 5a): "manual" | "scheduled". Existing rows backfill to "manual" + // via the migration default; the Drift tab + worker filter on RunType='scheduled'. + e.Property(x => x.RunType).HasMaxLength(20).HasDefaultValue("manual"); }); } } diff --git a/tests/TextStack.AiEvals/EvalSuiteRunnerTests.cs b/tests/TextStack.AiEvals/EvalSuiteRunnerTests.cs index d673f544..e0f0a435 100644 --- a/tests/TextStack.AiEvals/EvalSuiteRunnerTests.cs +++ b/tests/TextStack.AiEvals/EvalSuiteRunnerTests.cs @@ -49,4 +49,51 @@ public async Task RunAsync_scores_goldens_via_rubric_evaluator() Assert.Equal(3.0, r.Summary.Mean2, 3); Assert.Equal(5.0, r.Summary.Mean3, 3); } + + [Fact] + public async Task RunAsync_DefaultRunType_PersistsManual() + { + var ct = TestContext.Current.CancellationToken; + var generator = new FixedLlm("GENRE: Fiction\nYEAR: 1900\nDESCRIPTION: A canned description."); + var judge = new FixedLlm("{\"d1\": 4, \"d2\": 3, \"d3\": 5, \"rationale\": \"ok\"}"); + var db = new CapturingDb(); + + var runner = new EvalSuiteRunner(NullLogger.Instance); + await runner.RunAsync( + generatorFor: _ => generator, + judgeClient: judge, + judgeModelId: "judge-test", + keys: ["bookmeta"], + persist: true, + db: db, + gitSha: null, + ct: ct); // runType omitted → defaults to "manual" + + var run = Assert.Single(db.Added); + Assert.Equal("manual", run.RunType); + } + + [Fact] + public async Task RunAsync_ScheduledRunType_PersistsScheduled() + { + var ct = TestContext.Current.CancellationToken; + var generator = new FixedLlm("GENRE: Fiction\nYEAR: 1900\nDESCRIPTION: A canned description."); + var judge = new FixedLlm("{\"d1\": 4, \"d2\": 3, \"d3\": 5, \"rationale\": \"ok\"}"); + var db = new CapturingDb(); + + var runner = new EvalSuiteRunner(NullLogger.Instance); + await runner.RunAsync( + generatorFor: _ => generator, + judgeClient: judge, + judgeModelId: "judge-test", + keys: ["bookmeta"], + persist: true, + db: db, + gitSha: null, + ct: ct, + runType: "scheduled"); + + var run = Assert.Single(db.Added); + Assert.Equal("scheduled", run.RunType); + } } diff --git a/tests/TextStack.IntegrationTests/AdminEvalTrendEndpointTests.cs b/tests/TextStack.IntegrationTests/AdminEvalTrendEndpointTests.cs new file mode 100644 index 00000000..8c595f0c --- /dev/null +++ b/tests/TextStack.IntegrationTests/AdminEvalTrendEndpointTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text.Json; + +namespace TextStack.IntegrationTests; + +/// +/// Integration tests for the scheduled-eval trend endpoint (Phase 12 RLOps slice 5a), against +/// the live API on the admin host. The endpoint returns RunType='scheduled' eval rows newest-first +/// (the Drift tab in slice 5b consumes it). Scheduled evals are OFF by default, so the regression +/// guarantee is: admin-gated (401 without auth), and a well-formed (possibly empty) array when +/// authed. Each row, if present, carries the scheduled-eval point shape. +/// +/// To run: `docker compose up` (API on :8080) with `ENABLE_TEST_AUTH=true`; runs in CI. +/// +public class AdminEvalTrendEndpointTests : IClassFixture +{ + private readonly AuthenticatedApiFixture _fixture; + + public AdminEvalTrendEndpointTests(AuthenticatedApiFixture fixture) => _fixture = fixture; + + [Fact] + public async Task GetEvalTrend_NoAuth_Unauthorized() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/admin/ai-quality/drift/eval-trend"); + 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 GetEvalTrend_Authed_ReturnsScheduledRowsArray() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable"); + + var request = _fixture.CreateAdminRequest(HttpMethod.Get, "/admin/ai-quality/drift/eval-trend"); + 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; + // Scheduled evals OFF by default → empty array is the expected steady state. Either way the + // payload is a well-formed array; every present row carries the scheduled-eval point shape. + 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("modelId", out _)); + Assert.True(row.TryGetProperty("score", out _)); + Assert.True(row.TryGetProperty("n", out _)); + Assert.True(row.TryGetProperty("createdAt", out _)); + } + } + + [Fact] + public async Task GetEvalTrend_FilteredByMissingFeature_ReturnsEmpty() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable"); + + var request = _fixture.CreateAdminRequest( + HttpMethod.Get, "/admin/ai-quality/drift/eval-trend?feature=__no_such_feature__&limit=5"); + 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/ContinuousEvalWorkerDueTests.cs b/tests/TextStack.UnitTests/ContinuousEvalWorkerDueTests.cs new file mode 100644 index 00000000..6899d0d5 --- /dev/null +++ b/tests/TextStack.UnitTests/ContinuousEvalWorkerDueTests.cs @@ -0,0 +1,127 @@ +using Api.Services; +using Application.Ai; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace TextStack.UnitTests; + +/// +/// Pure "is it due" scheduler predicate (Phase 12 RLOps slice 5a): a scheduled run is due when +/// there's no prior scheduled run, or the newest one is ≥ IntervalHours old. +/// +public class ContinuousEvalWorkerDueTests +{ + private static readonly DateTimeOffset Now = new(2026, 6, 18, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void IsDue_NoPriorRun_IsDue() + { + Assert.True(ContinuousEvalWorker.IsDue(lastScheduledAt: null, Now, intervalHours: 24)); + } + + [Fact] + public void IsDue_ExactlyIntervalAgo_IsDue() + { + var last = Now.AddHours(-24); + Assert.True(ContinuousEvalWorker.IsDue(last, Now, intervalHours: 24)); + } + + [Fact] + public void IsDue_OlderThanInterval_IsDue() + { + var last = Now.AddHours(-30); + Assert.True(ContinuousEvalWorker.IsDue(last, Now, intervalHours: 24)); + } + + [Fact] + public void IsDue_WithinInterval_NotDue() + { + var last = Now.AddHours(-23); + Assert.False(ContinuousEvalWorker.IsDue(last, Now, intervalHours: 24)); + } + + // A scoped-service provider that EXPLODES the moment anything resolves it. If the disabled + // short-circuit (Eval:Scheduled:Enabled=false) ever creates a scope / touches the db / enters + // the gate before bailing, this throws — proving zero side effects when OFF. + 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 + { + ["Eval:Scheduled:Enabled"] = enabled ? "true" : "false", + }) + .Build(); + + private static ContinuousEvalWorker Worker(IServiceScopeFactory scopeFactory, IConfiguration config) => + new(scopeFactory, config, new EvalRunGate(), new EvalRegressionDetector(), + NullLogger.Instance); + + [Fact] + public async Task TickAsync_Disabled_ShortCircuitsBeforeAnyScopeOrDb() + { + var gate = new EvalRunGate(); + var worker = new ContinuousEvalWorker( + new ExplodingScopeFactory(), Config(enabled: false), gate, + new EvalRegressionDetector(), NullLogger.Instance); + + // Must NOT throw (scope factory would explode if used) and must NOT enter the gate. + await worker.TickAsync(TestContext.Current.CancellationToken); + + // Gate still free → disabled path never touched it. + Assert.True(gate.TryEnter()); + } + + [Fact] + public async Task TickAsync_EnabledButScopeThrows_PropagatesButGateNotLeaked() + { + // When enabled the worker DOES create a scope; here scope creation itself throws. + // TickAsync surfaces it (the ExecuteAsync loop catches it — see ExecuteAsync_TickThrows below), + // and critically the gate must NOT be left entered, since the throw happens before TryEnter. + var gate = new EvalRunGate(); + var worker = new ContinuousEvalWorker( + new ExplodingScopeFactory(), Config(enabled: true), gate, + new EvalRegressionDetector(), NullLogger.Instance); + + await Assert.ThrowsAsync( + () => worker.TickAsync(TestContext.Current.CancellationToken)); + + // The throw was before gate.TryEnter() → slot is still free, no leak. + Assert.True(gate.TryEnter()); + } + + // The invariant: a throwing tick must NEVER kill the BackgroundService. This reproduces the + // ExecuteAsync loop body's guard (try/catch around the tick) and proves the loop survives. + [Fact] + public async Task ExecuteAsync_TickThrows_LoopSurvives() + { + var ticks = 0; + var survived = true; + Func throwingTick = () => + { + ticks++; + throw new InvalidOperationException("db down"); + }; + + // Mirror ContinuousEvalWorker.ExecuteAsync's per-tick guard exactly. + for (var i = 0; i < 3; i++) + { + try + { + await throwingTick(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // swallowed + logged in prod; loop continues + } + } + + Assert.True(survived); + Assert.Equal(3, ticks); // proved the loop kept ticking past each throw + } +} diff --git a/tests/TextStack.UnitTests/EvalRegressionDetectorTests.cs b/tests/TextStack.UnitTests/EvalRegressionDetectorTests.cs new file mode 100644 index 00000000..da5fa684 --- /dev/null +++ b/tests/TextStack.UnitTests/EvalRegressionDetectorTests.cs @@ -0,0 +1,97 @@ +using Application.Ai; + +namespace TextStack.UnitTests; + +/// +/// Pure regression-detection coverage (Phase 12 RLOps slice 5a): a drop ≥ threshold for a +/// feature+model present in both prior and current is flagged; below-threshold and no-prior +/// cases are not; feature+model isolation holds. +/// +public class EvalRegressionDetectorTests +{ + private static readonly EvalRegressionDetector Detector = new(); + + [Fact] + public void Detect_DropAtThreshold_FlagsRegression() + { + var prior = new[] { ("explain", "gpt-4.1-nano", 4.0) }; + var current = new[] { ("explain", "gpt-4.1-nano", 3.5) }; + + var result = Detector.Detect(prior, current, dropThreshold: 0.5); + + var r = Assert.Single(result); + Assert.Equal("explain", r.Feature); + Assert.Equal("gpt-4.1-nano", r.ModelId); + Assert.Equal(4.0, r.PriorScore); + Assert.Equal(3.5, r.CurrentScore); + Assert.Equal(0.5, r.Drop, 6); + } + + [Fact] + public void Detect_DropBelowThreshold_NoFlag() + { + var prior = new[] { ("explain", "gpt-4.1-nano", 4.0) }; + var current = new[] { ("explain", "gpt-4.1-nano", 3.6) }; // drop 0.4 < 0.5 + + var result = Detector.Detect(prior, current, dropThreshold: 0.5); + + Assert.Empty(result); + } + + [Fact] + public void Detect_NoPriorForFeature_NoFlag() + { + var prior = Array.Empty<(string, string, double)>(); + var current = new[] { ("explain", "gpt-4.1-nano", 1.0) }; + + var result = Detector.Detect(prior, current, dropThreshold: 0.5); + + Assert.Empty(result); + } + + [Fact] + public void Detect_ScoreImproved_NoFlag() + { + var prior = new[] { ("explain", "gpt-4.1-nano", 3.0) }; + var current = new[] { ("explain", "gpt-4.1-nano", 4.5) }; + + var result = Detector.Detect(prior, current, dropThreshold: 0.5); + + Assert.Empty(result); + } + + [Fact] + public void Detect_IsolatesPerFeatureAndModel() + { + var prior = new[] + { + ("explain", "gpt-4.1-nano", 4.0), + ("vocab", "gpt-4.1-nano", 4.0), + ("explain", "other-model", 4.0), + }; + var current = new[] + { + ("explain", "gpt-4.1-nano", 3.0), // regressed + ("vocab", "gpt-4.1-nano", 4.0), // stable + ("explain", "other-model", 4.0), // different model — stable + }; + + var result = Detector.Detect(prior, current, dropThreshold: 0.5); + + var r = Assert.Single(result); + Assert.Equal("explain", r.Feature); + Assert.Equal("gpt-4.1-nano", r.ModelId); + } + + [Fact] + public void Detect_SameFeatureDifferentModel_NotMatchedToPrior() + { + // Prior has the feature but under a DIFFERENT model id → current model has no prior → no flag. + var prior = new[] { ("explain", "old-model", 4.0) }; + var current = new[] { ("explain", "new-model", 1.0) }; + + var result = Detector.Detect(prior, current, dropThreshold: 0.5); + + Assert.Empty(result); + } +} diff --git a/tests/TextStack.UnitTests/EvalRunGateTests.cs b/tests/TextStack.UnitTests/EvalRunGateTests.cs new file mode 100644 index 00000000..6893de00 --- /dev/null +++ b/tests/TextStack.UnitTests/EvalRunGateTests.cs @@ -0,0 +1,90 @@ +using Application.Ai; + +namespace TextStack.UnitTests; + +/// +/// Single-slot overlap gate (Phase 12 RLOps slice 5a): one TryEnter succeeds, a second fails +/// until Exit, then it can be re-entered. Shared by the admin trigger + ContinuousEvalWorker. +/// +public class EvalRunGateTests +{ + [Fact] + public void TryEnter_FirstCall_Succeeds() + { + var gate = new EvalRunGate(); + Assert.True(gate.TryEnter()); + } + + [Fact] + public void TryEnter_SecondCallWhileHeld_Fails() + { + var gate = new EvalRunGate(); + Assert.True(gate.TryEnter()); + Assert.False(gate.TryEnter()); + } + + [Fact] + public void TryEnter_AfterExit_SucceedsAgain() + { + var gate = new EvalRunGate(); + Assert.True(gate.TryEnter()); + gate.Exit(); + Assert.True(gate.TryEnter()); + } + + [Fact] + public void TryEnter_Concurrent_OnlyOneWins() + { + var gate = new EvalRunGate(); + var winners = 0; + + Parallel.For(0, 64, _ => + { + if (gate.TryEnter()) + Interlocked.Increment(ref winners); + }); + + Assert.Equal(1, winners); + } + + // Both callers (admin trigger + ContinuousEvalWorker) wrap the guarded run in try/finally so + // Exit ALWAYS runs even if the run throws. If Exit leaked on failure the slot would be + // permanently blocked until process restart. Mirror that exact shape. + [Fact] + public async Task Exit_AfterRunThrows_SlotReleased() + { + var gate = new EvalRunGate(); + + Assert.True(gate.TryEnter()); + await Assert.ThrowsAsync(async () => + { + try + { + await Task.Yield(); + throw new InvalidOperationException("run blew up"); + } + finally + { + gate.Exit(); + } + }); + + // Slot must be free again — a subsequent run can enter. + Assert.True(gate.TryEnter()); + } + + // Exit is Interlocked.Exchange(→0): calling it without a matching Enter, or twice, never throws + // and never corrupts the slot (free stays free). Guards against a double-Exit / Exit-without-Enter + // deadlocking the gate. + [Fact] + public void Exit_WithoutEnterOrTwice_IsHarmless() + { + var gate = new EvalRunGate(); + + gate.Exit(); // Exit without Enter — no throw, slot free + Assert.True(gate.TryEnter()); + gate.Exit(); + gate.Exit(); // double Exit — still free, no throw + Assert.True(gate.TryEnter()); + } +}