feat: meter AI usage
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ai/usage")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class AiUsageController : ControllerBase
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly JobTrackerContext _db;
|
||||
|
||||
public AiUsageController(UserManager<ApplicationUser> users, JobTrackerContext db)
|
||||
{
|
||||
_users = users;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public sealed record UsagePeriodDto(int Calls, long InputCharacters, long OutputCharacters, long EstimatedTokens);
|
||||
public sealed record UsageDto(UsagePeriodDto CurrentMonth, UsagePeriodDto AllTime);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<UsageDto>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
return Ok(new UsageDto(
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart), cancellationToken),
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken)));
|
||||
}
|
||||
|
||||
private static async Task<UsagePeriodDto> SumAsync(IQueryable<AiInteraction> query, CancellationToken cancellationToken)
|
||||
{
|
||||
var totals = await query.GroupBy(_ => 1).Select(group => new UsagePeriodDto(
|
||||
group.Count(),
|
||||
group.Sum(x => (long)x.InputCharacterCount),
|
||||
group.Sum(x => (long)x.OutputCharacterCount),
|
||||
group.Sum(x => (long)x.EstimatedTokenCount))).FirstOrDefaultAsync(cancellationToken);
|
||||
return totals ?? new UsagePeriodDto(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
}
|
||||
|
||||
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
|
||||
public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, DateTimeOffset CreatedAtUtc);
|
||||
public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, int InputCharacterCount, int OutputCharacterCount, int EstimatedTokenCount, DateTimeOffset CreatedAtUtc);
|
||||
|
||||
[HttpGet("modules")]
|
||||
public ActionResult<object> Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() });
|
||||
@@ -86,5 +86,5 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
private static InteractionDto ToDto(AiInteraction x) => new(
|
||||
x.Id, x.Module, x.Mode, x.Title, x.Provider,
|
||||
JsonSerializer.Deserialize<JsonElement>(string.IsNullOrWhiteSpace(x.ResultJson) ? "{}" : x.ResultJson),
|
||||
x.CreatedAtUtc);
|
||||
x.InputCharacterCount, x.OutputCharacterCount, x.EstimatedTokenCount, x.CreatedAtUtc);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAiUsageMetering : Migration
|
||||
{
|
||||
// Intentionally a no-op: StartupInitializationExtensions owns idempotent SQLite/MariaDB
|
||||
// column reconciliation and runs before EF migrations. The snapshot records the model change;
|
||||
// the reconciler performs the provider-safe DDL without duplicate-column failures.
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ namespace JobTrackerApi.Migrations
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EstimatedTokenCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("InputCharacterCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("JobApplicationId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -37,6 +43,9 @@ namespace JobTrackerApi.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("OutputCharacterCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OwnerUserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
|
||||
@@ -133,7 +133,8 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
_ => throw new ArgumentException($"Unknown AI module '{module}'."),
|
||||
};
|
||||
|
||||
var result = await _ai.SummarizeSectionAsync($"{instruction} {Guardrail}", source, max, 120);
|
||||
var prompt = $"{instruction} {Guardrail}";
|
||||
var result = await _ai.SummarizeSectionAsync(prompt, source, max, 120);
|
||||
if (string.IsNullOrWhiteSpace(result))
|
||||
{
|
||||
throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment.");
|
||||
@@ -148,6 +149,9 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
Title = title,
|
||||
Provider = string.IsNullOrWhiteSpace(provider) ? "ai-service" : provider,
|
||||
ResultJson = JsonSerializer.Serialize(new { text = result.Trim() }, Json),
|
||||
InputCharacterCount = prompt.Length + source.Length,
|
||||
OutputCharacterCount = result.Trim().Length,
|
||||
EstimatedTokenCount = EstimateTokens(prompt.Length + source.Length + result.Trim().Length),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
_db.AiInteractions.Add(interaction);
|
||||
@@ -174,6 +178,8 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static int EstimateTokens(int characterCount) => Math.Max(0, (characterCount + 3) / 4);
|
||||
|
||||
private static string? NormalizeMode(string module, string? mode)
|
||||
{
|
||||
if (module != "cover-letter") return null;
|
||||
|
||||
@@ -1016,10 +1016,16 @@ public static class StartupInitializationExtensions
|
||||
"Title" TEXT NOT NULL,
|
||||
"Provider" TEXT NOT NULL,
|
||||
"ResultJson" TEXT NOT NULL,
|
||||
"InputCharacterCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"OutputCharacterCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"EstimatedTokenCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_AiInteractions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
EnsureColumn(c, "AiInteractions", "InputCharacterCount", "ALTER TABLE AiInteractions ADD COLUMN InputCharacterCount INTEGER NOT NULL DEFAULT 0;");
|
||||
EnsureColumn(c, "AiInteractions", "OutputCharacterCount", "ALTER TABLE AiInteractions ADD COLUMN OutputCharacterCount INTEGER NOT NULL DEFAULT 0;");
|
||||
EnsureColumn(c, "AiInteractions", "EstimatedTokenCount", "ALTER TABLE AiInteractions ADD COLUMN EstimatedTokenCount INTEGER NOT NULL DEFAULT 0;");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_JobApplicationId" ON "AiInteractions" ("JobApplicationId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_Owner_Job_Module_Created" ON "AiInteractions" ("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc");""");
|
||||
}
|
||||
@@ -1760,6 +1766,9 @@ public static class StartupInitializationExtensions
|
||||
`Title` varchar(255) NOT NULL,
|
||||
`Provider` varchar(100) NOT NULL,
|
||||
`ResultJson` longtext NOT NULL,
|
||||
`InputCharacterCount` int NOT NULL DEFAULT 0,
|
||||
`OutputCharacterCount` int NOT NULL DEFAULT 0,
|
||||
`EstimatedTokenCount` int NOT NULL DEFAULT 0,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_AiInteractions_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
@@ -1842,6 +1851,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id");
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiInteractions", "Id");
|
||||
EnsureMySqlColumn(conn, "AiInteractions", "InputCharacterCount", "ALTER TABLE `AiInteractions` ADD COLUMN `InputCharacterCount` int NOT NULL DEFAULT 0;");
|
||||
EnsureMySqlColumn(conn, "AiInteractions", "OutputCharacterCount", "ALTER TABLE `AiInteractions` ADD COLUMN `OutputCharacterCount` int NOT NULL DEFAULT 0;");
|
||||
EnsureMySqlColumn(conn, "AiInteractions", "EstimatedTokenCount", "ALTER TABLE `AiInteractions` ADD COLUMN `EstimatedTokenCount` int NOT NULL DEFAULT 0;");
|
||||
|
||||
foreach (var (ixTable, ixName, ixColumns, ixUnique) in new[]
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user