feat: improve reminders summarizer output and system metadata handling
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class GmailControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Import_thread_rejects_missing_message_ids()
|
||||
{
|
||||
var controller = new GmailController(Mock.Of<IGmailOAuthService>(), null!, BuildConfig())
|
||||
{
|
||||
ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext
|
||||
{
|
||||
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||
{
|
||||
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[]
|
||||
{
|
||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "user-1")
|
||||
}, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
|
||||
Assert.Equal("At least one messageId is required.", badRequest.Value);
|
||||
}
|
||||
|
||||
private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig()
|
||||
{
|
||||
return new Microsoft.Extensions.Configuration.ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,21 @@ public sealed class AdminSystemController : ControllerBase
|
||||
SummarizerMetrics Summarizer
|
||||
);
|
||||
|
||||
private static string? NormalizeBuildMetadata(string? value)
|
||||
{
|
||||
var trimmed = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) return null;
|
||||
|
||||
// Ignore unresolved shell/compose placeholders that would otherwise leak
|
||||
// directly into the admin UI, e.g. $(git rev-parse --short HEAD) or ${APP_COMMIT_SHA}.
|
||||
if ((trimmed.StartsWith("$(") && trimmed.EndsWith(")")) || (trimmed.StartsWith("${") && trimmed.EndsWith("}")))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
[HttpPost("summarizer/probe")]
|
||||
public async Task<IActionResult> RunSummarizerProbe(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -57,13 +72,14 @@ public sealed class AdminSystemController : ControllerBase
|
||||
var companies = await _db.Companies.AsNoTracking().CountAsync(cancellationToken);
|
||||
var summarizer = await _summarizer.GetMetricsAsync(cancellationToken);
|
||||
|
||||
var version = (_cfg["App:Version"] ?? "").Trim();
|
||||
var version = NormalizeBuildMetadata(_cfg["App:Version"]);
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
}
|
||||
var commitSha = (_cfg["App:CommitSha"] ?? "").Trim();
|
||||
var buildStamp = (_cfg["App:BuildStamp"] ?? "").Trim();
|
||||
|
||||
var commitSha = NormalizeBuildMetadata(_cfg["App:CommitSha"]);
|
||||
var buildStamp = NormalizeBuildMetadata(_cfg["App:BuildStamp"]);
|
||||
|
||||
return Ok(new SystemStatusDto(
|
||||
Environment: _env.EnvironmentName,
|
||||
@@ -82,11 +98,11 @@ public sealed class AdminSystemController : ControllerBase
|
||||
),
|
||||
Email: new EmailStatusDto(
|
||||
Enabled: _cfg.GetValue("Email:Enabled", false),
|
||||
Host: (_cfg["Email:SmtpHost"] ?? "").Trim(),
|
||||
Host: (_cfg["Email:SmtpHost"] ?? string.Empty).Trim(),
|
||||
Port: _cfg.GetValue("Email:SmtpPort", 587),
|
||||
EnableSsl: _cfg.GetValue("Email:SmtpEnableSsl", true),
|
||||
From: (_cfg["Email:From"] ?? "").Trim(),
|
||||
FromName: (_cfg["Email:FromName"] ?? "").Trim()
|
||||
From: (_cfg["Email:From"] ?? string.Empty).Trim(),
|
||||
FromName: (_cfg["Email:FromName"] ?? string.Empty).Trim()
|
||||
),
|
||||
Summarizer: summarizer
|
||||
));
|
||||
|
||||
@@ -23,6 +23,11 @@ services:
|
||||
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
|
||||
- Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://summarizer:8001}
|
||||
# Email (SMTP)
|
||||
# Build metadata should be resolved before deployment. Examples:
|
||||
# APP_VERSION=1.0.0
|
||||
# APP_COMMIT_SHA=abc1234
|
||||
# APP_BUILD_STAMP=2026-03-22 14:00 UTC
|
||||
# Do not set literal placeholders like $(git rev-parse --short HEAD) in .env.
|
||||
- App__PublicBaseUrl=${APP_PUBLIC_BASE_URL}
|
||||
- App__Version=${APP_VERSION}
|
||||
- App__CommitSha=${APP_COMMIT_SHA}
|
||||
|
||||
@@ -130,7 +130,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const nav: NavItem[] = [
|
||||
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: "Manage" },
|
||||
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: "Manage" },
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Paper,
|
||||
TextField,
|
||||
Typography,
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
@@ -83,9 +84,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
setLoading(true);
|
||||
api
|
||||
.get<JobApplication>(`/jobapplications/${jobId}`)
|
||||
.then((r) => {
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
const j = r.data;
|
||||
setCompany(j.company ?? null);
|
||||
setJobTitle(j.jobTitle ?? "");
|
||||
@@ -111,13 +110,10 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
setHasCoverLetter(Boolean((j as any).hasCoverLetter));
|
||||
setHasPortfolio(Boolean((j as any).hasPortfolio));
|
||||
setHasOtherAttachment(Boolean((j as any).hasOtherAttachment));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}).finally(() => setLoading(false));
|
||||
}, [open, jobId]);
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
return !!company?.id && jobTitle.trim().length > 0 && !loading;
|
||||
}, [company, jobTitle, loading]);
|
||||
const canSave = useMemo(() => !!company?.id && jobTitle.trim().length > 0 && !loading, [company, jobTitle, loading]);
|
||||
|
||||
const save = async () => {
|
||||
if (!jobId || !company?.id) return;
|
||||
@@ -146,6 +142,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
deadline: deadline || null,
|
||||
coverLetterText: coverLetterText || null,
|
||||
dateApplied: dateApplied || null,
|
||||
jobUrl: jobUrl.trim() || null,
|
||||
});
|
||||
toast("Saved.", "success");
|
||||
onSaved();
|
||||
@@ -161,95 +158,59 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>Edit job</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1, mb: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Update job details, timeline status, documents, and notes from one editing workspace.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Application details</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
|
||||
<Autocomplete
|
||||
options={companies}
|
||||
getOptionLabel={(c) => c.name}
|
||||
value={company}
|
||||
onChange={(_, v) => setCompany(v)}
|
||||
renderInput={(params) => <TextField {...params} label="Company" />}
|
||||
/>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<Autocomplete options={companies} getOptionLabel={(c) => c.name} value={company} onChange={(_, v) => setCompany(v)} renderInput={(params) => <TextField {...params} label="Company" />} />
|
||||
<TextField label="Job title" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
||||
<TextField
|
||||
label="Applied on"
|
||||
type="date"
|
||||
value={dateApplied}
|
||||
onChange={(e) => setDateApplied(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField label="Applied on" type="date" value={dateApplied} onChange={(e) => setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="Job URL" value={jobUrl} onChange={(e) => setJobUrl(e.target.value)} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Status update</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 2, mt: 1 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField select label="Current status" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s} value={s}>{s}</MenuItem>
|
||||
))}
|
||||
{STATUS_OPTIONS.map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Status changed on"
|
||||
type="date"
|
||||
value={statusChangedAt}
|
||||
onChange={(e) => setStatusChangedAt(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
helperText={status === initialStatus ? "Only used when you change the status." : "This date will be recorded in the timeline."}
|
||||
/>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />}
|
||||
label="Reply received"
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Reply received on"
|
||||
type="date"
|
||||
disabled={!responseReceived}
|
||||
value={responseDate}
|
||||
onChange={(e) => setResponseDate(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField label="Status changed on" type="date" value={statusChangedAt} onChange={(e) => setStatusChangedAt(e.target.value)} InputLabelProps={{ shrink: true }} helperText={status === initialStatus ? "Only used when you change the status." : "This date will be recorded in the timeline."} />
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}><FormControlLabel control={<Checkbox checked={responseReceived} onChange={(e) => setResponseReceived(e.target.checked)} />} label="Reply received" /></Box>
|
||||
<TextField label="Reply received on" type="date" disabled={!responseReceived} value={responseDate} onChange={(e) => setResponseDate(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="Next action" value={nextAction} onChange={(e) => setNextAction(e.target.value)} />
|
||||
<TextField
|
||||
label="Follow up on"
|
||||
type="date"
|
||||
value={followUpAt}
|
||||
onChange={(e) => setFollowUpAt(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField label="Follow up on" type="date" value={followUpAt} onChange={(e) => setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Role details</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, mt: 1 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2, mt: 1 }}>
|
||||
<TextField label="Location" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
<TextField label="Salary" value={salary} onChange={(e) => setSalary(e.target.value)} />
|
||||
<TextField
|
||||
label="Deadline"
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField label="Deadline" type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="Description language" value={descriptionLanguage} onChange={(e) => setDescriptionLanguage(e.target.value)} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<TagsInput value={tags} onChange={setTags} />
|
||||
</Box>
|
||||
<TextField label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Description (original)" value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Translated description" value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Cover letter" value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} sx={{ gridColumn: "1 / -1" }} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><TagsInput value={tags} onChange={setTags} /></Box>
|
||||
<TextField label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} multiline rows={4} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Description (original)" value={description} onChange={(e) => setDescription(e.target.value)} multiline rows={6} helperText={`${description.length} characters`} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Translated description" value={translatedDescription} onChange={(e) => setTranslatedDescription(e.target.value)} multiline rows={6} helperText={`${translatedDescription.length} characters`} sx={{ gridColumn: "1 / -1" }} />
|
||||
<TextField label="Cover letter" value={coverLetterText} onChange={(e) => setCoverLetterText(e.target.value)} multiline rows={6} helperText={`${coverLetterText.length} characters`} sx={{ gridColumn: "1 / -1" }} />
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Attachments checklist</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1, mb: 1.5 }}>
|
||||
<Chip size="small" label={hasResume ? "Resume ready" : "Resume missing"} color={hasResume ? "success" : "default"} variant={hasResume ? "filled" : "outlined"} />
|
||||
<Chip size="small" label={hasCoverLetter ? "Cover letter ready" : "Cover letter missing"} color={hasCoverLetter ? "success" : "default"} variant={hasCoverLetter ? "filled" : "outlined"} />
|
||||
<Chip size="small" label={hasPortfolio ? "Portfolio ready" : "Portfolio optional"} color={hasPortfolio ? "success" : "default"} variant={hasPortfolio ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1 }}>
|
||||
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label="Resume" />
|
||||
<FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label="Cover letter" />
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function RemindersView() {
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap" }}>
|
||||
{j.needsFollowUp ? (
|
||||
<Chip size="small" label="Follow up" />
|
||||
<Chip size="small" color="warning" label="Follow up" />
|
||||
) : null}
|
||||
{j.followUpReason ? (
|
||||
<Chip size="small" label={j.followUpReason} variant="outlined" />
|
||||
@@ -129,3 +129,18 @@ export default function RemindersView() {
|
||||
);
|
||||
}
|
||||
|
||||
lign: "center", py: 3 }}>
|
||||
Nothing to follow up right now.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<JobDetailsDialog
|
||||
open={openJobId !== null}
|
||||
jobId={openJobId}
|
||||
onClose={() => setOpenJobId(null)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,13 @@ export default function AppShell({
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
{item.badgeCount && item.badgeCount > 0 ? (
|
||||
<Badge color="error" badgeContent={item.badgeCount > 99 ? "99+" : item.badgeCount}>
|
||||
{item.icon}
|
||||
</Badge>
|
||||
) : item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} />
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
@@ -115,9 +115,9 @@ export default function AdminSystemPage() {
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Environment</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{status?.environment ?? "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>Version {status?.version ?? "-"}</Typography>
|
||||
{status?.commitSha ? <Typography variant="body2" sx={{ color: "text.secondary" }}>Commit {status.commitSha}</Typography> : null}
|
||||
{status?.buildStamp ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{status.buildStamp}</Typography> : null}
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>Version {displayMetadata(status?.version)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Commit {displayMetadata(status?.commitSha)}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{displayMetadata(status?.buildStamp)}</Typography>
|
||||
</Paper>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>Database</Typography>
|
||||
@@ -193,3 +193,13 @@ export default function AdminSystemPage() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
.summarizer.lastError}</Alert> : null}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
tus.summarizer.lastError}</Alert> : null}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-2
@@ -237,11 +237,24 @@ async def summarize(req: SummarizeRequest):
|
||||
|
||||
if info["tech"]:
|
||||
uniq = []
|
||||
for t in info["tech"]:
|
||||
for t in _rank_tech_skills(info["tech"]):
|
||||
if t not in uniq:
|
||||
uniq.append(t)
|
||||
lines.append("")
|
||||
lines.append("Relevant hard skills: " + ", ".join(uniq[:14]))
|
||||
lines.append("Top hard skills: " + ", ".join(uniq[:10]))
|
||||
|
||||
if info["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]))
|
||||
|
||||
out = "\n".join(lines).strip()
|
||||
cache[key] = out
|
||||
return {"summary": out, "cached": False}
|
||||
skills: " + ", ".join(uniq[:14]))
|
||||
|
||||
if info["soft"]:
|
||||
uniq_soft = []
|
||||
|
||||
Reference in New Issue
Block a user