From 25a6da951e8bf54de86802401701f28466300d36 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 15 Aug 2026 20:25:10 +0200 Subject: [PATCH] fix(ai): meter synchronous generations --- .../MeteredSummarizerServiceTests.cs | 191 ++++++++++++++++++ .../Controllers/AiWorkspaceController.cs | 5 +- JobTrackerApi/Program.cs | 5 +- .../Services/AiUsageExecutionScope.cs | 25 +++ .../Services/AiUsageLimitExceptionHandler.cs | 26 +++ .../Services/MeteredSummarizerService.cs | 89 ++++++++ docs/audits/verification-log.md | 1 + .../pol-001-free-pro-entitlements.md | 29 +-- docs/work-programmes/master-work-plan.md | 10 +- 9 files changed, 360 insertions(+), 21 deletions(-) create mode 100644 JobTrackerApi.Tests/MeteredSummarizerServiceTests.cs create mode 100644 JobTrackerApi/Services/AiUsageExecutionScope.cs create mode 100644 JobTrackerApi/Services/AiUsageLimitExceptionHandler.cs create mode 100644 JobTrackerApi/Services/MeteredSummarizerService.cs diff --git a/JobTrackerApi.Tests/MeteredSummarizerServiceTests.cs b/JobTrackerApi.Tests/MeteredSummarizerServiceTests.cs new file mode 100644 index 0000000..b926e1e --- /dev/null +++ b/JobTrackerApi.Tests/MeteredSummarizerServiceTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using System.Text; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Http; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class MeteredSummarizerServiceTests +{ + [Fact] + public async Task Usage_limit_handler_returns_stable_429_problem() + { + var context = new DefaultHttpContext { TraceIdentifier = "trace-ai-limit" }; + context.Response.Body = new MemoryStream(); + + Assert.True(await new AiUsageLimitExceptionHandler().TryHandleAsync( + context, + new AiUsageLimitException("monthly_ai_calls_exhausted", "Monthly AI limit reached."), + default)); + + Assert.Equal(StatusCodes.Status429TooManyRequests, context.Response.StatusCode); + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body); + var body = await reader.ReadToEndAsync(); + Assert.Contains("monthly_ai_calls_exhausted", body, StringComparison.Ordinal); + Assert.Contains("trace-ai-limit", body, StringComparison.Ordinal); + } + + [Fact] + public async Task Successful_synchronous_generation_is_admitted_and_finalized() + { + await using var fixture = await Fixture.CreateAsync("{\"summary\":\"measured result\"}"); + + var result = await fixture.Service.SummarizeAsync("measured input", 150, 30); + + Assert.Equal("measured result", result); + var usage = Assert.Single(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync()); + Assert.Equal("synchronous", usage.SourceType); + Assert.Equal("synchronous.summary", usage.TaskType); + Assert.Equal("measured input".Length, usage.InputCharacterCount); + Assert.Equal("measured result".Length, usage.OutputCharacterCount); + Assert.Equal((usage.InputCharacterCount + usage.OutputCharacterCount + 3) / 4, usage.EstimatedTokenCount); + Assert.Equal(1, fixture.Handler.RequestCount); + } + + [Fact] + public async Task Exhausted_limit_rejects_before_provider_call() + { + await using var fixture = await Fixture.CreateAsync("{\"summary\":\"must not run\"}"); + fixture.Db.AiUsageRecords.Add(new AiUsageRecord + { + OwnerUserId = "owner", + SourceType = "synthetic", + SourceId = "monthly-limit", + TaskType = "synthetic", + CallCount = 250, + EstimatedTokenCount = 1, + CreatedAtUtc = DateTimeOffset.UtcNow, + }); + await fixture.Db.SaveChangesAsync(); + + var error = await Assert.ThrowsAsync(() => fixture.Service.SummarizeAsync("blocked")); + + Assert.Equal("monthly_ai_calls_exhausted", error.Code); + Assert.Equal(0, fixture.Handler.RequestCount); + } + + [Fact] + public async Task Existing_metered_scope_bypasses_the_synchronous_decorator() + { + await using var fixture = await Fixture.CreateAsync("{\"summary\":\"already metered\"}"); + + using (fixture.UsageScope.Suppress()) + Assert.Equal("already metered", await fixture.Service.SummarizeAsync("workspace input")); + + Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync()); + Assert.Equal(1, fixture.Handler.RequestCount); + } + + [Fact] + public async Task Durable_operation_scope_does_not_create_a_second_usage_record() + { + await using var fixture = await Fixture.CreateAsync("{\"summary\":\"operation result\"}"); + var lease = new UserOperationLease( + Guid.NewGuid(), "owner", "lease", "strategy.snapshot", "local_only", "job", "1", 1, null); + + using (fixture.OperationScope.Use(new AiOperationExecutionContext(lease, "local_only"))) + Assert.Equal("operation result", await fixture.Service.SummarizeAsync("operation input")); + + Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync()); + Assert.Equal(1, fixture.Handler.RequestCount); + } + + [Fact] + public async Task Free_owner_is_rejected_at_the_shared_provider_boundary() + { + await using var fixture = await Fixture.CreateAsync("{\"summary\":\"must not run\"}", Array.Empty()); + + var error = await Assert.ThrowsAsync(() => fixture.Service.SummarizeAsync("blocked")); + + Assert.Equal("ai_not_available", error.Code); + Assert.Empty(await fixture.Db.AiUsageRecords.AsNoTracking().ToListAsync()); + Assert.Equal(0, fixture.Handler.RequestCount); + } + + private sealed class Fixture : IAsyncDisposable + { + private readonly SqliteConnection _connection; + public JobTrackerContext Db { get; } + public MeteredSummarizerService Service { get; } + public CountingHandler Handler { get; } + public AiUsageExecutionScope UsageScope { get; } + public AiOperationExecutionScope OperationScope { get; } + + private Fixture( + SqliteConnection connection, + JobTrackerContext db, + MeteredSummarizerService service, + CountingHandler handler, + AiUsageExecutionScope usageScope, + AiOperationExecutionScope operationScope) + { + _connection = connection; + Db = db; + Service = service; + Handler = handler; + UsageScope = usageScope; + OperationScope = operationScope; + } + + public static async Task CreateAsync(string responseJson, IReadOnlyList? roles = null) + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns("owner"); + var db = new JobTrackerContext( + new DbContextOptionsBuilder().UseSqlite(connection).Options, + currentUser.Object); + await db.Database.EnsureCreatedAsync(); + + var handler = new CountingHandler(responseJson); + var client = new HttpClient(handler) { BaseAddress = new Uri("http://ai.test") }; + var factory = new Mock(); + factory.Setup(item => item.CreateClient("ai-service")).Returns(client); + var inner = new SummarizerService(factory.Object, new MemoryCache(new MemoryCacheOptions())); + var user = new ApplicationUser { Id = "owner", AiEnabled = true }; + var users = TestHostFactory.CreateUserManager(user); + users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync((roles ?? new[] { "Premium" }).ToList()); + var usageScope = new AiUsageExecutionScope(); + var operationScope = new AiOperationExecutionScope(); + var service = new MeteredSummarizerService( + inner, + db, + users.Object, + new AiUsageMeter(db, TimeProvider.System), + operationScope, + usageScope); + return new Fixture(connection, db, service, handler, usageScope, operationScope); + } + + public async ValueTask DisposeAsync() + { + await Db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } + + public sealed class CountingHandler(string responseJson) : HttpMessageHandler + { + public int RequestCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), + }); + } + } +} diff --git a/JobTrackerApi/Controllers/AiWorkspaceController.cs b/JobTrackerApi/Controllers/AiWorkspaceController.cs index bf6014e..c9cfcce 100644 --- a/JobTrackerApi/Controllers/AiWorkspaceController.cs +++ b/JobTrackerApi/Controllers/AiWorkspaceController.cs @@ -20,14 +20,16 @@ public sealed class AiWorkspaceController : ControllerBase private readonly IConfiguration _config; private readonly JobTrackerApi.Data.JobTrackerContext? _db; private readonly AiUsageMeter? _usage; + private readonly AiUsageExecutionScope? _usageScope; - public AiWorkspaceController(UserManager users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null, AiUsageMeter? usage = null) + public AiWorkspaceController(UserManager users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null, AiUsageMeter? usage = null, AiUsageExecutionScope? usageScope = null) { _users = users; _workspace = workspace; _config = config; _db = db; _usage = usage; + _usageScope = usageScope; } public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext); @@ -71,6 +73,7 @@ public sealed class AiWorkspaceController : ControllerBase try { + using var metering = _usageScope?.Suppress(); var interaction = await _workspace.GenerateAsync( user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user), new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct); diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 7d0d646..096a25d 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -39,6 +39,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(externalOrigin); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); @@ -149,6 +150,7 @@ builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier; }); +builder.Services.AddExceptionHandler(); builder.Services.AddOpenApi(); var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim(); if (string.IsNullOrWhiteSpace(dataRoot)) @@ -203,7 +205,8 @@ builder.Services.AddHttpClient("ai-service", client => builder.Services.AddMemoryCache(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/AiUsageExecutionScope.cs b/JobTrackerApi/Services/AiUsageExecutionScope.cs new file mode 100644 index 0000000..cc49a81 --- /dev/null +++ b/JobTrackerApi/Services/AiUsageExecutionScope.cs @@ -0,0 +1,25 @@ +namespace JobTrackerApi.Services; + +public sealed class AiUsageExecutionScope +{ + private readonly AsyncLocal _suppressionDepth = new(); + + public bool IsSuppressed => _suppressionDepth.Value > 0; + + public IDisposable Suppress() + { + _suppressionDepth.Value++; + return new Restore(this); + } + + private sealed class Restore(AiUsageExecutionScope owner) : IDisposable + { + private AiUsageExecutionScope? _owner = owner; + + public void Dispose() + { + var current = Interlocked.Exchange(ref _owner, null); + if (current is not null) current._suppressionDepth.Value--; + } + } +} diff --git a/JobTrackerApi/Services/AiUsageLimitExceptionHandler.cs b/JobTrackerApi/Services/AiUsageLimitExceptionHandler.cs new file mode 100644 index 0000000..1d629eb --- /dev/null +++ b/JobTrackerApi/Services/AiUsageLimitExceptionHandler.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Services; + +public sealed class AiUsageLimitExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + if (exception is not AiUsageLimitException limit) return false; + + httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await httpContext.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = StatusCodes.Status429TooManyRequests, + Title = "AI usage limit reached", + Detail = limit.Message, + Extensions = + { + ["code"] = limit.Code, + ["traceId"] = httpContext.TraceIdentifier, + }, + }, cancellationToken); + return true; + } +} diff --git a/JobTrackerApi/Services/MeteredSummarizerService.cs b/JobTrackerApi/Services/MeteredSummarizerService.cs new file mode 100644 index 0000000..6fafe57 --- /dev/null +++ b/JobTrackerApi/Services/MeteredSummarizerService.cs @@ -0,0 +1,89 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.AspNetCore.Identity; + +namespace JobTrackerApi.Services; + +// The shared synchronous provider boundary. Durable operations and the AI workspace reserve +// usage before reaching this layer, so their execution scopes explicitly bypass this decorator. +public sealed class MeteredSummarizerService( + SummarizerService inner, + JobTrackerContext db, + UserManager users, + AiUsageMeter usage, + AiOperationExecutionScope operationScope, + AiUsageExecutionScope usageScope) : ISummarizerService +{ + public Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30) + => MeterAsync("synchronous.summary", text, maxLength, ct => inner.SummarizeAsync(text, maxLength, minLength), CancellationToken.None); + + public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) + => MeterAsync("synchronous.rewrite", instruction + "\n\n" + text, maxLength, + ct => inner.SummarizeSectionAsync(instruction, text, maxLength, minLength), CancellationToken.None); + + public Task GenerateSectionWithMetadataAsync( + string instruction, + string text, + int maxLength = 180, + int minLength = 40, + CancellationToken cancellationToken = default) + => MeterAsync("synchronous.generate", instruction + "\n\n" + text, maxLength, + ct => inner.GenerateSectionWithMetadataAsync(instruction, text, maxLength, minLength, ct), cancellationToken); + + public Task ExtractTextAsync( + Stream stream, + string fileName, + string? contentType = null, + CancellationToken cancellationToken = default) + => inner.ExtractTextAsync(stream, fileName, contentType, cancellationToken); + + public Task RunProbeAsync(CancellationToken cancellationToken = default) + => inner.RunProbeAsync(cancellationToken); + + public Task GetMetricsAsync(CancellationToken cancellationToken = default) + => inner.GetMetricsAsync(cancellationToken); + + private async Task MeterAsync( + string taskType, + string input, + int maximumOutputCharacters, + Func> generate, + CancellationToken cancellationToken) + where T : class + { + if (usageScope.IsSuppressed || operationScope.Current is not null || string.IsNullOrWhiteSpace(db.CurrentUserId)) + return await generate(cancellationToken); + + var ownerUserId = db.CurrentUserId; + var user = await users.FindByIdAsync(ownerUserId); + var entitlements = user is null ? AccountPlans.ForRoles(null) : AccountPlans.ForRoles(await users.GetRolesAsync(user)); + if (user is null || !user.AiEnabled || !entitlements.Ai) + throw new AiUsageLimitException("ai_not_available", "AI features require an active Pro account with AI enabled."); + + var boundedInputCharacters = Math.Min(input.Length, 20_000); + var boundedOutputCharacters = Math.Clamp(maximumOutputCharacters, 0, 4_096); + var estimatedTokens = Math.Max(1, (boundedInputCharacters + boundedOutputCharacters + 3) / 4); + var reservation = await usage.ReserveAsync( + ownerUserId, + entitlements, + "synchronous", + Guid.NewGuid().ToString("D"), + taskType, + boundedInputCharacters, + estimatedTokens, + cancellationToken); + + var result = await generate(cancellationToken); + switch (result) + { + case string text: + await usage.FinalizeAsync(reservation.Record.Id, boundedInputCharacters, text.Length, cancellationToken); + break; + case AiGenerationResult generation: + await usage.FinalizeAsync(reservation.Record.Id, boundedInputCharacters, generation.Text.Length, cancellationToken); + break; + } + + return result; + } +} diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index 59527e8..f73cba6 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -215,3 +215,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un | V-181 | AI usage meter/operation/workspace/export/deletion real-SQLite tests; EF model check; SQLite/MariaDB scripts; disposable SQLite backfill and fresh application startup; full backend | Repository root | Make Workspace and durable Strategy/CV usage owner-safe, idempotent and independent of deletable private history | PASS — focused 28/28 and backend 663/663; no pending model changes; both providers generate bounded additive DDL; SQLite backfills the synthetic legacy row exactly once; fresh runtime applies through `20260815175236_AddCrossFeatureAiUsage` and serves `/health` | Synthetic local rows only; no provider/model call, MariaDB server, production migration or worker activation. CV retains a conservative reservation and older synchronous AI paths are not yet universal | Main durable usage boundary implemented; remaining synchronous producers stay tracked under POL-001 | | V-182 | Real ASP.NET Identity data-protection token integration on SQLite; focused auth tests; full backend | Repository root | Close SEC-005B expiry/replay/custom-username proof without SMTP or production | PASS — valid confirmation succeeds once, replay and zero-lifetime expiry return the same generic failure, a real change-email token preserves a custom username and cannot replay; focused 39/39 and backend 666/666 | Synthetic addresses and ephemeral local data-protection keys only; no email, browser, MariaDB or production call | SEC-005B local token-state gap closed | | V-183 | Owner-filtered job-choice API test; correspondence Jest; frontend production build | Repository root / `job-tracker-ui` | Remove the email compose/thread-move selectors' false 100-job ceiling | PASS — backend search finds the oldest target among 130 owned rows and excludes another tenant; correspondence 20/20 proves debounced server search, compose selection and thread-move selection; TypeScript/production build passes | Synthetic rows/JSDOM only; no provider, email or production action | MAIL-001 exhaustive job selection gap closed | +| V-184 | Shared synchronous AI provider decorator, durable/workspace suppression scopes, quota exception handler and full backend | Repository root | Make numeric Free/Pro AI limits universal without double-counting already-reserved work | PASS — focused shared-provider/accounting suite 25/25 and backend 674/674; success finalizes measured characters, Free/exhausted requests stop before provider I/O, workspace/operation scopes create no second row, and quota failures return stable 429 details | Fake in-process provider and SQLite only; no model, Stripe, MariaDB or production call | POL-001 repository accounting gap closed; Stripe lifecycle and production smoke remain | diff --git a/docs/verification/pol-001-free-pro-entitlements.md b/docs/verification/pol-001-free-pro-entitlements.md index 30a75ff..fb78699 100644 --- a/docs/verification/pol-001-free-pro-entitlements.md +++ b/docs/verification/pol-001-free-pro-entitlements.md @@ -1,6 +1,6 @@ # POL-001 Free/Pro entitlement verification -Date: 2026-08-02 +Date: 2026-08-15 Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free locked states and automated regressions pass. Real-browser, Stripe lifecycle, MariaDB and production checks remain unavailable. @@ -11,26 +11,26 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free - Pro and Admin use AI and Pro CV themes. The persisted Identity role remains `Premium`, and `Stripe:PricePremium` remains a compatibility key; neither is exposed as a public plan name. - Current database roles are authoritative on every explicit HTTP AI action. A stale role claim cannot preserve access after downgrade. - A locked explicit action returns HTTP 403 with `{ "code": "pro_required", "message": "This AI feature requires Pro." }`. -- Existing 250-call/1,000,000-token Pro ceilings remain because they are defined in the existing implementation roadmap. Free ceilings are zero. A content-free `AiUsageRecord` ledger is now authoritative for AI Workspace plus durable Strategy and CV work; legacy `AiInteraction` usage is backfilled. Older synchronous AI actions still need the same admission boundary before the numeric ceilings can be described as universal. +- Existing 250-call/1,000,000-token Pro ceilings remain because they are defined in the existing implementation roadmap. Free ceilings are zero. A content-free `AiUsageRecord` ledger is authoritative for AI Workspace, durable Strategy/CV work and every user-scoped generation through the shared synchronous provider boundary; legacy `AiInteraction` usage is backfilled. Health probes and non-generative text extraction are intentionally excluded. ## Entry-point inventory | Capability | User entry / frontend | API or worker execution path | Admission and recheck | Usage accounting | Free behavior | |---|---|---|---|---|---| | AI Workspace modules | Job details → AI Workspace; `AiWorkspacePanel` | `POST /api/jobapplications/{jobId}/ai/generate` → `AiWorkspaceService` → `ISummarizerService` | `Pro` policy with live role lookup | Ledger reservation before generation; actual estimate finalized on success | Generate disabled; existing history/read/delete remain available | -| Candidate fit | Job details Candidate Fit and Strategy Snapshot | `GET .../{id}/candidate-fit` → attachment/correspondence context → multiple summarizer calls | `Pro` policy | No complete shared usage row | Deterministic `match-score` remains available; AI narrative locked | +| Candidate fit | Job details Candidate Fit and Strategy Snapshot | `GET .../{id}/candidate-fit` → attachment/correspondence context → multiple summarizer calls | `Pro` policy plus shared provider admission | One ledger row per provider generation | Deterministic `match-score` remains available; AI narrative locked | | Focus plan | Job details Focus Plan and Strategy Snapshot | Durable `strategy.snapshot` operation → summarizer | `Pro` admission plus worker recheck | Atomic operation-ledger reservation; successful input/output estimate finalized | Locked; no synthetic fallback presented as generated | -| Interview brief | Job details Interview Prep | `GET .../{id}/interview-prep/brief` → summarizer | `Pro` policy | No complete shared usage row | Editable non-AI interview board remains available; generated brief locked | -| Tailored CV generation | Add Job option and job Tailored CV tab | `POST .../{id}/generate-tailored-cv-draft` → shared generation helpers → summarizer | `Pro` policy | No complete shared usage row | Job creation and manual tailored-draft editing remain available; no operation is started | -| Application package | Job workspace drafts | `POST .../{id}/generate-application-package` → attachment/email context → summarizer | `Pro` policy | No complete shared usage row | Existing/manual package drafts remain readable and editable | -| Follow-up draft | Job Follow-up tab | `GET .../{id}/followup-draft` → context → summarizer | `Pro` policy | No complete shared usage row | Manual correspondence data remains available; AI draft is locked | -| Job summary refresh | Job overview | `POST .../{id}/refresh-ai` → `SummarizeAsync` | `Pro` policy | No complete shared usage row | Existing summary/tags remain visible; refresh locked | -| Automatic job summary | Job create/detail | Core `POST /jobapplications` and `GET /{id}` optional summarizer calls | Live role condition inside core action | No complete shared usage row | Core request succeeds without calling AI | +| Interview brief | Job details Interview Prep | `GET .../{id}/interview-prep/brief` → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | Editable non-AI interview board remains available; generated brief locked | +| Tailored CV generation | Add Job option and job Tailored CV tab | `POST .../{id}/generate-tailored-cv-draft` → shared generation helpers → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | Job creation and manual tailored-draft editing remain available; no operation is started | +| Application package | Job workspace drafts | `POST .../{id}/generate-application-package` → attachment/email context → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | Existing/manual package drafts remain readable and editable | +| Follow-up draft | Job Follow-up tab | `GET .../{id}/followup-draft` → context → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | Manual correspondence data remains available; AI draft is locked | +| Job summary refresh | Job overview | `POST .../{id}/refresh-ai` → `SummarizeAsync` | `Pro` policy plus shared provider admission | One ledger row per provider generation | Existing summary/tags remain visible; refresh locked | +| Automatic job summary | Job create/detail | Core `POST /jobapplications` and `GET /{id}` optional summarizer calls | Live role condition plus shared provider admission | One ledger row when a provider generation runs | Core request succeeds without calling AI | | CV import/parse | Career Profile upload/parse/reprocess | `/profile-cv/upload`, `/parse`, `/reprocess` → durable `cv.process` operation | `Pro` policy before admission; queued run rechecks live roles | Atomic conservative operation-ledger reservation; no raw CV content | Manual profile editing and previous review runs remain available | -| CV rebuild/improve/rewrite/PDF | Career Profile AI buttons | `/rebuild`, `/improve`, `/rewrite-section`, `/rewrite-preview`, `/export-pdf` | `Pro` policy; queued rebuild/improve recheck live roles | CV-run state only | AI controls locked; manual profile data remains available | -| CV Builder writing aid | CV Builder AI Tools | `POST /api/cv/ai/assist` → summarizer | `Pro` policy | No complete shared usage row | AI buttons disabled; CV editing/history remain available | +| CV rebuild/improve/rewrite/PDF | Career Profile AI buttons | `/rebuild`, `/improve`, `/rewrite-section`, `/rewrite-preview`, `/export-pdf` | `Pro` policy; queued work rechecks roles and synchronous generation uses shared admission | Durable operation reservation or synchronous provider ledger row | AI controls locked; manual profile data remains available | +| CV Builder writing aid | CV Builder AI Tools | `POST /api/cv/ai/assist` → summarizer | `Pro` policy plus shared provider admission | One ledger row per provider generation | AI buttons disabled; CV editing/history remain available | | Pro CV themes | CV Builder Customize | `GET /api/cv/themes`; create/save validates selected theme | Live role lookup in theme catalog checks | Not applicable | Pro themes identified and unavailable; existing unchanged selection can still be saved | -| Job enrichment worker | No direct UI; disabled by default | `JobEnrichmentHostedService` per owner | Live role recheck immediately before summary; deterministic tag detection still runs for Free | No complete shared usage row | No model call; core tag enrichment remains possible | +| Job enrichment worker | No direct UI; disabled by default | `JobEnrichmentHostedService` per owner | Live role recheck plus shared provider admission immediately before summary; deterministic tag detection still runs for Free | One ledger row per provider generation | No model call; core tag enrichment remains possible | | Admin AI probe | Admin system diagnostics | `/api/admin/system/ai/probe` | Admin role; Admin maps to Pro | Health metric only | Not a Free user path | | Periodic service probe | No user entry | summarizer health probe | No private/user payload; operational health only | Health metric only | Not a user AI capability | | Attachment storage | Add-job/files UI | `AttachmentsController` storage check | Central Free/Pro storage entitlement | Bytes stored | 250 MB Free; 5 GB Pro (existing defined capability) | @@ -43,7 +43,8 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free - `AccountPlansTests`: Free zero AI, Pro/Admin AI, and only `free`/`pro` external names. - AI Workspace UI test: Free locked state, disabled generation and upgrade link. - `AiUsageMeterTests`, operation integration, account export/deletion and SQLite compatibility tests cover idempotent reservation, limits, owner isolation, history-independent totals, Strategy finalization, CV conservative reservation and lifecycle handling. -- Full backend after the ledger migration: 663/663. +- `MeteredSummarizerServiceTests` prove synchronous success finalization, pre-provider quota rejection, Free-user rejection, workspace/operation double-count suppression and stable HTTP 429 problem details. +- Full backend after universal provider admission: 674/674. - Full backend: 568/568. - Full frontend: 47/47 suites, 157/157 tests. - Production frontend build: pass. @@ -55,7 +56,7 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free - Stripe webhook transitions were code-inspected and existing status tests cover active/trialing vs expired states, but no real or mocked end-to-end checkout/webhook cycle ran in this package. - MariaDB and production were not changed or tested. - PRODUCT-001 removed landing-page prices, the third “Bring your own key” tier, Free AI allowance and “Unlimited AI” claims. Public capability copy now comes from one two-plan catalogue; commercial terms remain in configured Stripe Checkout. -- The durable ledger now spans AI Workspace, Strategy Snapshot and CV processing, and deleting user-visible AI history no longer erases usage. Candidate Fit, Interview Prep, application-package/follow-up drafting, CV Builder assistance and automatic summary paths remain synchronous and are not yet universally admitted through this ledger; the UI must therefore avoid claiming that the displayed numeric ceiling covers every AI path. +- The durable ledger spans AI Workspace, Strategy Snapshot, CV processing and all user-scoped calls through `ISummarizerService`. Failed or empty provider attempts retain their conservative reservation because they may still have consumed provider capacity; successful generations replace it with measured input/output. Health probes and extraction-only calls are not user generation usage. ## Rollback diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index 7ec50b9..e7aa4d6 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -395,10 +395,10 @@ This queue records the highest-value work that can proceed without production cr - **Required browser verification:** locked state/upgrade action/dismissal and Pro execution; mobile/theme/accessibility. - **Required production verification:** configured Stripe/role mapping only when operator activation is approved. - **Status:** `IMPLEMENTED — NOT VERIFIED`. -- **Blocker:** Stripe/MariaDB/production verification is unavailable. The main durable producers are accounted, but older synchronous AI actions still need ledger admission before numeric limits are universal. -- **Evidence:** `docs/verification/pol-001-free-pro-entitlements.md`; V-181; focused accounting/operation/lifecycle 28/28; full backend 663/663; existing frontend/browser entitlement evidence. +- **Blocker:** Stripe/MariaDB/production verification is unavailable. Repository entitlement and universal user-generation accounting are complete. +- **Evidence:** `docs/verification/pol-001-free-pro-entitlements.md`; V-181/V-184; focused shared-provider accounting 25/25; full backend 674/674; existing frontend/browser entitlement evidence. - **Commit:** none. -- **Remaining work:** mocked Stripe expiry/downgrade lifecycle; move remaining synchronous AI actions through ledger admission; production role/config smoke. PRODUCT-001 has removed the former landing-page price/third-tier/unlimited claims. +- **Remaining work:** mocked Stripe checkout/webhook expiry/downgrade lifecycle and production role/config smoke. PRODUCT-001 has removed the former landing-page price/third-tier/unlimited claims. ### POL-002 — AI privacy, consent and external-fallback policy @@ -416,7 +416,7 @@ This queue records the highest-value work that can proceed without production cr - **Blocker:** browser localhost is denied; MariaDB/production/external-provider verification is unavailable. Task-specific payload minimization/accounting depend on AI-003/004. - **Evidence:** `docs/verification/pol-002-ai-privacy.md`; focused backend 72/72 and final policy 28/28; sidecar 18/18; focused frontend 8/8; full backend 576/576; full frontend 47 suites/158 tests; production build; config and migration script checks. - **Commit:** none. -- **Remaining work:** browser user/admin disclosure checks; MariaDB and production synthetic egress proof; AI-003/004 task-specific payload minimization, accounting and real producer verification. AI-001/002 now carry rechecked policy/task context and record bounded local-first provenance. +- **Remaining work:** browser user/admin disclosure checks; MariaDB and production synthetic egress proof; AI-003/004 task-specific payload minimization and real producer verification. AI-001/002 now carry rechecked policy/task context and record bounded local-first provenance; V-184 closes shared synchronous accounting. ### AI-001 — Durable AI queue, backpressure and operation APIs @@ -452,7 +452,7 @@ This queue records the highest-value work that can proceed without production cr - **Blocker:** browser and production checks, actual local-model selection and controlled provider fallback depend on administrator browser policy plus PROD-001/003 access/benchmarks. Repository behavior is not blocked. - **Evidence:** `docs/verification/ai-002-provider-routing.md`; V-098–V-100; focused backend 26/26, full backend 588/588, sidecar fake-transport 22/22, Compose/diff checks pass. - **Commit:** none. -- **Remaining work:** keep Strategy/CV local-only until explicit external task approval; move older synchronous AI actions through central usage admission; MariaDB/selected-model/controlled-provider/production verification. Old provider/model configuration remains available for rollback. +- **Remaining work:** keep Strategy/CV local-only until explicit external task approval; MariaDB/selected-model/controlled-provider/production verification. Shared synchronous AI actions now use central usage admission (V-184). Old provider/model configuration remains available for rollback. ### PROD-001 — Read-only production AI inventory and rollout safety