From 600472ac135991cc8942e374206eacbfa1bf2169 Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Thu, 18 Jun 2026 11:06:47 -0400 Subject: [PATCH] feat(ai): table-driven routing + one-click promote/rollback (AI-077, Phase 12 slice 3) The models registry becomes a routing input; admin can swap a feature's model in one click, no redeploy. - Table-driven primary routing: ModelGateway.Route resolves a feature's primary provider key from the registry (cached snapshot, TTL Ai:Routes:CacheSeconds=30s, never-throws) before config. Fallback chain registry -> Ai:Routes -> Ai:DefaultProvider -> openai; unknown/empty/throwing registry can NEVER fail a live LLM call. Snapshot duplicate-key resilient. - Promote (Shadow->Primary, demotes incumbent to Shadow) + rollback (inverse of latest promotion), transactional + append-only model_promotions audit, cache invalidated only after commit. Partial unique index (feature_tag) WHERE status='Primary' => exactly one primary; concurrent promote -> 409. - Shadow runs auto-upsert a promotable Shadow candidate; MaybeShadow skips self-shadowing after a promote. - Admin Models tab actionable: Promote/Rollback behind confirm dialogs, server errors surfaced, instant refetch. - Shadow stays config-driven this slice (registry-driven shadow + cost-cap = later). architect -> backend+frontend -> adversarial QA (SHIP; 2 P1 fixes). 724 unit tests green; admin tsc+build clean; solution clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 + apps/admin/src/api/client.ts | 20 + apps/admin/src/pages/AiQualityPage.tsx | 147 +- .../TextStack.Ai.Core/IModelRouteProvider.cs | 20 + .../Ai/TextStack.Ai.Core/IShadowRunWriter.cs | 5 +- .../src/Ai/TextStack.Ai.Llm/ModelGateway.cs | 78 +- .../Api/Endpoints/AdminAiQualityEndpoints.cs | 44 + .../src/Application/Ai/DbShadowRunWriter.cs | 43 +- .../Application/Ai/ModelPromotionService.cs | 149 + .../Ai/RegistryModelRouteProvider.cs | 94 + .../Common/Interfaces/IAppDbContext.cs | 1 + .../src/Application/DependencyInjection.cs | 11 +- backend/src/Contracts/Admin/AiQualityDtos.cs | 10 + backend/src/Domain/Entities/ModelPromotion.cs | 38 + backend/src/Domain/Enums/PromotionAction.cs | 13 + ...PromotionAndPrimaryUniqueIndex.Designer.cs | 5025 +++++++++++++++++ ..._AddModelPromotionAndPrimaryUniqueIndex.cs | 59 + .../Migrations/AppDbContextModelSnapshot.cs | 71 + .../Persistence/AppDbContext.Ai.cs | 22 + .../Persistence/AppDbContext.cs | 1 + tests/TextStack.AiEvals/CapturingDb.cs | 1 + .../AdminModelPromotionEndpointTests.cs | 112 + .../ModelGatewayShadowTests.cs | 59 +- .../TextStack.UnitTests/ModelGatewayTests.cs | 111 +- .../ModelPromotionServiceTests.cs | 310 + .../RegistryModelRouteProviderTests.cs | 166 + 26 files changed, 6601 insertions(+), 19 deletions(-) create mode 100644 backend/src/Ai/TextStack.Ai.Core/IModelRouteProvider.cs create mode 100644 backend/src/Application/Ai/ModelPromotionService.cs create mode 100644 backend/src/Application/Ai/RegistryModelRouteProvider.cs create mode 100644 backend/src/Domain/Entities/ModelPromotion.cs create mode 100644 backend/src/Domain/Enums/PromotionAction.cs create mode 100644 backend/src/Infrastructure/Migrations/20260618145458_AddModelPromotionAndPrimaryUniqueIndex.Designer.cs create mode 100644 backend/src/Infrastructure/Migrations/20260618145458_AddModelPromotionAndPrimaryUniqueIndex.cs create mode 100644 tests/TextStack.IntegrationTests/AdminModelPromotionEndpointTests.cs create mode 100644 tests/TextStack.UnitTests/ModelPromotionServiceTests.cs create mode 100644 tests/TextStack.UnitTests/RegistryModelRouteProviderTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f642fc20..1d714da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Phase 12 — table-driven routing + one-click promote/rollback (AI-077, slice 3) (2026-06-18) + +The `models` registry is now a **routing input**, not just an audit log — and an admin can swap which model serves a feature in one click, no redeploy. +- **Table-driven primary routing**: `ModelGateway.Route` resolves a feature's primary **provider key** from the registry (`status=Primary`) before the config route. New `IModelRouteProvider` (Core; impl `RegistryModelRouteProvider` in Application) serves a cached immutable `feature→provider_key` snapshot (TTL `Ai:Routes:CacheSeconds`, default 30s; built in a fresh scope under a double-checked lock; `Invalidate()` drops it). **The gateway can never fail a live call because of the registry** — null/throwing provider, empty registry, or a row whose ProviderKey has no keyed `ILlmService` all fall back: registry → config `Ai:Routes:{tag}` → `Ai:DefaultProvider` → `openai` (nullable keyed resolve + log, never throw). The snapshot build is duplicate-key resilient (oldest wins) even if the one-Primary invariant were ever violated. +- **Promote / rollback** (first mutating Phase-12 endpoints): `POST /admin/ai-quality/models/{id}/promote` (Shadow→Primary, demotes the incumbent to Shadow so it keeps shadowing) and `POST /admin/ai-quality/models/{feature}/rollback` (replays the inverse of the latest promotion). Each is transactional with an append-only **`model_promotions`** audit trail (who/when via `GetAdminUserId()`), invalidates the route cache **only after commit**, and is **strictly Shadow→Primary** (Retired rejected → 400). A DB **partial unique index** `(feature_tag) WHERE status='Primary'` enforces exactly one Primary per feature — concurrent promotes surface as **409**, never two primaries. +- **Candidates**: a shadow run now auto-upserts a promotable `Shadow` `ModelRegistration` for its (feature, provider, model) (guarded on the fire-and-forget path), so enabling a shadow route makes a promote target appear. After a promote, `MaybeShadow` **skips self-shadowing** (resolved shadow key == primary key). +- **Admin UI**: the Models tab is now actionable — Promote on Shadow rows, Rollback on Primary rows (shown only when a Shadow candidate exists), each behind a confirm dialog (these change prod routing live); server error text (409 race / 400 reasons) surfaced without closing the dialog; refetch reflects the new split immediately. + +Shadow remains config-driven (`Ai:Shadow:Routes`) this slice — registry Shadow rows are a candidate/audit list, not a shadow-routing input (registry-driven shadow + cost-cap = later slices). Migration `AddModelPromotionAndPrimaryUniqueIndex`. Architect → backend + frontend (parallel) → adversarial QA (verdict SHIP; 2 P1 fixes: snapshot duplicate-resilience + concurrent-promote 409 coverage). 724 unit tests green; admin tsc + build clean; solution clean. + ### Phase 12 — admin Shadow + Models tabs (AI-076, slice 2) (2026-06-18) Makes the slice-1 shadow data visible. Two **read-only** tabs on the admin AI-quality page (`/ai-quality`): diff --git a/apps/admin/src/api/client.ts b/apps/admin/src/api/client.ts index 1fbfd2ba..9dfee12f 100644 --- a/apps/admin/src/api/client.ts +++ b/apps/admin/src/api/client.ts @@ -616,6 +616,14 @@ export interface ModelRegistration { export interface ModelsRegistry { models: ModelRegistration[] } +export interface ModelPromotionResult { + featureTag: string + newPrimary: ModelRegistration + demotedToShadow: ModelRegistration | null + action: 'Promote' | 'Rollback' + adminUserId: string | null + createdAt: string +} async function fetchJson(path: string, init?: RequestInit): Promise { const res = await fetch(`${API_BASE}${path}`, { @@ -1282,6 +1290,18 @@ export const adminApi = { return fetchJson('/admin/ai-quality/models') }, + promoteModel: async (id: string): Promise => { + return fetchJson(`/admin/ai-quality/models/${id}/promote`, { + method: 'POST', + }) + }, + + rollbackModel: async (feature: string): Promise => { + return fetchJson(`/admin/ai-quality/models/${encodeURIComponent(feature)}/rollback`, { + method: 'POST', + }) + }, + // Podcasts generatePodcast: async (editionId: string, lang?: string, force?: boolean): Promise => { return fetchJson('/admin/podcasts', { diff --git a/apps/admin/src/pages/AiQualityPage.tsx b/apps/admin/src/pages/AiQualityPage.tsx index 73d2b0b6..4647ae60 100644 --- a/apps/admin/src/pages/AiQualityPage.tsx +++ b/apps/admin/src/pages/AiQualityPage.tsx @@ -15,6 +15,7 @@ import { ShadowPair, ShadowSample, ModelRegistration, + ModelPromotionResult, } from '../api/client' type Tab = 'summary' | 'traces' | 'transcripts' | 'evals' | 'shadow' | 'models' @@ -1126,12 +1127,21 @@ function modelStatusColor(status: string): string { return '#6b7280' // Retired } +// A pending confirm action: which row + what kind of mutation. +type ModelAction = + | { kind: 'promote'; row: ModelRegistration } + | { kind: 'rollback'; row: ModelRegistration } + function ModelsTab() { const [models, setModels] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [confirm, setConfirm] = useState(null) + const [dialogError, setDialogError] = useState(null) + const [pending, setPending] = useState(false) - useEffect(() => { + const load = () => adminApi .getModels() .then((d) => { @@ -1139,16 +1149,48 @@ function ModelsTab() { setError(null) }) .catch((e) => setError(e instanceof Error ? e.message : 'Failed to load')) - .finally(() => setLoading(false)) + + useEffect(() => { + load().finally(() => setLoading(false)) }, []) + // Features that currently have at least one Shadow row — rollback needs a prior promotion. + const featuresWithShadow = new Set(models.filter((m) => m.status === 'Shadow').map((m) => m.featureTag)) + + const runAction = async () => { + if (!confirm) return + setPending(true) + setDialogError(null) + try { + let res: ModelPromotionResult + if (confirm.kind === 'promote') { + res = await adminApi.promoteModel(confirm.row.id) + } else { + res = await adminApi.rollbackModel(confirm.row.featureTag) + } + await load() + setConfirm(null) + setSuccess( + `${res.action === 'Rollback' ? 'Rolled back' : 'Promoted'} ${res.featureTag} → primary ${res.newPrimary.modelId}` + + (res.demotedToShadow ? ` (${res.demotedToShadow.modelId} now shadow)` : ''), + ) + } catch (e) { + // Surface the server message (409 race / 400 reasons) in the dialog; keep it open. + setDialogError(e instanceof Error ? e.message : 'Action failed') + } finally { + setPending(false) + } + } + return ( <>

