feat(career): add factual evidence bank

This commit is contained in:
cesnimda
2026-08-31 17:03:09 +02:00
parent 5337ba3a5e
commit 78ad0c66f9
16 changed files with 362 additions and 5 deletions
@@ -0,0 +1,84 @@
using System.Text.Json;
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/career/evidence")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class CareerEvidenceController(JobTrackerContext db, UserManager<ApplicationUser> users) : ControllerBase
{
public sealed record EvidenceRequest(string? Category, string? Title, string? Statement, IReadOnlyList<string>? Tags, string? SourceType, string? SourceReference, bool? IsVerified);
public sealed record EvidenceDto(int Id, string Category, string Title, string Statement, IReadOnlyList<string> Tags, string SourceType, string? SourceReference, bool IsVerified, DateTimeOffset UpdatedAtUtc);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<EvidenceDto>>> List(CancellationToken ct)
{
var owner = await OwnerAsync();
if (owner is null) return Unauthorized();
var rows = await db.Set<CareerEvidence>().AsNoTracking().Where(x => x.OwnerUserId == owner).OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
return Ok(rows.Select(ToDto));
}
[HttpPost]
public async Task<ActionResult<EvidenceDto>> Create(EvidenceRequest request, CancellationToken ct)
{
var owner = await OwnerAsync();
if (owner is null) return Unauthorized();
var error = Validate(request);
if (error is not null) return BadRequest(error);
var row = new CareerEvidence { OwnerUserId = owner };
Apply(row, request);
db.Add(row);
await db.SaveChangesAsync(ct);
return Ok(ToDto(row));
}
[HttpPut("{id:int}")]
public async Task<ActionResult<EvidenceDto>> Update(int id, EvidenceRequest request, CancellationToken ct)
{
var owner = await OwnerAsync();
if (owner is null) return Unauthorized();
var row = await db.Set<CareerEvidence>().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == owner, ct);
if (row is null) return NotFound();
var error = Validate(request);
if (error is not null) return BadRequest(error);
Apply(row, request);
await db.SaveChangesAsync(ct);
return Ok(ToDto(row));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var owner = await OwnerAsync();
if (owner is null) return Unauthorized();
var row = await db.Set<CareerEvidence>().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == owner, ct);
if (row is null) return NotFound();
db.Remove(row);
await db.SaveChangesAsync(ct);
return NoContent();
}
private async Task<string?> OwnerAsync() => (await users.GetUserAsync(User))?.Id;
private static string? Validate(EvidenceRequest value) =>
string.IsNullOrWhiteSpace(value.Title) ? "Title is required." : string.IsNullOrWhiteSpace(value.Statement) ? "Evidence is required." : null;
private static void Apply(CareerEvidence row, EvidenceRequest value)
{
row.Category = Clean(value.Category, "achievement", 32);
row.Title = Clean(value.Title, string.Empty, 255);
row.Statement = value.Statement!.Trim();
row.TagsJson = JsonSerializer.Serialize((value.Tags ?? []).Select(x => x.Trim()).Where(x => x.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).Take(30));
row.SourceType = Clean(value.SourceType, "manual", 32);
row.SourceReference = string.IsNullOrWhiteSpace(value.SourceReference) ? null : value.SourceReference.Trim()[..Math.Min(value.SourceReference.Trim().Length, 500)];
row.IsVerified = value.IsVerified ?? true;
row.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
private static string Clean(string? value, string fallback, int max) { var text = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); return text[..Math.Min(text.Length, max)]; }
private static EvidenceDto ToDto(CareerEvidence row) => new(row.Id, row.Category, row.Title, row.Statement, JsonSerializer.Deserialize<List<string>>(row.TagsJson) ?? [], row.SourceType, row.SourceReference, row.IsVerified, row.UpdatedAtUtc);
}
+8
View File
@@ -51,6 +51,7 @@ namespace JobTrackerApi.Data
public DbSet<CareerProject> CareerProjects => Set<CareerProject>();
public DbSet<CareerCertification> CareerCertifications => Set<CareerCertification>();
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
public DbSet<CareerEvidence> CareerEvidence => Set<CareerEvidence>();
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
@@ -418,6 +419,13 @@ namespace JobTrackerApi.Data
ConfigureCareerChild<CareerProject>(modelBuilder);
ConfigureCareerChild<CareerCertification>(modelBuilder);
ConfigureCareerChild<CareerLanguage>(modelBuilder);
modelBuilder.Entity<CareerEvidence>().HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CareerEvidence>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<CareerEvidence>().Property(x => x.Category).HasMaxLength(32);
modelBuilder.Entity<CareerEvidence>().Property(x => x.Title).HasMaxLength(255);
modelBuilder.Entity<CareerEvidence>().Property(x => x.SourceType).HasMaxLength(32);
modelBuilder.Entity<CareerEvidence>().Property(x => x.SourceReference).HasMaxLength(500);
modelBuilder.Entity<CareerEvidence>().HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc });
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
modelBuilder.Entity<InterviewPrepNote>()
@@ -0,0 +1,35 @@
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
[DbContext(typeof(JobTrackerContext))]
[Migration("20260830136000_AdoptCareerEvidenceSchema")]
public sealed class AdoptCareerEvidenceSchema : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.Sql(
ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? MySqlDdl : SqliteDdl);
protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.Sql(
ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "DROP TABLE IF EXISTS `CareerEvidence`;" : "DROP TABLE IF EXISTS \"CareerEvidence\";");
private const string SqliteDdl = """
CREATE TABLE IF NOT EXISTS "CareerEvidence" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerEvidence" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "Category" TEXT NOT NULL, "Title" TEXT NOT NULL,
"Statement" TEXT NOT NULL, "TagsJson" TEXT NOT NULL, "SourceType" TEXT NOT NULL,
"SourceReference" TEXT NULL, "IsVerified" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL, "UpdatedAtUtc" TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS "IX_CareerEvidence_Owner_Updated" ON "CareerEvidence" ("OwnerUserId", "UpdatedAtUtc");
""";
private const string MySqlDdl = """
CREATE TABLE IF NOT EXISTS `CareerEvidence` (
`Id` int NOT NULL AUTO_INCREMENT, `OwnerUserId` varchar(255) NOT NULL,
`Category` varchar(32) NOT NULL, `Title` varchar(255) NOT NULL,
`Statement` longtext NOT NULL, `TagsJson` longtext NOT NULL,
`SourceType` varchar(32) NOT NULL, `SourceReference` varchar(500) NULL,
`IsVerified` tinyint(1) NOT NULL, `CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL, PRIMARY KEY (`Id`)) CHARACTER SET=utf8mb4;
CREATE INDEX IF NOT EXISTS `IX_CareerEvidence_Owner_Updated` ON `CareerEvidence` (`OwnerUserId`, `UpdatedAtUtc`);
""";
}
@@ -987,6 +987,24 @@ namespace JobTrackerApi.Migrations
b.ToTable("Companies");
});
modelBuilder.Entity("JobTrackerApi.Models.CareerEvidence", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("Category").IsRequired().HasMaxLength(32).HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc").HasColumnType("TEXT");
b.Property<bool>("IsVerified").HasColumnType("INTEGER");
b.Property<string>("OwnerUserId").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<string>("SourceReference").HasMaxLength(500).HasColumnType("TEXT");
b.Property<string>("SourceType").IsRequired().HasMaxLength(32).HasColumnType("TEXT");
b.Property<string>("Statement").IsRequired().HasColumnType("TEXT");
b.Property<string>("TagsJson").IsRequired().HasColumnType("TEXT");
b.Property<string>("Title").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAtUtc").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerUserId", "UpdatedAtUtc");
b.ToTable("CareerEvidence");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
+17
View File
@@ -0,0 +1,17 @@
namespace JobTrackerApi.Models;
// User-approved factual evidence. AI may recommend these records but never writes them.
public sealed class CareerEvidence
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public string Category { get; set; } = "achievement";
public string Title { get; set; } = string.Empty;
public string Statement { get; set; } = string.Empty;
public string TagsJson { get; set; } = "[]";
public string SourceType { get; set; } = "manual";
public string? SourceReference { get; set; }
public bool IsVerified { get; set; } = true;
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
@@ -138,6 +138,7 @@ public sealed class AccountDataExportService(
var careerProjects = await db.CareerProjects.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
var careerCertifications = await db.CareerCertifications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
var careerLanguages = await db.CareerLanguages.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
var careerEvidence = await db.CareerEvidence.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
await AddJsonAsync("data/career.json", new
{
Profile = careerProfile,
@@ -148,7 +149,8 @@ public sealed class AccountDataExportService(
Projects = careerProjects,
Certifications = careerCertifications,
Languages = careerLanguages,
}, (careerProfileId is null ? 0 : 1) + careerVersions.Count + careerExperiences.Count + careerEducation.Count + careerSkills.Count + careerProjects.Count + careerCertifications.Count + careerLanguages.Count);
Evidence = careerEvidence,
}, (careerProfileId is null ? 0 : 1) + careerVersions.Count + careerExperiences.Count + careerEducation.Count + careerSkills.Count + careerProjects.Count + careerCertifications.Count + careerLanguages.Count + careerEvidence.Count);
var variants = await db.CvVariants.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var variantVersions = await db.CvVariantVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
@@ -248,6 +248,7 @@ public sealed class AccountDeletionService(
deleted += await db.CareerProjects.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerCertifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerLanguages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerEvidence.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerProfileVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerProfiles.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CvExtractionRuns.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
@@ -1,6 +1,7 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace JobTrackerApi.Services;
@@ -176,6 +177,26 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
match.RelevantProjects.Select(p => p.Subtitle is null ? p.Title : $"{p.Title} — {p.Subtitle}").ToList()));
}
var evidenceTerms = match.MatchedSkills.Concat(analysis.Keywords)
.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var evidence = await _db.CareerEvidence.AsNoTracking()
.Where(x => x.OwnerUserId == ownerUserId && x.IsVerified)
.OrderByDescending(x => x.UpdatedAtUtc)
.ToListAsync(ct);
var relevantEvidence = evidence.Where(item =>
{
var searchable = $"{item.Title} {item.Statement} {string.Join(' ', EvidenceTags(item.TagsJson))}";
return evidenceTerms.Any(term => searchable.Contains(term, StringComparison.OrdinalIgnoreCase));
}).Take(6).Select(item => $"{item.Title} — {item.Statement}").ToList();
if (relevantEvidence.Count > 0)
{
suggestions.Add(new TailoringSuggestionDto(
"career-evidence",
"Verified evidence to reuse",
"Facts you previously approved. Adapt the wording, but keep the claim accurate.",
relevantEvidence));
}
if (analysis.Keywords.Count > 0)
{
suggestions.Add(new TailoringSuggestionDto(
@@ -297,4 +318,10 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
_db.JobApplications.AsNoTracking()
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
private static IReadOnlyList<string> EvidenceTags(string? json)
{
try { return JsonSerializer.Deserialize<List<string>>(json ?? "[]") ?? []; }
catch (JsonException) { return []; }
}
}
@@ -25,6 +25,7 @@ internal static class StartupSchemaOwnership
"Attachments",
"CareerCertifications",
"CareerEducations",
"CareerEvidence",
"CareerExperiences",
"CareerLanguages",
"CareerProfiles",