feat(career): add factual evidence bank
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user