- Registered models per feature and their routing status. Read-only. + Registered models per feature and their routing status. Promote a shadow to primary or roll a feature back to its + previous primary.

{error && } + {success && setSuccess(null)} />} {loading ? (

Loading…

@@ -1163,6 +1205,7 @@ function ModelsTab() { Model Status Created + Actions @@ -1173,15 +1216,97 @@ function ModelsTab() { {m.modelId} {m.status} {timeAgo(m.createdAt)} + + {m.status === 'Shadow' && ( + + )} + {m.status === 'Primary' && featuresWithShadow.has(m.featureTag) && ( + + )} + ))} )} + + {confirm && ( + { + if (!pending) setConfirm(null) + }} + /> + )} ) } +function ModelConfirmDialog({ + action, + pending, + error, + onConfirm, + onClose, +}: { + action: ModelAction + pending: boolean + error: string | null + onConfirm: () => void + onClose: () => void +}) { + const { row } = action + const title = action.kind === 'promote' ? 'Promote to primary' : 'Roll back primary' + const message = + action.kind === 'promote' + ? `Promote ${row.modelId} to primary for ${row.featureTag}? The current primary will keep running as shadow.` + : `Roll back ${row.featureTag} to its previous primary?` + return ( +
+
e.stopPropagation()} style={{ ...modal, maxWidth: 460 }}> +
+

{title}

+ +
+

{message}

+

This changes production routing immediately.

+ {error && } +
+ + +
+
+
+ ) +} + // ─────────────────────────── shared ─────────────────────────── function Pager({ @@ -1218,6 +1343,22 @@ function Banner({ text }: { text: string }) { return
{text}
} +function SuccessBanner({ text, onClose }: { text: string; onClose: () => void }) { + return ( +
+ {text} + +
+ ) +} + function formatBreakdown(json: string | null): string { if (!json) return '—' try { diff --git a/backend/src/Ai/TextStack.Ai.Core/IModelRouteProvider.cs b/backend/src/Ai/TextStack.Ai.Core/IModelRouteProvider.cs new file mode 100644 index 00000000..714291a5 --- /dev/null +++ b/backend/src/Ai/TextStack.Ai.Core/IModelRouteProvider.cs @@ -0,0 +1,20 @@ +namespace TextStack.Ai.Core; + +/// +/// Resolves the PRIMARY provider key for a feature tag from the models registry +/// (the table-driven source of truth, set by admin promote/rollback). The +/// ModelGateway consults this BEFORE its config fallback, so a promotion takes effect +/// without a redeploy. Hot path: the implementation MUST cache and MUST NEVER throw — +/// any failure (no DB, empty registry, race) returns null so the gateway falls +/// straight through to its config route. Implemented in Application (EF kept out of Llm). +/// +public interface IModelRouteProvider +{ + /// Primary provider key for the feature, or null when none is registered + /// or on ANY failure (never throws). Cached for a short TTL. + string? PrimaryProviderKey(string featureTag); + + /// Drop the cached snapshot so the next read rebuilds from the registry + /// (called after a promote/rollback writes a new Primary). + void Invalidate(); +} diff --git a/backend/src/Ai/TextStack.Ai.Core/IShadowRunWriter.cs b/backend/src/Ai/TextStack.Ai.Core/IShadowRunWriter.cs index 856bef00..ffee977a 100644 --- a/backend/src/Ai/TextStack.Ai.Core/IShadowRunWriter.cs +++ b/backend/src/Ai/TextStack.Ai.Core/IShadowRunWriter.cs @@ -32,4 +32,7 @@ public record ShadowRun( Guid? ShadowTraceId, string PromptHash, Guid? UserId, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + // Resolved shadow PROVIDER key (e.g. "openai-explain"), threaded from the gateway so + // the writer can upsert a candidate Shadow ModelRegistration (promotable later). + string ShadowProviderKey = ""); diff --git a/backend/src/Ai/TextStack.Ai.Llm/ModelGateway.cs b/backend/src/Ai/TextStack.Ai.Llm/ModelGateway.cs index 4d68e758..fd897534 100644 --- a/backend/src/Ai/TextStack.Ai.Llm/ModelGateway.cs +++ b/backend/src/Ai/TextStack.Ai.Llm/ModelGateway.cs @@ -29,6 +29,7 @@ public sealed class ModelGateway( IConfiguration config, IServiceScopeFactory scopeFactory, ShadowOptions shadowOptions, + IModelRouteProvider routeProvider, ILogger logger) : ILlmService { public async Task CompleteAsync(LlmRequest request, CancellationToken ct) @@ -79,15 +80,58 @@ public async IAsyncEnumerable StreamAsync( private ILlmService Route(string? featureTag) { - string? key = null; - if (!string.IsNullOrWhiteSpace(featureTag)) + // Precedence: registry Primary (table-driven, set by admin promote/rollback) → + // config Ai:Routes:{feature} → Ai:DefaultProvider → "openai". The route provider + // is hot-path safe (cached, never throws → null on any failure), so a missing DB + // or empty registry transparently falls through to the config route below. + var registryKey = RegistryKey(featureTag); + var configKey = !string.IsNullOrWhiteSpace(featureTag) + ? config[$"Ai:Routes:{featureTag}"] + : null; + var key = registryKey ?? configKey ?? config["Ai:DefaultProvider"] ?? "openai"; + + // A registry row may name a provider key with no keyed registration (e.g. a key + // renamed in config but still recorded in `models`). That must NEVER throw — fall + // back to the config route + log, mirroring an unknown-key first-deploy guard. + var svc = serviceProvider.GetKeyedService(key); + if (svc is null) { - key = config[$"Ai:Routes:{featureTag}"]; - if (key is null) - logger.LogDebug("No Ai:Routes entry for feature '{Feature}'; using default provider", featureTag); + logger.LogWarning( + "Unknown provider key '{Key}' for feature '{Feature}'; falling back to config route", key, featureTag); + key = configKey ?? config["Ai:DefaultProvider"] ?? "openai"; + svc = serviceProvider.GetRequiredKeyedService(key); + } + return svc; + } + + /// Resolved PRIMARY provider key for a feature (registry → config → default), + /// mirroring 's precedence WITHOUT touching DI. Used to skip a + /// shadow that now points at the same provider as the primary. + private string ResolvedPrimaryKey(string? featureTag) + { + var registryKey = RegistryKey(featureTag); + var configKey = !string.IsNullOrWhiteSpace(featureTag) + ? config[$"Ai:Routes:{featureTag}"] + : null; + return registryKey ?? configKey ?? config["Ai:DefaultProvider"] ?? "openai"; + } + + /// Registry Primary key for a feature, defensively. The provider contract is + /// never-throws, but we ALSO guard here so a misbehaving provider can never break a real + /// LLM call — it just falls through to the config route. + private string? RegistryKey(string? featureTag) + { + if (string.IsNullOrWhiteSpace(featureTag)) + return null; + try + { + return routeProvider.PrimaryProviderKey(featureTag); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Registry route lookup threw for feature '{Feature}'; using config route", featureTag); + return null; } - key ??= config["Ai:DefaultProvider"] ?? "openai"; - return serviceProvider.GetRequiredKeyedService(key); } /// @@ -101,6 +145,18 @@ private void MaybeShadow(LlmRequest request, LlmResponse primary, long primaryLa var shadowKey = shadowOptions.ShadowKeyFor(request.FeatureTag); if (shadowKey is null) return; + + // Post-promotion guard: if the shadow route now points at the SAME provider the + // primary already resolves to (e.g. the shadow candidate got promoted), shadowing + // would compare a model to itself + burn paid calls for nothing. Skip it. + if (string.Equals(shadowKey, ResolvedPrimaryKey(request.FeatureTag), StringComparison.Ordinal)) + { + logger.LogDebug( + "Shadow key '{Key}' equals the primary for feature '{Feature}'; skipping self-shadow", + shadowKey, request.FeatureTag); + return; + } + if (!ShouldSample(shadowOptions.RateFor(request.FeatureTag))) return; @@ -125,7 +181,7 @@ private void MaybeShadow(LlmRequest request, LlmResponse primary, long primaryLa // the gateway threads one (mirrors how LlmTrace.UserId is currently null). var run = BuildShadowRun( feature, primary, primaryLatencyMs, shadowResponse, sw.ElapsedMilliseconds, - ComputePromptHash(request), userId: null); + ComputePromptHash(request), userId: null, shadowProviderKey: shadowKey); var writer = scope.ServiceProvider.GetRequiredService(); await writer.WriteAsync(run, CancellationToken.None); @@ -155,7 +211,8 @@ public static bool ShouldSample(double rate) /// public static Core.ShadowRun BuildShadowRun( string featureTag, LlmResponse primary, long primaryMs, - LlmResponse shadow, long shadowMs, string promptHash, Guid? userId) => + LlmResponse shadow, long shadowMs, string promptHash, Guid? userId, + string shadowProviderKey = "") => new( Id: Guid.NewGuid(), FeatureTag: featureTag, @@ -175,7 +232,8 @@ public static Core.ShadowRun BuildShadowRun( ShadowTraceId: shadow.TraceId, PromptHash: promptHash, UserId: userId, - CreatedAt: DateTimeOffset.UtcNow); + CreatedAt: DateTimeOffset.UtcNow, + ShadowProviderKey: shadowProviderKey); /// Same prompt-hash scheme as the TracingDecorator (system prompt + messages JSON). public static string ComputePromptHash(LlmRequest request) diff --git a/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs b/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs index c36fdc68..16e7865e 100644 --- a/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs +++ b/backend/src/Api/Endpoints/AdminAiQualityEndpoints.cs @@ -1,3 +1,4 @@ +using Api.Middleware; using Application.Common.Interfaces; using Contracts.Admin; using Infrastructure.Persistence; @@ -37,6 +38,8 @@ public static void MapAdminAiQualityEndpoints(this WebApplication app) group.MapGet("/shadow/summary", GetShadowSummary); group.MapGet("/shadow/samples", GetShadowSamples); group.MapGet("/models", GetModels); + group.MapPost("/models/{id:guid}/promote", PromoteModel); + group.MapPost("/models/{feature}/rollback", RollbackModel); } // Phase 7 DoD gate (AI-046): A/B the single-call baseline vs the full FieldCrew on the same brief+source over @@ -661,6 +664,47 @@ private static async Task GetModels(AppDbContext db, CancellationToken return Results.Ok(new ModelsRegistryDto(models)); } + // Phase 12 (RLOps): make a Shadow registration the new Primary for its feature, demoting + // the current Primary to Shadow + writing an audit row, in one transaction. Service throws + // DomainException (→400 for not-found/Retired/already-Primary) or ConflictException (→409 for + // a concurrent promote), both mapped by the ExceptionMiddleware. AdminUserId is audited. + private static async Task PromoteModel( + Guid id, + HttpContext httpContext, + Application.Ai.ModelPromotionService service, + CancellationToken ct) + { + var adminId = httpContext.GetAdminUserId(); + var result = await service.PromoteAsync(id, adminId, ct); + return Results.Ok(ToDto(result)); + } + + // Phase 12 (RLOps): revert the most recent promotion for a feature (the demoted model becomes + // Primary again). 409 (ConflictException) if there's no prior promotion to roll back. + private static async Task RollbackModel( + string feature, + HttpContext httpContext, + Application.Ai.ModelPromotionService service, + CancellationToken ct) + { + var adminId = httpContext.GetAdminUserId(); + var result = await service.RollbackAsync(feature, adminId, ct); + return Results.Ok(ToDto(result)); + } + + private static ModelPromotionResultDto ToDto(Application.Ai.ModelPromotionResult r) => + new( + r.FeatureTag, + new ModelRegistrationDto( + r.NewPrimary.Id, r.NewPrimary.FeatureTag, r.NewPrimary.ProviderKey, + r.NewPrimary.ModelId, r.NewPrimary.Status.ToString(), r.NewPrimary.CreatedAt), + r.DemotedToShadow is null ? null : new ModelRegistrationDto( + r.DemotedToShadow.Id, r.DemotedToShadow.FeatureTag, r.DemotedToShadow.ProviderKey, + r.DemotedToShadow.ModelId, r.DemotedToShadow.Status.ToString(), r.DemotedToShadow.CreatedAt), + r.Action.ToString(), + r.AdminUserId, + r.CreatedAt); + /// Raw-SQL row for the shadow-pair aggregate (public + mutable for EF SqlQuery /// materialization, like FeatureRow). Avg columns are nullable — an all-null group yields NULL. public sealed class ShadowPairRow diff --git a/backend/src/Application/Ai/DbShadowRunWriter.cs b/backend/src/Application/Ai/DbShadowRunWriter.cs index 10b893f2..3e351ec5 100644 --- a/backend/src/Application/Ai/DbShadowRunWriter.cs +++ b/backend/src/Application/Ai/DbShadowRunWriter.cs @@ -1,5 +1,8 @@ using Application.Common.Interfaces; using Domain.Entities; +using Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Application.Ai; @@ -10,7 +13,9 @@ namespace Application.Ai; /// namespace clash (Application.* vs TextStack.*) and the type-name clash with the EF /// entity . /// -public sealed class DbShadowRunWriter(IAppDbContext db) : global::TextStack.Ai.Core.IShadowRunWriter +public sealed class DbShadowRunWriter( + IAppDbContext db, + ILogger logger) : global::TextStack.Ai.Core.IShadowRunWriter { public async Task WriteAsync(global::TextStack.Ai.Core.ShadowRun run, CancellationToken ct) { @@ -37,5 +42,41 @@ public async Task WriteAsync(global::TextStack.Ai.Core.ShadowRun run, Cancellati CreatedAt = run.CreatedAt, }); await db.SaveChangesAsync(ct); + + // Candidate registration: ensure a Shadow ModelRegistration exists for this + // (feature, shadow provider, shadow model) so an admin has a row to promote. Keyed + // on the existing (feature_tag, provider_key, model_id) unique index. Best-effort — + // this runs on the gateway's fire-and-forget shadow path, so a failure here must + // NOT bubble out of the writer (the shadow_run itself is already persisted). + try + { + if (!string.IsNullOrWhiteSpace(run.ShadowProviderKey) + && !string.IsNullOrWhiteSpace(run.ShadowModelId)) + { + var exists = await db.Models.AnyAsync( + m => m.FeatureTag == run.FeatureTag + && m.ProviderKey == run.ShadowProviderKey + && m.ModelId == run.ShadowModelId, + ct); + if (!exists) + { + db.Models.Add(new ModelRegistration + { + Id = Guid.NewGuid(), + FeatureTag = run.FeatureTag, + ProviderKey = run.ShadowProviderKey, + ModelId = run.ShadowModelId, + Status = ModelStatus.Shadow, + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(ct); + } + } + } + catch (Exception ex) + { + // A concurrent shadow write may lose the unique-index race; swallow + log. + logger.LogDebug(ex, "Shadow candidate upsert skipped for feature '{Feature}'", run.FeatureTag); + } } } diff --git a/backend/src/Application/Ai/ModelPromotionService.cs b/backend/src/Application/Ai/ModelPromotionService.cs new file mode 100644 index 00000000..66cefc9d --- /dev/null +++ b/backend/src/Application/Ai/ModelPromotionService.cs @@ -0,0 +1,149 @@ +using Application.Common.Interfaces; +using Domain.Entities; +using Domain.Enums; +using Domain.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Application.Ai; + +/// +/// One-click promote / rollback of the PRIMARY model for a feature (Phase 12 RLOps). +/// Mutates the models registry — the source of truth the gateway routes by via +/// — and writes an +/// append-only audit row, all in one transaction. After a +/// successful commit it invalidates +/// the route cache so traffic flips on the next call. +/// +/// Invariants: exactly one Primary per feature (a partial unique index enforces it; a +/// concurrent promote surfaces as a ); transitions are +/// strictly Shadow→Primary (a Retired model is never promotable); rollback replays the +/// inverse of the latest promotion for the feature, at any age. +/// +public sealed class ModelPromotionService( + IAppDbContext db, + global::TextStack.Ai.Core.IModelRouteProvider routeProvider) +{ + /// Make the given Shadow registration the Primary for its feature, demoting + /// the current Primary (if any) to Shadow. Rejects not-found / Retired / already-Primary + /// ( → 400) and a concurrent promote ( → 409). + public async Task PromoteAsync(Guid modelRegistrationId, Guid? adminUserId, CancellationToken ct) + { + await using var tx = await db.BeginTransactionAsync(ct); + + var target = await db.Models.FirstOrDefaultAsync(m => m.Id == modelRegistrationId, ct) + ?? throw new DomainException("MODEL_NOT_FOUND", $"Model registration '{modelRegistrationId}' not found"); + + if (target.Status == ModelStatus.Retired) + throw new DomainException("MODEL_RETIRED", "A retired model cannot be promoted"); + if (target.Status == ModelStatus.Primary) + throw new DomainException("ALREADY_PRIMARY", $"Model is already the primary for feature '{target.FeatureTag}'"); + + // Current Primary of the SAME feature (the outgoing one). May be none. + var outgoing = await db.Models.FirstOrDefaultAsync( + m => m.FeatureTag == target.FeatureTag && m.Status == ModelStatus.Primary, ct); + + if (outgoing is not null) + outgoing.Status = ModelStatus.Shadow; + target.Status = ModelStatus.Primary; + + db.ModelPromotions.Add(new ModelPromotion + { + Id = Guid.NewGuid(), + FeatureTag = target.FeatureTag, + FromModelRegistrationId = outgoing?.Id, + FromProviderKey = outgoing?.ProviderKey, + FromModelId = outgoing?.ModelId, + ToModelRegistrationId = target.Id, + ToProviderKey = target.ProviderKey, + ToModelId = target.ModelId, + Action = PromotionAction.Promote, + AdminUserId = adminUserId, + CreatedAt = DateTimeOffset.UtcNow, + }); + + await SaveAndCommitAsync(tx, ct); + routeProvider.Invalidate(); + + return new ModelPromotionResult(target.FeatureTag, target, outgoing, PromotionAction.Promote, adminUserId); + } + + /// Revert the most recent promotion for a feature: make the previously + /// demoted Shadow (the latest row's From) Primary again and demote the current + /// Primary to Shadow. Rejects when there's no prior promotion or it had no outgoing + /// model to restore ( → 409). Never rejects by age. + public async Task RollbackAsync(string featureTag, Guid? adminUserId, CancellationToken ct) + { + await using var tx = await db.BeginTransactionAsync(ct); + + var latest = await db.ModelPromotions + .Where(p => p.FeatureTag == featureTag) + .OrderByDescending(p => p.CreatedAt) + .FirstOrDefaultAsync(ct); + + if (latest is null) + throw new ConflictException($"No prior promotion to roll back for feature '{featureTag}'"); + if (latest.FromModelRegistrationId is null) + throw new ConflictException($"The latest promotion for feature '{featureTag}' had no prior primary to restore"); + + // The model to restore (the one demoted by `latest`). + var restore = await db.Models.FirstOrDefaultAsync(m => m.Id == latest.FromModelRegistrationId.Value, ct) + ?? throw new ConflictException("The model to roll back to no longer exists"); + if (restore.Status == ModelStatus.Retired) + throw new ConflictException("The model to roll back to is retired"); + + var current = await db.Models.FirstOrDefaultAsync( + m => m.FeatureTag == featureTag && m.Status == ModelStatus.Primary, ct); + + if (current is not null) + current.Status = ModelStatus.Shadow; + restore.Status = ModelStatus.Primary; + + db.ModelPromotions.Add(new ModelPromotion + { + Id = Guid.NewGuid(), + FeatureTag = featureTag, + FromModelRegistrationId = current?.Id, + FromProviderKey = current?.ProviderKey, + FromModelId = current?.ModelId, + ToModelRegistrationId = restore.Id, + ToProviderKey = restore.ProviderKey, + ToModelId = restore.ModelId, + Action = PromotionAction.Rollback, + AdminUserId = adminUserId, + CreatedAt = DateTimeOffset.UtcNow, + }); + + await SaveAndCommitAsync(tx, ct); + routeProvider.Invalidate(); + + return new ModelPromotionResult(featureTag, restore, current, PromotionAction.Rollback, adminUserId); + } + + // SaveChanges can violate the partial unique index (feature_tag) WHERE status='Primary' + // under a concurrent promote → surface as a conflict for the endpoint to map to 409. + private async Task SaveAndCommitAsync( + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction tx, CancellationToken ct) + { + try + { + await db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (DbUpdateException) + { + throw new ConflictException("Concurrent primary change detected; please retry"); + } + } +} + +/// Outcome of a promote/rollback: the new Primary, the model demoted to Shadow +/// (null if there was none), and the audited action + admin. +public sealed record ModelPromotionResult( + string FeatureTag, + ModelRegistration NewPrimary, + ModelRegistration? DemotedToShadow, + PromotionAction Action, + Guid? AdminUserId) +{ + public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; +} diff --git a/backend/src/Application/Ai/RegistryModelRouteProvider.cs b/backend/src/Application/Ai/RegistryModelRouteProvider.cs new file mode 100644 index 00000000..907504af --- /dev/null +++ b/backend/src/Application/Ai/RegistryModelRouteProvider.cs @@ -0,0 +1,94 @@ +using Application.Common.Interfaces; +using Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Application.Ai; + +/// +/// Table-driven primary routing (Phase 12 RLOps): resolves the PRIMARY provider key for +/// a feature from the models registry, the source of truth that admin +/// promote/rollback mutates. Holds ONE immutable snapshot (feature_tag → provider_key) +/// in under a short absolute TTL (Ai:Routes:CacheSeconds, +/// default 30s); a promote/rollback calls for an instant flip. +/// +/// Singleton. Hot path: NEVER throws — any failure +/// (no DB, empty table, transient error) logs Debug + returns null so the gateway falls +/// through to its config route. The snapshot is built inside a fresh DI scope (the +/// singleton can't hold a scoped DbContext), guarded by a lock so a cache miss under load +/// does at most a brief double-build rather than a thundering herd of DB queries. +/// +public sealed class RegistryModelRouteProvider( + IServiceScopeFactory scopeFactory, + IMemoryCache cache, + IConfiguration config, + ILogger logger) : global::TextStack.Ai.Core.IModelRouteProvider +{ + private const string CacheKey = "ai:routes:primary"; + private readonly Lock _gate = new(); + + public string? PrimaryProviderKey(string featureTag) + { + if (string.IsNullOrWhiteSpace(featureTag)) + return null; + + try + { + var snapshot = GetOrBuildSnapshot(); + return snapshot.TryGetValue(featureTag, out var key) ? key : null; + } + catch (Exception ex) + { + // Hot path: never throw. The gateway falls through to its config route. + logger.LogDebug(ex, "Primary route lookup failed for feature '{Feature}'; falling through", featureTag); + return null; + } + } + + public void Invalidate() => cache.Remove(CacheKey); + + private IReadOnlyDictionary GetOrBuildSnapshot() + { + if (cache.TryGetValue(CacheKey, out IReadOnlyDictionary? cached) && cached is not null) + return cached; + + // Serialize the (re)build so concurrent misses don't all hit the DB. Double-check + // inside the lock in case another thread just populated it. + lock (_gate) + { + if (cache.TryGetValue(CacheKey, out cached) && cached is not null) + return cached; + + var snapshot = BuildSnapshot(); + var ttl = TimeSpan.FromSeconds(config.GetValue("Ai:Routes:CacheSeconds", 30)); + cache.Set(CacheKey, snapshot, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }); + return snapshot; + } + } + + private IReadOnlyDictionary BuildSnapshot() + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // One Primary per feature SHOULD be enforced by a partial unique index. But this is + // the LLM hot path: if that index ever fails to enforce (mis-filtered migration, a + // manual DB edit, a not-yet-applied migration), two Primary rows for one feature must + // NOT crash every call via a duplicate-key ToDictionary. Group defensively and take + // the first deterministically (oldest by CreatedAt) so routing stays deterministic. + var primaries = db.Models + .AsNoTracking() + .Where(m => m.Status == ModelStatus.Primary) + .OrderBy(m => m.CreatedAt) + .Select(m => new { m.FeatureTag, m.ProviderKey }) + .ToList(); + + var snapshot = new Dictionary(primaries.Count); + foreach (var p in primaries) + snapshot.TryAdd(p.FeatureTag, p.ProviderKey); // first (oldest) wins; dupes ignored + return snapshot; + } +} diff --git a/backend/src/Application/Common/Interfaces/IAppDbContext.cs b/backend/src/Application/Common/Interfaces/IAppDbContext.cs index afcaadeb..16fdb326 100644 --- a/backend/src/Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/Application/Common/Interfaces/IAppDbContext.cs @@ -61,6 +61,7 @@ public interface IAppDbContext DbSet LlmTraces { get; } DbSet ShadowRuns { get; } DbSet Models { get; } + DbSet ModelPromotions { get; } DbSet EvalRuns { get; } DbSet AgentRuns { get; } DbSet PodcastGenerationJobs { get; } diff --git a/backend/src/Application/DependencyInjection.cs b/backend/src/Application/DependencyInjection.cs index 37654365..39c43726 100644 --- a/backend/src/Application/DependencyInjection.cs +++ b/backend/src/Application/DependencyInjection.cs @@ -117,14 +117,23 @@ public static IServiceCollection AddApplication(this IServiceCollection services sp.GetRequiredService>())); } + // Table-driven primary routing (Phase 12 RLOps): the gateway consults this BEFORE + // its config route, so an admin promote/rollback flips traffic without a redeploy. + // Singleton (caches one snapshot of the `models` registry); hot-path safe + never throws. + services.AddSingleton(); + + // One-click promote / rollback of the primary model for a feature. + services.AddScoped(); + // Default Core.ILlmService = the gateway (routes FeatureTag → decorated provider; - // optional fire-and-forget shadow routing per Ai:Shadow). + // registry-first primary routing + optional fire-and-forget shadow routing per Ai:Shadow). services.AddSingleton(sp => new global::TextStack.Ai.Llm.ModelGateway( sp, sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>())); // Embeddings (Phase 4 RAG). Single OpenAI provider; resolved lazily so a keyless diff --git a/backend/src/Contracts/Admin/AiQualityDtos.cs b/backend/src/Contracts/Admin/AiQualityDtos.cs index c7000994..8fb6d4dc 100644 --- a/backend/src/Contracts/Admin/AiQualityDtos.cs +++ b/backend/src/Contracts/Admin/AiQualityDtos.cs @@ -179,3 +179,13 @@ public record ModelRegistrationDto( /// The models registry payload (whole table; tiny). public record ModelsRegistryDto(IReadOnlyList Models); + +/// Result of a promote/rollback: the new Primary, the model demoted to Shadow +/// (null if there was none), the audited action ("Promote"/"Rollback") + admin + time. +public record ModelPromotionResultDto( + string FeatureTag, + ModelRegistrationDto NewPrimary, + ModelRegistrationDto? DemotedToShadow, + string Action, + Guid? AdminUserId, + DateTimeOffset CreatedAt); diff --git a/backend/src/Domain/Entities/ModelPromotion.cs b/backend/src/Domain/Entities/ModelPromotion.cs new file mode 100644 index 00000000..290796ab --- /dev/null +++ b/backend/src/Domain/Entities/ModelPromotion.cs @@ -0,0 +1,38 @@ +using Domain.Enums; + +namespace Domain.Entities; + +/// +/// Append-only audit of a primary-routing change for one feature (table +/// model_promotions, Phase 12 RLOps). Each row records the swap an admin made: +/// the outgoing Primary (From*, null on the very first promotion for a feature) +/// and the incoming one (To*), with provider/model denormalized so the history +/// reads even after the models rows change. Rollback reads the latest row for a +/// feature and re-applies the inverse swap, writing a new row with +/// . Never updated or deleted — it's the audit +/// trail the rollback path replays. Plain POCO; EF mapping lives in AppDbContext.Ai.cs. +/// +public class ModelPromotion +{ + public Guid Id { get; set; } + public required string FeatureTag { get; set; } + + /// The outgoing Primary that was demoted to Shadow; null on the first + /// promotion for a feature (no prior Primary registration). + public Guid? FromModelRegistrationId { get; set; } + + /// The incoming model that became Primary. + public required Guid ToModelRegistrationId { get; set; } + + public string? FromProviderKey { get; set; } + public string? FromModelId { get; set; } + public required string ToProviderKey { get; set; } + public required string ToModelId { get; set; } + + public PromotionAction Action { get; set; } + + /// The admin who triggered the change (null when unattributed). + public Guid? AdminUserId { get; set; } + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/backend/src/Domain/Enums/PromotionAction.cs b/backend/src/Domain/Enums/PromotionAction.cs new file mode 100644 index 00000000..a02a796d --- /dev/null +++ b/backend/src/Domain/Enums/PromotionAction.cs @@ -0,0 +1,13 @@ +namespace Domain.Enums; + +/// +/// Why a row was written. Promote = +/// an admin made a Shadow model the new Primary; Rollback = reverting the most +/// recent promotion for a feature (the demoted Shadow becomes Primary again). Stored as +/// a string column (HasConversion<string>) so the values read in SQL. +/// +public enum PromotionAction +{ + Promote = 0, + Rollback = 1 +} diff --git a/backend/src/Infrastructure/Migrations/20260618145458_AddModelPromotionAndPrimaryUniqueIndex.Designer.cs b/backend/src/Infrastructure/Migrations/20260618145458_AddModelPromotionAndPrimaryUniqueIndex.Designer.cs new file mode 100644 index 00000000..681c647e --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260618145458_AddModelPromotionAndPrimaryUniqueIndex.Designer.cs @@ -0,0 +1,5025 @@ +// +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("20260618145458_AddModelPromotionAndPrimaryUniqueIndex")] + partial class AddModelPromotionAndPrimaryUniqueIndex + { + /// + 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("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/20260618145458_AddModelPromotionAndPrimaryUniqueIndex.cs b/backend/src/Infrastructure/Migrations/20260618145458_AddModelPromotionAndPrimaryUniqueIndex.cs new file mode 100644 index 00000000..1a4a00f0 --- /dev/null +++ b/backend/src/Infrastructure/Migrations/20260618145458_AddModelPromotionAndPrimaryUniqueIndex.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddModelPromotionAndPrimaryUniqueIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "model_promotions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + feature_tag = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + from_model_registration_id = table.Column(type: "uuid", nullable: true), + to_model_registration_id = table.Column(type: "uuid", nullable: false), + from_provider_key = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + from_model_id = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + to_provider_key = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + to_model_id = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + action = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + admin_user_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_model_promotions", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_models_feature_tag", + table: "models", + column: "feature_tag", + unique: true, + filter: "status = 'Primary'"); + + migrationBuilder.CreateIndex( + name: "ix_model_promotions_feature_tag_created_at", + table: "model_promotions", + columns: new[] { "feature_tag", "created_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "model_promotions"); + + migrationBuilder.DropIndex( + name: "ix_models_feature_tag", + table: "models"); + } + } +} diff --git a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 9a2a148e..3b91c2d0 100644 --- a/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -1510,6 +1510,72 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1548,6 +1614,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs index fb44ea8e..c1a88c2e 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Ai.cs @@ -70,6 +70,28 @@ private static void ConfigureAi(ModelBuilder modelBuilder) e.Property(x => x.ModelId).HasMaxLength(128); e.Property(x => x.FeatureTag).HasMaxLength(64); e.Property(x => x.Status).HasConversion().HasMaxLength(20); + + // Table-driven routing invariant (Phase 12 RLOps): AT MOST ONE Primary per + // feature. A partial unique index enforces it at the DB level, so a concurrent + // promote violates it (caught in ModelPromotionService → 409) rather than + // racing two Primaries. Filter uses the STORED enum string ('Primary') on the + // snake_case column. + e.HasIndex(x => x.FeatureTag) + .IsUnique() + .HasFilter("status = 'Primary'"); + }); + + modelBuilder.Entity(e => + { + // Hot query: latest promotion for a feature (rollback) — feature + time. + e.HasIndex(x => new { x.FeatureTag, x.CreatedAt }); + + e.Property(x => x.FeatureTag).HasMaxLength(64); + e.Property(x => x.FromProviderKey).HasMaxLength(64); + e.Property(x => x.FromModelId).HasMaxLength(128); + e.Property(x => x.ToProviderKey).HasMaxLength(64); + e.Property(x => x.ToModelId).HasMaxLength(128); + e.Property(x => x.Action).HasConversion().HasMaxLength(20); }); modelBuilder.Entity(e => diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.cs b/backend/src/Infrastructure/Persistence/AppDbContext.cs index 0bc5c79e..647bf890 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.cs @@ -82,6 +82,7 @@ public Task BeginTransactionAsync(CancellationToken ct = public DbSet LlmTraces => Set(); public DbSet ShadowRuns => Set(); public DbSet Models => Set(); + public DbSet ModelPromotions => Set(); public DbSet EvalRuns => Set(); public DbSet AgentRuns => Set(); public DbSet PodcastGenerationJobs => Set(); diff --git a/tests/TextStack.AiEvals/CapturingDb.cs b/tests/TextStack.AiEvals/CapturingDb.cs index 5eab1e73..6d3d2d3a 100644 --- a/tests/TextStack.AiEvals/CapturingDb.cs +++ b/tests/TextStack.AiEvals/CapturingDb.cs @@ -98,5 +98,6 @@ public override EntityEntry Add(EvalRun entity) public DbSet AgentRuns => throw new NotSupportedException(); public DbSet ShadowRuns => throw new NotSupportedException(); public DbSet Models => throw new NotSupportedException(); + public DbSet ModelPromotions => throw new NotSupportedException(); public DbSet PodcastGenerationJobs => throw new NotSupportedException(); } diff --git a/tests/TextStack.IntegrationTests/AdminModelPromotionEndpointTests.cs b/tests/TextStack.IntegrationTests/AdminModelPromotionEndpointTests.cs new file mode 100644 index 00000000..f1f61c43 --- /dev/null +++ b/tests/TextStack.IntegrationTests/AdminModelPromotionEndpointTests.cs @@ -0,0 +1,112 @@ +using System.Net; +using System.Text.Json; + +namespace TextStack.IntegrationTests; + +/// +/// Integration tests for the table-driven promote/rollback endpoints (Phase 12 RLOps slice 3), +/// against the live API on the admin host. Mirrors AdminShadowEndpointTests: auth + validation +/// paths assert without depending on seeded mutable state, and a full promote→reflected-in-/models +/// →rollback round-trip runs when the fixture user is admin. Skips (not fails) when auth/endpoint +/// is unavailable. To run: `docker compose up` (API on :8080) with ENABLE_TEST_AUTH=true; runs in CI. +/// +public class AdminModelPromotionEndpointTests : IClassFixture +{ + private readonly AuthenticatedApiFixture _fixture; + + public AdminModelPromotionEndpointTests(AuthenticatedApiFixture fixture) => _fixture = fixture; + + [Fact] + public async Task Promote_NoAuth_Unauthorized() + { + var request = new HttpRequestMessage( + HttpMethod.Post, $"/admin/ai-quality/models/{Guid.NewGuid()}/promote"); + 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 Rollback_NoAuth_Unauthorized() + { + var request = new HttpRequestMessage( + HttpMethod.Post, "/admin/ai-quality/models/explain/rollback"); + 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 Promote_NonexistentModel_BadRequest() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable"); + + var request = _fixture.CreateAdminRequest( + HttpMethod.Post, $"/admin/ai-quality/models/{Guid.NewGuid()}/promote"); + 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.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Promote_AlreadyPrimaryRow_BadRequest() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable"); + + // Find a currently-Primary registration from the seeded registry; promoting it again + // must be rejected (already-Primary → 400). Read-only; leaves the registry untouched. + var models = await GetModels(); + Assert.SkipWhen(models is null, "endpoint not deployed / not admin"); + var primary = models!.Value.EnumerateArray() + .FirstOrDefault(m => m.GetProperty("status").GetString() == "Primary"); + Assert.SkipWhen(primary.ValueKind == JsonValueKind.Undefined, "no seeded Primary row"); + + var id = primary.GetProperty("id").GetString(); + var request = _fixture.CreateAdminRequest( + HttpMethod.Post, $"/admin/ai-quality/models/{id}/promote"); + var response = await _fixture.Client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Rollback_NoPriorPromotion_Conflict() + { + Assert.SkipUnless(_fixture.IsAuthenticated, "auth unavailable"); + + // A feature that has never been promoted has nothing to roll back → 409. + var request = _fixture.CreateAdminRequest( + HttpMethod.Post, $"/admin/ai-quality/models/__never_promoted__{Guid.NewGuid():N}/rollback"); + 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.Conflict, response.StatusCode); + } + + private async Task GetModels() + { + var request = _fixture.CreateAdminRequest(HttpMethod.Get, "/admin/ai-quality/models"); + var response = await _fixture.Client.SendAsync(request, TestContext.Current.CancellationToken); + if (IntegrationSkip.Unavailable(response) + || response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden + || response.StatusCode != HttpStatusCode.OK) + return null; + + var doc = JsonDocument.Parse( + await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + return doc.RootElement.GetProperty("models").Clone(); + } +} diff --git a/tests/TextStack.UnitTests/ModelGatewayShadowTests.cs b/tests/TextStack.UnitTests/ModelGatewayShadowTests.cs index 96591839..4d892e9a 100644 --- a/tests/TextStack.UnitTests/ModelGatewayShadowTests.cs +++ b/tests/TextStack.UnitTests/ModelGatewayShadowTests.cs @@ -223,6 +223,50 @@ public async Task CompleteAsync_CallerCtAlreadyCancelled_ShadowStillRuns_WithOwn Assert.NotEqual(cts.Token, capturing.SeenToken); } + // ---- shadow self-skip: shadow key == resolved primary key → no shadow ---- + + [Fact] + public async Task MaybeShadow_ShadowKeyEqualsPrimaryKey_SkipsShadow() + { + // Registry promotes explain's primary to "openai-explain" — the SAME provider the + // shadow route points at. Shadowing would compare a model to itself; the gateway skips. + var writer = new FakeShadowRunWriter(); + var rawShadow = new RecordingLlm(Resp("shadow")); + var primary = new RecordingLlm(Resp("primary")); + + var cfg = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Ai:DefaultProvider"] = "openai", + ["Ai:Routes:explain"] = "openai", + }) + .Build(); + + var services = new ServiceCollection(); + // Registry routes explain → "openai-explain", so the primary resolves that keyed svc. + services.AddKeyedSingleton("openai-explain", primary); + services.AddKeyedSingleton("openai-explain-raw", rawShadow); + services.AddScoped(_ => writer); + var inner = services.BuildServiceProvider(); + var spy = new KeyRecordingProvider(inner, RequestedKeys); + + var opts = new ShadowOptions( + DefaultSampleRate: 1.0, + Routes: new Dictionary { ["explain"] = "openai-explain" }, + PerFeatureRates: null, + TimeoutSeconds: 15); + var routes = new StubRouteProvider(new() { ["explain"] = "openai-explain" }); + + var gateway = new ModelGateway(spy, cfg, spy.ScopeFactory, opts, routes, NullLogger.Instance); + + var result = await gateway.CompleteAsync(Req(), CancellationToken.None); + Assert.Equal("primary", result.Text); + + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.False(writer.Written.Task.IsCompleted); // self-shadow skipped → no row + Assert.Equal(0, rawShadow.CallCount); + } + // ---- StreamAsync edge: empty stream still fires exactly one shadow on clean end ---- [Fact] @@ -301,7 +345,8 @@ await Assert.ThrowsAsync(async () => private ModelGateway Build( ILlmService primary, ILlmService shadow, FakeShadowRunWriter writer, - double sampleRate = 1.0, int timeoutSeconds = 15, ShadowOptions? shadowOpts = null) + double sampleRate = 1.0, int timeoutSeconds = 15, ShadowOptions? shadowOpts = null, + IModelRouteProvider? routeProvider = null) { var cfg = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -328,7 +373,17 @@ private ModelGateway Build( PerFeatureRates: null, TimeoutSeconds: timeoutSeconds); - return new ModelGateway(spy, cfg, spy.ScopeFactory, opts, NullLogger.Instance); + var routes = routeProvider ?? new StubRouteProvider(); + return new ModelGateway(spy, cfg, spy.ScopeFactory, opts, routes, NullLogger.Instance); + } + + /// Registry route provider returning a fixed map (empty = no registry hit). + private sealed class StubRouteProvider(Dictionary? routes = null) : IModelRouteProvider + { + private readonly Dictionary _routes = routes ?? new(); + public string? PrimaryProviderKey(string featureTag) => + _routes.TryGetValue(featureTag, out var k) ? k : null; + public void Invalidate() { } } // ---- fakes ---- diff --git a/tests/TextStack.UnitTests/ModelGatewayTests.cs b/tests/TextStack.UnitTests/ModelGatewayTests.cs index de0b4cfd..a798ef4c 100644 --- a/tests/TextStack.UnitTests/ModelGatewayTests.cs +++ b/tests/TextStack.UnitTests/ModelGatewayTests.cs @@ -31,8 +31,19 @@ private static ModelGateway BuildGateway() // No shadow routes → primary-only routing (these tests cover primary routing). var shadow = new ShadowOptions(0.0, new Dictionary(), null, 15); + // No registry routes → falls through to the config route (these tests cover config routing). + var routes = new StubRouteProvider(); return new ModelGateway( - sp, cfg, sp.GetRequiredService(), shadow, NullLogger.Instance); + sp, cfg, sp.GetRequiredService(), shadow, routes, NullLogger.Instance); + } + + /// Registry route provider returning a fixed map (empty = no registry hit). + private sealed class StubRouteProvider(Dictionary? routes = null) : IModelRouteProvider + { + private readonly Dictionary _routes = routes ?? new(); + public string? PrimaryProviderKey(string featureTag) => + _routes.TryGetValue(featureTag, out var k) ? k : null; + public void Invalidate() { } } [Theory] @@ -51,6 +62,104 @@ public async Task CompleteAsync_RoutesByFeatureTag(string? feature, string expec Assert.Equal(expectedProvider, response.ModelId); } + // ── Table-driven primary routing precedence (Phase 12 RLOps) ────────────────── + + // Build a gateway with an explicit route provider + the standard openai/ollama stubs. + private static ModelGateway BuildGateway(IModelRouteProvider routes) + { + var cfg = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Ai:DefaultProvider"] = "openai", + ["Ai:Routes:explain"] = "openai", // config route + ["Ai:Routes:distractor"] = "ollama", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("openai", new KeyEchoLlm("openai")); + services.AddKeyedSingleton("ollama", new KeyEchoLlm("ollama")); + var sp = services.BuildServiceProvider(); + + var shadow = new ShadowOptions(0.0, new Dictionary(), null, 15); + return new ModelGateway( + sp, cfg, sp.GetRequiredService(), shadow, routes, NullLogger.Instance); + } + + [Fact] + public async Task Route_RegistryHit_BeatsConfigRoute() + { + // Config says explain→openai, registry overrides to ollama → registry wins. + var routes = new StubRouteProvider(new() { ["explain"] = "ollama" }); + var gateway = BuildGateway(routes); + + var resp = await gateway.CompleteAsync( + new LlmRequest("s", Array.Empty(), 10, FeatureTag: "explain"), CancellationToken.None); + + Assert.Equal("ollama", resp.ModelId); + } + + [Fact] + public async Task Route_NoRegistry_FallsBackToConfigRoute() + { + var routes = new StubRouteProvider(); // empty → no registry hit + var gateway = BuildGateway(routes); + + var resp = await gateway.CompleteAsync( + new LlmRequest("s", Array.Empty(), 10, FeatureTag: "explain"), CancellationToken.None); + + Assert.Equal("openai", resp.ModelId); // config route + } + + [Fact] + public async Task Route_NoRegistryNoConfig_FallsBackToDefaultProvider() + { + var routes = new StubRouteProvider(); + var gateway = BuildGateway(routes); + + var resp = await gateway.CompleteAsync( + new LlmRequest("s", Array.Empty(), 10, FeatureTag: "no-route-feature"), CancellationToken.None); + + Assert.Equal("openai", resp.ModelId); // DefaultProvider + } + + [Fact] + public async Task Route_RegistryUnknownProviderKey_FallsBackToConfig_NoThrow() + { + // Registry names a provider key with no keyed registration → must NOT throw; the + // gateway falls back to the config route (explain→openai). + var routes = new StubRouteProvider(new() { ["explain"] = "ghost-provider" }); + var gateway = BuildGateway(routes); + + var resp = await gateway.CompleteAsync( + new LlmRequest("s", Array.Empty(), 10, FeatureTag: "explain"), CancellationToken.None); + + Assert.Equal("openai", resp.ModelId); + } + + [Fact] + public async Task Route_RouteProviderThrows_GatewayStillResolvesViaConfig() + { + // The provider contract is never-throws, but the gateway ALSO guards the lookup so a + // misbehaving provider can't break a real call — it falls through to the config route. + var routes = new ThrowingRouteProvider(); + var gateway = BuildGateway(routes); + + var resp = await gateway.CompleteAsync( + new LlmRequest("s", Array.Empty(), 10, FeatureTag: "explain"), CancellationToken.None); + + Assert.Equal("openai", resp.ModelId); // config route, no throw + } + + /// A deliberately-broken provider (violates the never-throw contract) — used to + /// document that a thrown lookup propagates rather than being silently swallowed by the + /// gateway (the contract puts the never-throw guarantee in the IMPL, not the gateway). + private sealed class ThrowingRouteProvider : IModelRouteProvider + { + public string? PrimaryProviderKey(string featureTag) => throw new InvalidOperationException("boom"); + public void Invalidate() { } + } + private sealed class KeyEchoLlm(string key) : ILlmService { public Task CompleteAsync(LlmRequest request, CancellationToken ct) => diff --git a/tests/TextStack.UnitTests/ModelPromotionServiceTests.cs b/tests/TextStack.UnitTests/ModelPromotionServiceTests.cs new file mode 100644 index 00000000..2890aeb3 --- /dev/null +++ b/tests/TextStack.UnitTests/ModelPromotionServiceTests.cs @@ -0,0 +1,310 @@ +using Application.Ai; +using Application.Common.Interfaces; +using Domain.Entities; +using Domain.Enums; +using Domain.Exceptions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Moq; +using TextStack.Ai.Core; + +namespace TextStack.UnitTests; + +/// +/// Promote/rollback transitions over a Moq whose Models + +/// ModelPromotions sets are backed by plain List<T> (async LINQ via TestAsyncQueryable — +/// the production AppDbContext can't load on EF InMemory). Asserts the registry swap, the +/// one-Primary invariant, the audit row, rejections, and a symmetric rollback. The route +/// provider is a spy so we can assert Invalidate() fires after a commit. +/// +public class ModelPromotionServiceTests +{ + private sealed class Harness + { + public List Models { get; } = []; + public List Promotions { get; } = []; + public int SaveCalls { get; private set; } + public int CommitCalls { get; private set; } + public int RollbackCalls { get; private set; } + public SpyRouteProvider Routes { get; } = new(); + public ModelPromotionService Service { get; } + + /// When set, SaveChangesAsync throws this on the Nth call (1-based) to + /// simulate the partial-unique-index violation a concurrent promote triggers. + public Exception? ThrowOnSaveCall { get; set; } + public int ThrowOnSaveCallNumber { get; set; } = 1; + + public Harness() + { + var db = new Mock(); + db.Setup(x => x.Models).Returns(() => FakeSet(Models).Object); + db.Setup(x => x.ModelPromotions).Returns(() => FakeSet(Promotions).Object); + db.Setup(x => x.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(() => + { + SaveCalls++; + if (ThrowOnSaveCall is not null && SaveCalls == ThrowOnSaveCallNumber) + throw ThrowOnSaveCall; + return 0; + }); + + var tx = new Mock(); + tx.Setup(x => x.CommitAsync(It.IsAny())) + .Returns(() => { CommitCalls++; return Task.CompletedTask; }); + tx.Setup(x => x.RollbackAsync(It.IsAny())) + .Returns(() => { RollbackCalls++; return Task.CompletedTask; }); + db.Setup(x => x.BeginTransactionAsync(It.IsAny())) + .ReturnsAsync(tx.Object); + + Service = new ModelPromotionService(db.Object, Routes); + } + + public ModelRegistration AddModel(string feature, string provider, string model, ModelStatus status) + { + var m = new ModelRegistration + { + Id = Guid.NewGuid(), + FeatureTag = feature, + ProviderKey = provider, + ModelId = model, + Status = status, + CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), + }; + Models.Add(m); + return m; + } + } + + private sealed class SpyRouteProvider : IModelRouteProvider + { + public int InvalidateCount { get; private set; } + public string? PrimaryProviderKey(string featureTag) => null; + public void Invalidate() => InvalidateCount++; + } + + private static Mock> FakeSet(List data) where T : class + { + var q = new TestAsyncEnumerable(data); + var set = new Mock>(); + var iq = set.As>(); + iq.Setup(m => m.Provider).Returns(((IQueryable)q).Provider); + iq.Setup(m => m.Expression).Returns(((IQueryable)q).Expression); + iq.Setup(m => m.ElementType).Returns(((IQueryable)q).ElementType); + iq.Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator()); + set.As>() + .Setup(m => m.GetAsyncEnumerator(It.IsAny())) + .Returns(() => new TestAsyncEnumerator(data.GetEnumerator())); + set.Setup(m => m.Add(It.IsAny())).Callback(e => data.Add(e)); + return set; + } + + // ── Promote ─────────────────────────────────────────────────────────────────── + + [Fact] + public async Task PromoteAsync_ShadowToPrimary_FlipsBoth_AndWritesAudit() + { + var h = new Harness(); + var outgoing = h.AddModel("explain", "openai", "gpt-4.1-nano", ModelStatus.Primary); + var target = h.AddModel("explain", "openai-explain", "gpt-4.1-mini", ModelStatus.Shadow); + var admin = Guid.NewGuid(); + + var result = await h.Service.PromoteAsync(target.Id, admin, CancellationToken.None); + + Assert.Equal(ModelStatus.Primary, target.Status); // target promoted + Assert.Equal(ModelStatus.Shadow, outgoing.Status); // outgoing demoted + Assert.Equal(target.Id, result.NewPrimary.Id); + Assert.Equal(outgoing.Id, result.DemotedToShadow!.Id); + Assert.Equal(PromotionAction.Promote, result.Action); + + // Exactly one feature has a Primary (invariant). + Assert.Single(h.Models.Where(m => m is { FeatureTag: "explain", Status: ModelStatus.Primary })); + + // Audit row with denormalized keys + admin. + var audit = Assert.Single(h.Promotions); + Assert.Equal(PromotionAction.Promote, audit.Action); + Assert.Equal(outgoing.Id, audit.FromModelRegistrationId); + Assert.Equal("openai", audit.FromProviderKey); + Assert.Equal(target.Id, audit.ToModelRegistrationId); + Assert.Equal("openai-explain", audit.ToProviderKey); + Assert.Equal(admin, audit.AdminUserId); + + Assert.Equal(1, h.Routes.InvalidateCount); // cache flipped after commit + } + + [Fact] + public async Task PromoteAsync_NoExistingPrimary_FirstPromotion_FromIsNull() + { + var h = new Harness(); + var target = h.AddModel("podcast.script", "ollama", "gemma4:e2b", ModelStatus.Shadow); + + var result = await h.Service.PromoteAsync(target.Id, null, CancellationToken.None); + + Assert.Equal(ModelStatus.Primary, target.Status); + Assert.Null(result.DemotedToShadow); + var audit = Assert.Single(h.Promotions); + Assert.Null(audit.FromModelRegistrationId); + } + + [Fact] + public async Task PromoteAsync_NotFound_Throws() + { + var h = new Harness(); + await Assert.ThrowsAsync(() => + h.Service.PromoteAsync(Guid.NewGuid(), null, CancellationToken.None)); + Assert.Equal(0, h.Routes.InvalidateCount); + } + + [Fact] + public async Task PromoteAsync_Retired_Throws() + { + var h = new Harness(); + var retired = h.AddModel("explain", "openai", "old", ModelStatus.Retired); + await Assert.ThrowsAsync(() => + h.Service.PromoteAsync(retired.Id, null, CancellationToken.None)); + Assert.Equal(ModelStatus.Retired, retired.Status); // untouched + } + + [Fact] + public async Task PromoteAsync_AlreadyPrimary_Throws() + { + var h = new Harness(); + var primary = h.AddModel("explain", "openai", "gpt", ModelStatus.Primary); + await Assert.ThrowsAsync(() => + h.Service.PromoteAsync(primary.Id, null, CancellationToken.None)); + } + + // ── Rollback ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task RollbackAsync_RestoresPriorPrimary_Symmetrically() + { + var h = new Harness(); + var a = h.AddModel("explain", "openai", "nano", ModelStatus.Primary); // original primary + var b = h.AddModel("explain", "openai-explain", "mini", ModelStatus.Shadow); + + // Promote b (a → Shadow, b → Primary). + await h.Service.PromoteAsync(b.Id, null, CancellationToken.None); + Assert.Equal(ModelStatus.Primary, b.Status); + Assert.Equal(ModelStatus.Shadow, a.Status); + + // Rollback: a → Primary again, b → Shadow. + var result = await h.Service.RollbackAsync("explain", null, CancellationToken.None); + + Assert.Equal(ModelStatus.Primary, a.Status); + Assert.Equal(ModelStatus.Shadow, b.Status); + Assert.Equal(a.Id, result.NewPrimary.Id); + Assert.Equal(b.Id, result.DemotedToShadow!.Id); + Assert.Equal(PromotionAction.Rollback, result.Action); + + // Invariant holds + a Rollback audit row was appended. + Assert.Single(h.Models.Where(m => m is { FeatureTag: "explain", Status: ModelStatus.Primary })); + Assert.Equal(2, h.Promotions.Count); + Assert.Equal(PromotionAction.Rollback, h.Promotions[^1].Action); + Assert.Equal(2, h.Routes.InvalidateCount); // promote + rollback + } + + [Fact] + public async Task RollbackAsync_NoPriorPromotion_Conflicts() + { + var h = new Harness(); + h.AddModel("explain", "openai", "nano", ModelStatus.Primary); + await Assert.ThrowsAsync(() => + h.Service.RollbackAsync("explain", null, CancellationToken.None)); + } + + // ── Concurrency / atomicity (the partial-unique-index invariant) ──────────────── + + [Fact] + public async Task PromoteAsync_ConcurrentPromote_IndexViolation_SurfacesConflict_NoInvalidate() + { + // Two admins promote different shadows for the same feature at once: the loser's + // SaveChanges violates the (feature_tag) WHERE status='Primary' partial unique index. + // EF surfaces DbUpdateException → the service MUST map it to ConflictException (→409), + // NOT a 500, and MUST NOT invalidate the route cache (the swap never committed). + var h = new Harness + { + ThrowOnSaveCall = new DbUpdateException("23505: duplicate key value violates unique constraint"), + }; + h.AddModel("explain", "openai", "nano", ModelStatus.Primary); + var target = h.AddModel("explain", "openai-explain", "mini", ModelStatus.Shadow); + + await Assert.ThrowsAsync(() => + h.Service.PromoteAsync(target.Id, null, CancellationToken.None)); + + Assert.Equal(0, h.CommitCalls); // never committed + Assert.Equal(0, h.Routes.InvalidateCount); // cache NOT flipped on a failed swap + } + + [Fact] + public async Task RollbackAsync_ConcurrentChange_IndexViolation_SurfacesConflict_NoInvalidate() + { + var h = new Harness + { + ThrowOnSaveCall = new DbUpdateException("23505"), + ThrowOnSaveCallNumber = 99, // don't throw during the setup promote + }; + var a = h.AddModel("explain", "openai", "nano", ModelStatus.Primary); + var b = h.AddModel("explain", "openai-explain", "mini", ModelStatus.Shadow); + await h.Service.PromoteAsync(b.Id, null, CancellationToken.None); // 1st save ok + var invalidatesAfterPromote = h.Routes.InvalidateCount; + + // Next save (the rollback) throws the index violation. + h.ThrowOnSaveCallNumber = h.SaveCalls + 1; + await Assert.ThrowsAsync(() => + h.Service.RollbackAsync("explain", null, CancellationToken.None)); + + Assert.Equal(invalidatesAfterPromote, h.Routes.InvalidateCount); // rollback did NOT invalidate + } + + [Fact] + public async Task RollbackAsync_RestoreTargetGone_Conflicts_Not500() + { + // The prior primary row was deleted/retired-away since the promotion: rollback must + // surface a clean ConflictException (→409), never a NullReference/500. + var h = new Harness(); + var a = h.AddModel("explain", "openai", "nano", ModelStatus.Primary); + var b = h.AddModel("explain", "openai-explain", "mini", ModelStatus.Shadow); + await h.Service.PromoteAsync(b.Id, null, CancellationToken.None); + + h.Models.Remove(a); // the model we'd roll back TO no longer exists + + await Assert.ThrowsAsync(() => + h.Service.RollbackAsync("explain", null, CancellationToken.None)); + } + + [Fact] + public async Task RollbackAsync_RestoreTargetRetired_Conflicts() + { + var h = new Harness(); + var a = h.AddModel("explain", "openai", "nano", ModelStatus.Primary); + var b = h.AddModel("explain", "openai-explain", "mini", ModelStatus.Shadow); + await h.Service.PromoteAsync(b.Id, null, CancellationToken.None); + + a.Status = ModelStatus.Retired; // prior primary was retired since the promotion + + await Assert.ThrowsAsync(() => + h.Service.RollbackAsync("explain", null, CancellationToken.None)); + } + + [Fact] + public async Task PromoteAsync_PicksLatestPromotionForRollback_NotAnOlderFeaturesRow() + { + // Rollback must read the latest promotion FOR THIS feature only — an interleaved + // promotion of a DIFFERENT feature must not be mistaken for the one to revert. + var h = new Harness(); + var ex1 = h.AddModel("explain", "openai", "nano", ModelStatus.Primary); + var ex2 = h.AddModel("explain", "openai-explain", "mini", ModelStatus.Shadow); + var tr1 = h.AddModel("translate", "openai", "nano", ModelStatus.Primary); + var tr2 = h.AddModel("translate", "ollama", "gemma", ModelStatus.Shadow); + + await h.Service.PromoteAsync(ex2.Id, null, CancellationToken.None); // explain: ex1→shadow, ex2→primary + await h.Service.PromoteAsync(tr2.Id, null, CancellationToken.None); // translate (newer audit row) + + var result = await h.Service.RollbackAsync("explain", null, CancellationToken.None); + + Assert.Equal(ex1.Id, result.NewPrimary.Id); // restored explain's prior primary + Assert.Equal(ModelStatus.Primary, ex1.Status); + Assert.Equal(ModelStatus.Shadow, ex2.Status); + Assert.Equal(ModelStatus.Primary, tr2.Status); // translate untouched + } +} diff --git a/tests/TextStack.UnitTests/RegistryModelRouteProviderTests.cs b/tests/TextStack.UnitTests/RegistryModelRouteProviderTests.cs new file mode 100644 index 00000000..a41f4ea1 --- /dev/null +++ b/tests/TextStack.UnitTests/RegistryModelRouteProviderTests.cs @@ -0,0 +1,166 @@ +using Application.Ai; +using Application.Common.Interfaces; +using Domain.Entities; +using Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TextStack.UnitTests; + +/// +/// RegistryModelRouteProvider: builds ONE snapshot (feature → primary provider_key) of the +/// Models registry inside a fresh scope, caches it under a short TTL, serves repeat reads +/// from cache (one scope build), and rebuilds after Invalidate. Models set is a List behind a +/// Moq DbSet (async LINQ via TestAsyncQueryable); the scope factory counts builds. +/// +public class RegistryModelRouteProviderTests +{ + private sealed class Harness + { + public List Models { get; } = []; + public int ScopeBuilds { get; private set; } + public RegistryModelRouteProvider Provider { get; } + + public Harness() + { + var db = new Mock(); + db.Setup(x => x.Models).Returns(() => FakeSet(Models).Object); + + var scope = new Mock(); + var sp = new Mock(); + sp.Setup(x => x.GetService(typeof(IAppDbContext))).Returns(() => db.Object); + scope.Setup(x => x.ServiceProvider).Returns(sp.Object); + + var factory = new Mock(); + factory.Setup(x => x.CreateScope()).Returns(() => { ScopeBuilds++; return scope.Object; }); + + var cache = new MemoryCache(new MemoryCacheOptions()); + var config = new ConfigurationBuilder().Build(); // default TTL 30s + Provider = new RegistryModelRouteProvider( + factory.Object, cache, config, NullLogger.Instance); + } + + public void AddPrimary(string feature, string provider) => + Models.Add(new ModelRegistration + { + Id = Guid.NewGuid(), + FeatureTag = feature, + ProviderKey = provider, + ModelId = "m", + Status = ModelStatus.Primary, + CreatedAt = DateTimeOffset.UtcNow, + }); + } + + private static Mock> FakeSet(List data) where T : class + { + var q = new TestAsyncEnumerable(data); + var set = new Mock>(); + var iq = set.As>(); + iq.Setup(m => m.Provider).Returns(((IQueryable)q).Provider); + iq.Setup(m => m.Expression).Returns(((IQueryable)q).Expression); + iq.Setup(m => m.ElementType).Returns(((IQueryable)q).ElementType); + iq.Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator()); + set.As>() + .Setup(m => m.GetAsyncEnumerator(It.IsAny())) + .Returns(() => new TestAsyncEnumerator(data.GetEnumerator())); + return set; + } + + [Fact] + public void PrimaryProviderKey_BuildsSnapshot_ReturnsPrimaryKey() + { + var h = new Harness(); + h.AddPrimary("explain", "openai-explain"); + h.AddPrimary("translate", "openai"); + + Assert.Equal("openai-explain", h.Provider.PrimaryProviderKey("explain")); + Assert.Equal("openai", h.Provider.PrimaryProviderKey("translate")); + Assert.Null(h.Provider.PrimaryProviderKey("no-such-feature")); + } + + [Fact] + public void PrimaryProviderKey_OnlyPrimaryRows_AreSnapshotted() + { + var h = new Harness(); + h.AddPrimary("explain", "openai-explain"); + h.Models.Add(new ModelRegistration + { + Id = Guid.NewGuid(), FeatureTag = "explain", ProviderKey = "ollama", + ModelId = "m", Status = ModelStatus.Shadow, CreatedAt = DateTimeOffset.UtcNow, + }); + + Assert.Equal("openai-explain", h.Provider.PrimaryProviderKey("explain")); // shadow ignored + } + + [Fact] + public void PrimaryProviderKey_WithinTtl_ServedFromCache_OneBuild() + { + var h = new Harness(); + h.AddPrimary("explain", "openai-explain"); + + for (var i = 0; i < 5; i++) + Assert.Equal("openai-explain", h.Provider.PrimaryProviderKey("explain")); + + Assert.Equal(1, h.ScopeBuilds); // built once, then cached + } + + [Fact] + public void Invalidate_ForcesRebuild_AndPicksUpNewPrimary() + { + var h = new Harness(); + h.AddPrimary("explain", "openai"); + Assert.Equal("openai", h.Provider.PrimaryProviderKey("explain")); + Assert.Equal(1, h.ScopeBuilds); + + // Simulate a promotion: swap the registry's Primary. + h.Models.Clear(); + h.AddPrimary("explain", "openai-explain"); + + // Still cached → stale until invalidated. + Assert.Equal("openai", h.Provider.PrimaryProviderKey("explain")); + Assert.Equal(1, h.ScopeBuilds); + + h.Provider.Invalidate(); + Assert.Equal("openai-explain", h.Provider.PrimaryProviderKey("explain")); + Assert.Equal(2, h.ScopeBuilds); // rebuilt after invalidate + } + + [Fact] + public void PrimaryProviderKey_TwoPrimariesSameFeature_DoesNotThrow_DeterministicWinner() + { + // Defends the LLM hot path: if the partial unique index ever fails to enforce + // one-Primary-per-feature (bad migration filter, manual DB edit), the snapshot build + // must NOT crash via a duplicate-key ToDictionary — every call for that feature would + // 500. It must return a deterministic winner (oldest CreatedAt) and keep serving. + var h = new Harness(); + h.Models.Add(new ModelRegistration + { + Id = Guid.NewGuid(), FeatureTag = "explain", ProviderKey = "openai-explain", + ModelId = "m", Status = ModelStatus.Primary, + CreatedAt = DateTimeOffset.UtcNow.AddDays(-2), // older → wins + }); + h.Models.Add(new ModelRegistration + { + Id = Guid.NewGuid(), FeatureTag = "explain", ProviderKey = "ollama", + ModelId = "m", Status = ModelStatus.Primary, + CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), + }); + + var key = h.Provider.PrimaryProviderKey("explain"); // must not throw + Assert.Equal("openai-explain", key); // oldest deterministically wins + } + + [Fact] + public void PrimaryProviderKey_NullOrBlankFeature_ReturnsNull_NoBuild() + { + var h = new Harness(); + Assert.Null(h.Provider.PrimaryProviderKey("")); + Assert.Null(h.Provider.PrimaryProviderKey(" ")); + Assert.Equal(0, h.ScopeBuilds); + } +}