feat: improve admin observability and translation-first summaries

This commit is contained in:
cesnimda
2026-03-22 21:37:30 +01:00
parent 8014c1e890
commit 4c49ffb0d6
8 changed files with 585 additions and 261 deletions
@@ -15,6 +15,17 @@ public sealed class ProductionConfigTests
Assert.NotNull(method); Assert.NotNull(method);
} }
[Fact]
public void Summarizer_metrics_include_runtime_device_details()
{
var ctor = typeof(SummarizerMetrics).GetConstructors().Single();
var parameterNames = ctor.GetParameters().Select(x => x.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("device", parameterNames);
Assert.Contains("gpuAvailable", parameterNames);
Assert.Contains("gpuName", parameterNames);
}
[Fact] [Fact]
public void Profile_cv_controller_supports_pdf_and_docx_extensions() public void Profile_cv_controller_supports_pdf_and_docx_extensions()
{ {
@@ -1,4 +1,5 @@
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using JobTrackerApi.Data; using JobTrackerApi.Data;
using JobTrackerApi.Services; using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -29,6 +30,9 @@ public sealed class AdminSystemController : ControllerBase
public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount); public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount);
public sealed record EmailStatusDto(bool Enabled, string? Host, int Port, bool EnableSsl, string? From, string? FromName); public sealed record EmailStatusDto(bool Enabled, string? Host, int Port, bool EnableSsl, string? From, string? FromName);
public sealed record DatabaseStatusDto(string Provider, bool LooksConfigured, bool CanConnect, string? Target, bool UsesFileStorage, string? Warning);
public sealed record RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName);
public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured);
public sealed record SystemStatusDto( public sealed record SystemStatusDto(
string Environment, string Environment,
string ContentRoot, string ContentRoot,
@@ -37,6 +41,9 @@ public sealed class AdminSystemController : ControllerBase
string? BuildStamp, string? BuildStamp,
StorageStatusDto Storage, StorageStatusDto Storage,
EmailStatusDto Email, EmailStatusDto Email,
DatabaseStatusDto Database,
RuntimeStatusDto Runtime,
AuthStatusDto Auth,
SummarizerMetrics Summarizer SummarizerMetrics Summarizer
); );
@@ -65,6 +72,8 @@ public sealed class AdminSystemController : ControllerBase
[HttpGet] [HttpGet]
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken) public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
{ {
var provider = (_cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var usesFileStorage = provider is not "mysql" and not "mariadb";
var dbPath = _paths.GetDbPath(); var dbPath = _paths.GetDbPath();
var dbFile = new FileInfo(dbPath); var dbFile = new FileInfo(dbPath);
@@ -80,6 +89,53 @@ public sealed class AdminSystemController : ControllerBase
var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]); var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]);
var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]); var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]);
var connectionString = _cfg.GetConnectionString("JobTracker") ?? string.Empty;
var looksConfigured = !string.IsNullOrWhiteSpace(connectionString) || usesFileStorage;
string? dbTarget;
if (usesFileStorage)
{
dbTarget = dbPath;
}
else if (string.IsNullOrWhiteSpace(connectionString))
{
dbTarget = null;
}
else
{
dbTarget = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Trim())
.FirstOrDefault(part => part.StartsWith("server=", StringComparison.OrdinalIgnoreCase)
|| part.StartsWith("host=", StringComparison.OrdinalIgnoreCase)
|| part.StartsWith("database=", StringComparison.OrdinalIgnoreCase));
}
bool canConnect;
try
{
canConnect = await _db.Database.CanConnectAsync(cancellationToken);
}
catch
{
canConnect = false;
}
string? dbWarning = null;
if (!looksConfigured)
{
dbWarning = "Connection string is missing.";
}
else if (!canConnect)
{
dbWarning = "Database connection failed.";
}
else if (!usesFileStorage && jobs.Count == 0 && companies == 0)
{
dbWarning = "Connected, but no data is present yet. Check whether this is the intended production database.";
}
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
return Ok(new SystemStatusDto( return Ok(new SystemStatusDto(
Environment: _env.EnvironmentName, Environment: _env.EnvironmentName,
@@ -104,6 +160,26 @@ public sealed class AdminSystemController : ControllerBase
From: (_cfg["Email:From"] ?? string.Empty).Trim(), From: (_cfg["Email:From"] ?? string.Empty).Trim(),
FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim() FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim()
), ),
Database: new DatabaseStatusDto(
Provider: provider,
LooksConfigured: looksConfigured,
CanConnect: canConnect,
Target: dbTarget,
UsesFileStorage: usesFileStorage,
Warning: dbWarning
),
Runtime: new RuntimeStatusDto(
Framework: RuntimeInformation.FrameworkDescription,
OSDescription: RuntimeInformation.OSDescription,
ProcessArchitecture: RuntimeInformation.ProcessArchitecture.ToString(),
MachineName: Environment.MachineName
),
Auth: new AuthStatusDto(
Required: _cfg.GetValue("Auth:Require", false),
HasJwtKey: !string.IsNullOrWhiteSpace((_cfg["Auth:JwtKey"] ?? string.Empty).Trim()),
GoogleConfigured: !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()),
GmailConfigured: gmailConfigured
),
Summarizer: summarizer Summarizer: summarizer
)); ));
} }
@@ -71,6 +71,20 @@ namespace JobTrackerApi.Controllers
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
} }
private static string BuildSummarySource(JobApplication job)
{
// Prefer translated text for summaries and skill extraction so non-English
// postings become easier to understand while keeping the original text intact.
var parts = new[]
{
job.TranslatedDescription,
job.Description,
job.Notes
};
return string.Join("\n\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()));
}
private static string? NormalizeTags(string? raw) private static string? NormalizeTags(string? raw)
{ {
var normalized = SplitTags(raw) var normalized = SplitTags(raw)
@@ -363,8 +377,9 @@ namespace JobTrackerApi.Controllers
.MaxAsync(c => (DateTime?)c.Date, cancellationToken); .MaxAsync(c => (DateTime?)c.Date, cancellationToken);
var d = RulesEngine.Evaluate(settings, job, now, lm); var d = RulesEngine.Evaluate(settings, job, now, lm);
// Return persisted short summary and compute a fuller summary on demand for the details view. // Prefer translated content for the detailed summary so Norwegian postings
var full = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 250, 40); // surface readable English analysis while the original text remains available.
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
return Ok(new JobApplicationDto( return Ok(new JobApplicationDto(
Id: job.Id, Id: job.Id,
@@ -596,7 +611,7 @@ namespace JobTrackerApi.Controllers
// Generate and persist a short summary at creation time to avoid repeated model calls. // Generate and persist a short summary at creation time to avoid repeated model calls.
try try
{ {
var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 160, 60); var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
job.ShortSummary = shortSum; job.ShortSummary = shortSum;
} }
catch catch
@@ -752,11 +767,10 @@ namespace JobTrackerApi.Controllers
if (job is null) return NotFound(); if (job is null) return NotFound();
var sourceText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes } var sourceText = BuildSummarySource(job);
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(sourceText)) if (string.IsNullOrWhiteSpace(sourceText))
{ {
return BadRequest("This job does not have enough description or notes to generate a summary and skills."); return BadRequest("This job does not have enough translated text, description, or notes to generate a summary and skills.");
} }
var tags = SkillTagger.Detect(sourceText) var tags = SkillTagger.Detect(sourceText)
@@ -1710,7 +1724,7 @@ Candidate master CV:
)) ))
.ToList(); .ToList();
return Ok(new DuplicateCheckResult(matches.Count > 0, matches)); return Ok(new DuplicateCheckResult(matches.Any(), matches));
} }
[HttpGet("{id:int}/followup-draft")] [HttpGet("{id:int}/followup-draft")]
+19 -10
View File
@@ -37,13 +37,22 @@ builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
var cfg = sp.GetRequiredService<IConfiguration>(); var cfg = sp.GetRequiredService<IConfiguration>();
var paths = sp.GetRequiredService<AppPaths>(); var paths = sp.GetRequiredService<AppPaths>();
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker"); var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs)) if (string.IsNullOrWhiteSpace(cs))
{ {
cs = $"Data Source={paths.GetDbPath()}"; cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
} }
if (provider is "mysql" or "mariadb")
{
options.UseMySql(cs, ServerVersion.AutoDetect(cs));
}
else
{
options.UseSqlite(cs); options.UseSqlite(cs);
}
// We create Identity tables on startup in environments where `dotnet ef` isn't available. // We create Identity tables on startup in environments where `dotnet ef` isn't available.
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it. // That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
@@ -299,7 +308,11 @@ using (var scope = app.Services.CreateScope())
var paths = scope.ServiceProvider.GetRequiredService<AppPaths>(); var paths = scope.ServiceProvider.GetRequiredService<AppPaths>();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>(); var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var useSqliteBootstrap = provider is not "mysql" and not "mariadb";
if (useSqliteBootstrap)
{
// Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied). // Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied).
// If the schema already contains the columns added by migration 20260310195000, record that migration // If the schema already contains the columns added by migration 20260310195000, record that migration
// so EF doesn't try to apply it again and fail on duplicate columns. // so EF doesn't try to apply it again and fail on duplicate columns.
@@ -561,6 +574,7 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
// Ensure data folder exists before creating/opening SQLite files. // Ensure data folder exists before creating/opening SQLite files.
Directory.CreateDirectory(paths.DataRoot); Directory.CreateDirectory(paths.DataRoot);
}
db.Database.Migrate(); db.Database.Migrate();
@@ -602,16 +616,11 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult(); var admin = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
if (admin is not null) if (admin is not null)
{ {
using var cmd = conn.CreateCommand(); db.Database.ExecuteSqlRaw(
cmd.CommandText = """ "UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;" +
UPDATE Companies SET OwnerUserId=$uid WHERE OwnerUserId IS NULL; "UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;",
UPDATE JobApplications SET OwnerUserId=$uid WHERE OwnerUserId IS NULL; admin.Id
"""; );
var p = cmd.CreateParameter();
p.ParameterName = "$uid";
p.Value = admin.Id;
cmd.Parameters.Add(p);
cmd.ExecuteNonQuery();
} }
} }
} }
@@ -16,6 +16,9 @@ namespace JobTrackerApi.Services
public sealed record SummarizerMetrics( public sealed record SummarizerMetrics(
bool Healthy, bool Healthy,
string? Model, string? Model,
string? Device,
bool? GpuAvailable,
string? GpuName,
double? HealthLatencyMs, double? HealthLatencyMs,
double? ProbeLatencyMs, double? ProbeLatencyMs,
DateTimeOffset? LastProbeAt, DateTimeOffset? LastProbeAt,
@@ -216,6 +219,9 @@ namespace JobTrackerApi.Services
{ {
var client = _httpFactory.CreateClient("summarizer"); var client = _httpFactory.CreateClient("summarizer");
string? model = null; string? model = null;
string? device = null;
bool? gpuAvailable = null;
string? gpuName = null;
double? healthLatencyMs = null; double? healthLatencyMs = null;
var healthy = false; var healthy = false;
string? healthError = null; string? healthError = null;
@@ -236,6 +242,21 @@ namespace JobTrackerApi.Services
{ {
model = modelEl.GetString(); model = modelEl.GetString();
} }
if (doc.RootElement.TryGetProperty("device", out var deviceEl))
{
device = deviceEl.GetString();
}
if (doc.RootElement.TryGetProperty("gpu_available", out var gpuAvailableEl) && gpuAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
gpuAvailable = gpuAvailableEl.GetBoolean();
}
if (doc.RootElement.TryGetProperty("gpu_name", out var gpuNameEl))
{
gpuName = gpuNameEl.GetString();
}
} }
else else
{ {
@@ -283,6 +304,9 @@ namespace JobTrackerApi.Services
return new SummarizerMetrics( return new SummarizerMetrics(
Healthy: healthy, Healthy: healthy,
Model: model, Model: model,
Device: device,
GpuAvailable: gpuAvailable,
GpuName: gpuName,
HealthLatencyMs: healthLatencyMs, HealthLatencyMs: healthLatencyMs,
ProbeLatencyMs: probeLatencyMs, ProbeLatencyMs: probeLatencyMs,
LastProbeAt: lastProbeAt, LastProbeAt: lastProbeAt,
@@ -155,7 +155,10 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application"; const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application";
const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || ""; const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || "";
const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet."; const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet.";
const rawDescriptionText = job?.translatedDescription || job?.description || ""; const translatedDescriptionText = job?.translatedDescription?.trim() || "";
const originalDescriptionText = job?.description?.trim() || "";
const showTranslatedText = translatedDescriptionText.length > 0;
const showOriginalText = originalDescriptionText.length > 0;
const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]); const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]);
return ( return (
@@ -219,7 +222,18 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
</Box> </Box>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography> <Typography sx={{ whiteSpace: "pre-wrap" }}>{summaryFirstText}</Typography>
</Box> </Box>
{rawDescriptionText ? <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Original role text</Typography><Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{rawDescriptionText}</Typography></Box> : null} {showTranslatedText ? (
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Translated role text</Typography>
<Typography sx={{ whiteSpace: "pre-wrap" }}>{translatedDescriptionText}</Typography>
</Box>
) : null}
{showOriginalText ? (
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="overline">Original role text</Typography>
<Typography sx={{ whiteSpace: "pre-wrap", color: "text.secondary" }}>{originalDescriptionText}</Typography>
</Box>
) : null}
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Notes</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box> <Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">Notes</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{job?.notes ?? ""}</Typography></Box>
</Box> </Box>
)} )}
+168 -66
View File
@@ -1,12 +1,23 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Alert, Box, Button, Paper, Typography } from "@mui/material"; import {
Alert,
Box,
Button,
Chip,
Paper,
Stack,
Typography,
} from "@mui/material";
import { api } from "../api"; import { api } from "../api";
type SummarizerMetrics = { type SummarizerMetrics = {
healthy: boolean; healthy: boolean;
model?: string | null; model?: string | null;
device?: string | null;
gpuAvailable?: boolean;
gpuName?: string | null;
healthLatencyMs?: number | null; healthLatencyMs?: number | null;
probeLatencyMs?: number | null; probeLatencyMs?: number | null;
lastProbeAt?: string | null; lastProbeAt?: string | null;
@@ -18,6 +29,8 @@ type SummarizerMetrics = {
cacheMisses: number; cacheMisses: number;
failures: number; failures: number;
averageLatencyMs?: number | null; averageLatencyMs?: number | null;
lastSuccessAt?: string | null;
lastFailureAt?: string | null;
lastError?: string | null; lastError?: string | null;
}; };
@@ -44,6 +57,26 @@ type SystemStatus = {
from?: string | null; from?: string | null;
fromName?: string | null; fromName?: string | null;
}; };
database: {
provider: string;
looksConfigured: boolean;
canConnect: boolean;
target?: string | null;
usesFileStorage: boolean;
warning?: string | null;
};
runtime: {
framework: string;
osDescription: string;
processArchitecture: string;
machineName?: string | null;
};
auth: {
required: boolean;
hasJwtKey: boolean;
googleConfigured: boolean;
gmailConfigured: boolean;
};
summarizer: SummarizerMetrics; summarizer: SummarizerMetrics;
}; };
@@ -58,6 +91,25 @@ function displayMetadata(value?: string | null) {
return value && value.trim().length > 0 ? value : "-"; return value && value.trim().length > 0 ? value : "-";
} }
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "-";
}
function SummaryCard({ title, value, subtitle, tone = "default" }: { title: string; value: string; subtitle?: string; tone?: "default" | "success" | "warning" | "error" }) {
const color = tone === "success" ? "success.main" : tone === "warning" ? "warning.main" : tone === "error" ? "error.main" : "text.primary";
return (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{title}</Typography>
<Typography variant="h5" sx={{ fontWeight: 950, color }}>{value}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{subtitle || "-"}</Typography>
</Paper>
);
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return <Typography variant="body2"><strong>{label}:</strong> {value}</Typography>;
}
export default function AdminSystemPage() { export default function AdminSystemPage() {
const [status, setStatus] = useState<SystemStatus | null>(null); const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -82,12 +134,26 @@ export default function AdminSystemPage() {
void load(); void load();
}, []); }, []);
const dbTone = useMemo(() => {
if (!status) return "default" as const;
if (!status.database.looksConfigured || !status.database.canConnect) return "error" as const;
if (status.database.warning) return "warning" as const;
return "success" as const;
}, [status]);
const summarizerTone = useMemo(() => {
if (!status) return "default" as const;
if (!status.summarizer.healthy) return "error" as const;
if (status.summarizer.probeFailures > 0 || status.summarizer.failures > 0) return "warning" as const;
return "success" as const;
}, [status]);
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 950 }}>System status</Typography> <Typography variant="h5" sx={{ fontWeight: 950 }}>System status</Typography>
<Typography sx={{ color: "text.secondary" }}>Quick operational view of storage, email, and summarizer health.</Typography> <Typography sx={{ color: "text.secondary" }}>Production diagnostics for runtime, database, auth, email, and summarizer health.</Typography>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button <Button
@@ -115,85 +181,121 @@ export default function AdminSystemPage() {
</Box> </Box>
{error ? <Alert severity="error">{error}</Alert> : null} {error ? <Alert severity="error">{error}</Alert> : null}
{status?.database.warning ? <Alert severity={status.database.canConnect ? "warning" : "error"}>{status.database.warning}</Alert> : null}
{status?.summarizer.lastError ? <Alert severity={status.summarizer.healthy ? "warning" : "error"}>{status.summarizer.lastError}</Alert> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, 1fr)" }, gap: 2 }}>
<Paper sx={{ p: 2 }}> <SummaryCard
<Typography variant="overline" sx={{ color: "text.secondary" }}>Environment</Typography> title="Environment"
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.environment ?? "-"}</Typography> value={status?.environment ?? "-"}
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>Version {displayMetadata(status?.version)}</Typography> subtitle={`Version ${displayMetadata(status?.version)} · Commit ${displayMetadata(status?.commitSha)}`}
<Typography variant="body2" sx={{ color: "text.secondary" }}>Commit {displayMetadata(status?.commitSha)}</Typography> />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{displayMetadata(status?.buildStamp)}</Typography> <SummaryCard
</Paper> title="Database"
<Paper sx={{ p: 2 }}> value={status ? (status.database.canConnect ? "Connected" : "Offline") : "-"}
<Typography variant="overline" sx={{ color: "text.secondary" }}>Database</Typography> subtitle={status ? `${status.database.provider} · ${status.database.target || "No target"}` : "-"}
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.storage.dbExists ? "Ready" : "Missing"}</Typography> tone={dbTone}
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{formatBytes(status?.storage.dbSizeBytes)}</Typography> />
</Paper> <SummaryCard
<Paper sx={{ p: 2 }}> title="SMTP"
<Typography variant="overline" sx={{ color: "text.secondary" }}>SMTP</Typography> value={status?.email.enabled ? "Enabled" : "Disabled"}
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.email.enabled ? "Enabled" : "Disabled"}</Typography> subtitle={status?.email.host || "No SMTP host configured"}
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{status?.email.host || "No SMTP host configured"}</Typography> tone={status?.email.enabled ? "success" : "default"}
</Paper> />
<Paper sx={{ p: 2 }}> <SummaryCard
<Typography variant="overline" sx={{ color: "text.secondary" }}>Summarizer</Typography> title="Summarizer"
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.summarizer.healthy ? "Healthy" : "Offline"}</Typography> value={status?.summarizer.healthy ? "Healthy" : "Offline"}
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}> subtitle={status?.summarizer.probeLatencyMs != null
{status?.summarizer.probeLatencyMs != null ? `${status.summarizer.probeLatencyMs} ms probe · ${status.summarizer.device || "unknown device"}`
? `${status.summarizer.probeLatencyMs} ms probe`
: status?.summarizer.healthLatencyMs != null : status?.summarizer.healthLatencyMs != null
? `${status.summarizer.healthLatencyMs} ms health` ? `${status.summarizer.healthLatencyMs} ms health · ${status.summarizer.device || "unknown device"}`
: "No latency data"} : "No latency data"}
</Typography> tone={summarizerTone}
/>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Database and storage</Typography>
<Stack spacing={0.75}>
<DetailRow label="Provider" value={status?.database.provider || "-"} />
<DetailRow label="Target" value={status?.database.target || "-"} />
<DetailRow label="Configured" value={status?.database.looksConfigured ? "Yes" : "No"} />
<DetailRow label="Can connect" value={status?.database.canConnect ? "Yes" : "No"} />
<DetailRow label="Uses file storage" value={status?.database.usesFileStorage ? "Yes" : "No"} />
<DetailRow label="Data root" value={status?.storage.dataRoot || "-"} />
<DetailRow label="DB path" value={status?.storage.dbPath || "-"} />
<DetailRow label="DB file exists" value={status?.storage.dbExists ? "Yes" : "No"} />
<DetailRow label="DB size" value={formatBytes(status?.storage.dbSizeBytes)} />
<DetailRow label="Companies" value={status?.storage.companyCount ?? 0} />
<DetailRow label="Jobs" value={status?.storage.jobCount ?? 0} />
<DetailRow label="Deleted jobs" value={status?.storage.deletedCount ?? 0} />
</Stack>
</Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Runtime and auth</Typography>
<Stack spacing={0.75}>
<DetailRow label="Framework" value={status?.runtime.framework || "-"} />
<DetailRow label="OS" value={status?.runtime.osDescription || "-"} />
<DetailRow label="Architecture" value={status?.runtime.processArchitecture || "-"} />
<DetailRow label="Machine" value={status?.runtime.machineName || "-"} />
<DetailRow label="Content root" value={status?.contentRoot || "-"} />
<DetailRow label="Build stamp" value={displayMetadata(status?.buildStamp)} />
<DetailRow label="Auth required" value={status?.auth.required ? "Yes" : "No"} />
<DetailRow label="JWT key configured" value={status?.auth.hasJwtKey ? "Yes" : "No"} />
<DetailRow label="Google login configured" value={status?.auth.googleConfigured ? "Yes" : "No"} />
<DetailRow label="Gmail integration configured" value={status?.auth.gmailConfigured ? "Yes" : "No"} />
</Stack>
</Paper> </Paper>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Storage</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Email configuration</Typography>
<Typography variant="body2"><strong>Data root:</strong> {status?.storage.dataRoot || "-"}</Typography> <Stack spacing={0.75}>
<Typography variant="body2"><strong>DB path:</strong> {status?.storage.dbPath || "-"}</Typography> <DetailRow label="Enabled" value={status?.email.enabled ? "Yes" : "No"} />
<Typography variant="body2"><strong>Companies:</strong> {status?.storage.companyCount ?? 0}</Typography> <DetailRow label="From" value={status?.email.from || "-"} />
<Typography variant="body2"><strong>Jobs:</strong> {status?.storage.jobCount ?? 0}</Typography> <DetailRow label="From name" value={status?.email.fromName || "-"} />
<Typography variant="body2"><strong>Deleted jobs:</strong> {status?.storage.deletedCount ?? 0}</Typography> <DetailRow label="Host" value={status?.email.host || "-"} />
<Typography variant="body2"><strong>Content root:</strong> {status?.contentRoot || "-"}</Typography> <DetailRow label="Port" value={status?.email.port ?? "-"} />
<DetailRow label="SSL" value={status?.email.enableSsl ? "Yes" : "No"} />
</Stack>
</Paper> </Paper>
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Email</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer runtime</Typography>
<Typography variant="body2"><strong>From:</strong> {status?.email.from || "-"}</Typography> <Stack spacing={0.75}>
<Typography variant="body2"><strong>From name:</strong> {status?.email.fromName || "-"}</Typography> <DetailRow label="Model" value={status?.summarizer.model || "-"} />
<Typography variant="body2"><strong>Host:</strong> {status?.email.host || "-"}</Typography> <DetailRow label="Device" value={status?.summarizer.device || "-"} />
<Typography variant="body2"><strong>Port:</strong> {status?.email.port ?? "-"}</Typography> <DetailRow label="GPU available" value={status?.summarizer.gpuAvailable ? "Yes" : "No"} />
<Typography variant="body2"><strong>SSL:</strong> {status?.email.enableSsl ? "Yes" : "No"}</Typography> <DetailRow label="GPU name" value={status?.summarizer.gpuName || "-"} />
<DetailRow label="Health latency" value={status?.summarizer.healthLatencyMs != null ? `${status.summarizer.healthLatencyMs} ms` : "-"} />
<DetailRow label="Probe latency" value={status?.summarizer.probeLatencyMs != null ? `${status.summarizer.probeLatencyMs} ms` : "-"} />
<DetailRow label="Last probe" value={formatDate(status?.summarizer.lastProbeAt)} />
<DetailRow label="Last successful probe" value={formatDate(status?.summarizer.lastProbeSuccessAt)} />
<DetailRow label="Last summarization success" value={formatDate(status?.summarizer.lastSuccessAt)} />
</Stack>
</Paper> </Paper>
</Box> </Box>
<Paper sx={{ p: 2 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>Summarizer telemetry</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(5, 1fr)" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(6, 1fr)" }, gap: 2 }}>
<Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography></Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheHits ?? 0}</Typography></Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.requests ?? 0}</Typography> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache misses</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheMisses ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.failures ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Probe failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.probeFailures ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography></Box>
</Box> </Box>
<Box> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography> <Chip label={status?.database.canConnect ? "Database connected" : "Database issue"} color={status?.database.canConnect ? "success" : "error"} size="small" />
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.cacheHits ?? 0}</Typography> <Chip label={status?.auth.required ? "Auth enforced" : "Auth optional"} color={status?.auth.required ? "success" : "warning"} size="small" />
<Chip label={status?.auth.googleConfigured ? "Google sign-in ready" : "Google sign-in off"} variant="outlined" size="small" />
<Chip label={status?.auth.gmailConfigured ? "Gmail ready" : "Gmail incomplete"} variant="outlined" size="small" />
<Chip label={status?.summarizer.gpuAvailable ? "GPU visible" : "CPU mode"} color={status?.summarizer.gpuAvailable ? "success" : "default"} size="small" />
</Box> </Box>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Failures</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.failures ?? 0}</Typography>
</Box>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.averageLatencyMs != null ? `${status.summarizer.averageLatencyMs} ms` : "-"}</Typography>
</Box>
<Box>
<Typography variant="overline" sx={{ color: "text.secondary" }}>Probe latency</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.summarizer.probeLatencyMs != null ? `${status.summarizer.probeLatencyMs} ms` : "-"}</Typography>
</Box>
</Box>
<Typography variant="body2" sx={{ mt: 1 }}><strong>Probe failures:</strong> {status?.summarizer.probeFailures ?? 0}</Typography>
{status?.summarizer.lastError ? <Alert severity="warning" sx={{ mt: 2 }}>{status.summarizer.lastError}</Alert> : null}
</Paper> </Paper>
</Box> </Box>
); );
+111 -37
View File
@@ -10,12 +10,21 @@ app = FastAPI(title="Local Summarizer")
MODEL_NAME = "sshleifer/distilbart-cnn-12-6" MODEL_NAME = "sshleifer/distilbart-cnn-12-6"
MAX_INPUT_CHARS = 20000 MAX_INPUT_CHARS = 20000
MAX_CONTEXT_CHARS = 2200
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME) def _load_runtime():
model.eval() tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME)
model.to(device) model.eval()
has_cuda = torch.cuda.is_available()
device = torch.device("cuda" if has_cuda else "cpu")
model.to(device)
gpu_name = torch.cuda.get_device_name(0) if has_cuda else None
return tokenizer, model, device, has_cuda, gpu_name
tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime()
cache = TTLCache(maxsize=1024, ttl=60 * 60) cache = TTLCache(maxsize=1024, ttl=60 * 60)
@@ -33,7 +42,13 @@ def _key(text: str, max_length: int, min_length: int, top_skills: int) -> str:
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"ok": True, "model": MODEL_NAME} return {
"ok": True,
"model": MODEL_NAME,
"device": str(device),
"gpu_available": GPU_AVAILABLE,
"gpu_name": GPU_NAME,
}
_TECH = [ _TECH = [
@@ -54,6 +69,17 @@ _TECH_PRIORITY = [
] ]
_MUST_HAVE_HINTS = [
"must have", "required", "requirements", "you have", "you bring", "essential", "we are looking for",
]
_NICE_TO_HAVE_HINTS = [
"nice to have", "bonus", "preferred", "advantageous", "extra plus",
]
_SCREENING_HINTS = [
"experience with", "hands-on", "demonstrated", "proven", "track record", "delivered",
]
def _rank_tech_skills(skills): def _rank_tech_skills(skills):
ordered = [] ordered = []
seen = set() seen = set()
@@ -84,7 +110,7 @@ def _extract_bullets(lines, max_items=8):
continue continue
if re.match(r"^([-*]|\u2022)\s+", s): if re.match(r"^([-*]|\u2022)\s+", s):
s = re.sub(r"^([-*]|\u2022)\s+", "", s).strip() s = re.sub(r"^([-*]|\u2022)\s+", "", s).strip()
if 3 <= len(s) <= 200: if 3 <= len(s) <= 220:
out.append(s) out.append(s)
if len(out) >= max_items: if len(out) >= max_items:
break break
@@ -96,6 +122,7 @@ def _top_keywords(text: str, limit=6):
stop = { stop = {
"with", "from", "that", "this", "will", "have", "your", "their", "about", "role", "team", "work", "with", "from", "that", "this", "will", "have", "your", "their", "about", "role", "team", "work",
"experience", "skills", "requirements", "responsibilities", "company", "using", "ability", "years", "experience", "skills", "requirements", "responsibilities", "company", "using", "ability", "years",
"looking", "candidate", "position", "working", "across", "strong", "building", "support",
} }
counts = {} counts = {}
for word in words: for word in words:
@@ -106,6 +133,27 @@ def _top_keywords(text: str, limit=6):
return [word for word, _ in ordered[:limit]] return [word for word, _ in ordered[:limit]]
def _first_matching_sentences(text: str, hints, limit=3):
sentences = re.split(r"(?<=[.!?])\s+", text)
found = []
for sentence in sentences:
low = sentence.lower()
if any(hint in low for hint in hints):
cleaned = sentence.strip()
if 20 <= len(cleaned) <= 220:
found.append(cleaned)
if len(found) >= limit:
break
return found
def _trim_line(text: str, max_len: int = 140) -> str:
text = re.sub(r"\s+", " ", text).strip(" -•\t")
if len(text) <= max_len:
return text
return text[: max_len - 1].rstrip() + ""
def _role_focused_excerpt(text: str) -> dict: def _role_focused_excerpt(text: str) -> dict:
cleaned = _strip_html(text) cleaned = _strip_html(text)
lines = [ln.strip() for ln in cleaned.splitlines()] lines = [ln.strip() for ln in cleaned.splitlines()]
@@ -162,6 +210,11 @@ def _role_focused_excerpt(text: str) -> dict:
responsibilities = any_bullets[:6] responsibilities = any_bullets[:6]
requirements = any_bullets[6:10] requirements = any_bullets[6:10]
if not requirements:
requirements = [_trim_line(x) for x in _first_matching_sentences(cleaned, _MUST_HAVE_HINTS, limit=4)]
if not nice:
nice = [_trim_line(x) for x in _first_matching_sentences(cleaned, _NICE_TO_HAVE_HINTS, limit=3)]
focused_parts = [] focused_parts = []
if responsibilities: if responsibilities:
focused_parts.append("Responsibilities:\n- " + "\n- ".join(responsibilities)) focused_parts.append("Responsibilities:\n- " + "\n- ".join(responsibilities))
@@ -169,7 +222,14 @@ def _role_focused_excerpt(text: str) -> dict:
focused_parts.append("Requirements:\n- " + "\n- ".join(requirements)) focused_parts.append("Requirements:\n- " + "\n- ".join(requirements))
if nice: if nice:
focused_parts.append("Nice to have:\n- " + "\n- ".join(nice)) focused_parts.append("Nice to have:\n- " + "\n- ".join(nice))
focused_parts.append("Context:\n" + cleaned[:1500]) focused_parts.append("Context:\n" + cleaned[:MAX_CONTEXT_CHARS])
screen_focus = []
for item in requirements[:4]:
if any(hint in item.lower() for hint in _SCREENING_HINTS) or len(screen_focus) < 2:
screen_focus.append(_trim_line(item))
if not screen_focus:
screen_focus = [_trim_line(x) for x in _first_matching_sentences(cleaned, _SCREENING_HINTS, limit=3)]
return { return {
"cleaned": cleaned, "cleaned": cleaned,
@@ -180,6 +240,7 @@ def _role_focused_excerpt(text: str) -> dict:
"tech": tech_found, "tech": tech_found,
"soft": soft_found, "soft": soft_found,
"keywords": _top_keywords(cleaned), "keywords": _top_keywords(cleaned),
"screen_focus": screen_focus[:3],
} }
@@ -193,7 +254,9 @@ def _model_summarize(text: str, max_length: int, min_length: int) -> str:
attention_mask=attention_mask, attention_mask=attention_mask,
max_length=max_length, max_length=max_length,
min_length=min_length, min_length=min_length,
num_beams=4, num_beams=3,
length_penalty=1.0,
no_repeat_ngram_size=3,
early_stopping=True, early_stopping=True,
) )
return tokenizer.decode(outputs[0], skip_special_tokens=True).strip() return tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
@@ -211,53 +274,64 @@ async def summarize(req: SummarizeRequest):
info = _role_focused_excerpt(req.text) info = _role_focused_excerpt(req.text)
summary = _model_summarize(info["focused_input"], req.max_length, req.min_length) summary = _model_summarize(info["focused_input"], req.max_length, req.min_length)
ranked_tech = []
for t in _rank_tech_skills(info["tech"]):
if t not in ranked_tech:
ranked_tech.append(t)
uniq_soft = []
for s in info["soft"]:
if s not in uniq_soft:
uniq_soft.append(s)
lines = ["Role summary:", summary] lines = ["Role summary:", summary]
if info["requirements"]: if info["requirements"]:
lines.append("") lines.append("")
lines.append("What the company wants most:") lines.append("What the company wants most:")
for x in info["requirements"][:7]: for x in info["requirements"][:5]:
lines.append(f"- {x}") lines.append(f"- {_trim_line(x)}")
if ranked_tech:
lines.append("")
lines.append("Top hard skills:")
for skill in ranked_tech[: req.top_skills]:
lines.append(f"- {skill}")
if info["keywords"]:
lines.append("")
lines.append("Keywords to mirror:")
for keyword in info["keywords"][:5]:
lines.append(f"- {keyword}")
if info["responsibilities"]: if info["responsibilities"]:
lines.append("") lines.append("")
lines.append("What you would be doing:") lines.append("What you would be doing:")
for x in info["responsibilities"][:6]: for x in info["responsibilities"][:4]:
lines.append(f"- {x}") lines.append(f"- {_trim_line(x)}")
if info["nice"]: if info["nice"]:
lines.append("") lines.append("")
lines.append("Nice to have:") lines.append("Nice to have:")
for x in info["nice"][:5]: for x in info["nice"][:3]:
lines.append(f"- {x}") lines.append(f"- {_trim_line(x)}")
if info["tech"]: if uniq_soft:
uniq = []
for t in _rank_tech_skills(info["tech"]):
if t not in uniq:
uniq.append(t)
lines.append("") lines.append("")
lines.append("Top hard skills: " + ", ".join(uniq[: req.top_skills])) lines.append("Relevant soft skills:")
for soft in uniq_soft[:5]:
if info["soft"]: lines.append(f"- {soft}")
uniq_soft = []
for s in info["soft"]:
if s not in uniq_soft:
uniq_soft.append(s)
lines.append("")
lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8]))
if info["keywords"]:
lines.append("")
lines.append("Key themes: " + ", ".join(info["keywords"][:6]))
lines.append("") lines.append("")
lines.append("Interview focus:") lines.append("Interview focus:")
if info["requirements"]: if info["screen_focus"]:
for x in info["screen_focus"]:
lines.append(f"- Be ready to prove: {_trim_line(x)}")
elif info["requirements"]:
for x in info["requirements"][:3]: for x in info["requirements"][:3]:
lines.append(f"- Prepare examples that demonstrate: {x}") lines.append(f"- Prepare examples that demonstrate: {_trim_line(x)}")
elif info["tech"]: elif ranked_tech:
for x in _rank_tech_skills(info["tech"])[:3]: for x in ranked_tech[:3]:
lines.append(f"- Be ready to explain your hands-on experience with {x}") lines.append(f"- Be ready to explain your hands-on experience with {x}")
else: else:
lines.append("- Prepare examples showing relevant impact, collaboration, and delivery.") lines.append("- Prepare examples showing relevant impact, collaboration, and delivery.")