Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9191e4cc5b | |||
| cc55fc0cf8 | |||
| 0d65835857 | |||
| 0551a525a8 | |||
| 22d7dd3573 | |||
| f22c6791a7 | |||
| f402213526 | |||
| b283f8b9d2 | |||
| 517c42250d | |||
| 18d1de45cb | |||
| 44000f96f2 | |||
| 99fc94bc18 | |||
| 4253d33dfd | |||
| 839a2ed80d |
@@ -9,6 +9,9 @@ GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
|
||||
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
|
||||
GOOGLE_GMAIL_REDIRECT_URI=
|
||||
AI_SERVICE_BASE_URL=http://ai-service:8001
|
||||
# Optional: enables hybrid CV block classification in the local AI service.
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b
|
||||
|
||||
# Optional: only needed if you want the UI to call a non-default API base URL.
|
||||
# In production the UI defaults to `/api`.
|
||||
|
||||
@@ -7,3 +7,9 @@
|
||||
{"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S07","taskId":"T01"},"ts":"2026-03-27T08:36:36.314Z","actor":"agent","hash":"0aa4019d4a27538a","session_id":"96f47087-e006-4aa2-8147-1cc42da4374d"}
|
||||
{"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S07","taskId":"T02"},"ts":"2026-03-27T08:51:21.876Z","actor":"agent","hash":"7f6dfb093ecf298e","session_id":"96f47087-e006-4aa2-8147-1cc42da4374d"}
|
||||
{"cmd":"complete-task","params":{"milestoneId":"M001","sliceId":"S07","taskId":"T03"},"ts":"2026-03-27T08:55:15.935Z","actor":"agent","hash":"0b8928a7f97d0d42","session_id":"96f47087-e006-4aa2-8147-1cc42da4374d"}
|
||||
{"cmd":"plan-milestone","params":{"milestoneId":"M005"},"ts":"2026-03-28T22:04:42.705Z","actor":"agent","hash":"9f92dc9597f6bcca","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
|
||||
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S01"},"ts":"2026-03-28T22:05:00.001Z","actor":"agent","hash":"94d3ace67d51aaad","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
|
||||
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S02"},"ts":"2026-03-28T22:05:16.424Z","actor":"agent","hash":"cc2907fae86cc252","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
|
||||
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S03"},"ts":"2026-03-28T22:05:32.786Z","actor":"agent","hash":"ae2f80720d601a48","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
|
||||
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S04"},"ts":"2026-03-28T22:05:48.342Z","actor":"agent","hash":"38e10b5bfc9e49e6","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
|
||||
{"cmd":"plan-slice","params":{"milestoneId":"M005","sliceId":"S05"},"ts":"2026-03-28T22:06:02.267Z","actor":"agent","hash":"a4cdfef1b0f97af3","session_id":"14376f9c-a697-450d-ba63-4e6522e8f68d"}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# M001: M001: Gmail and draft quality loop
|
||||
# M001: M001: M001: Gmail and draft quality loop
|
||||
|
||||
## Vision
|
||||
Turn the existing job tracker into a daily-use personal job-search workspace where Gmail import and AI drafting are strong enough to trust, while preserving manual control over all real-world sending and applying.
|
||||
M001: M001: Gmail and draft quality loop
|
||||
|
||||
## Slice Overview
|
||||
| ID | Slice | Risk | Depends | Done | After this |
|
||||
|----|-------|------|---------|------|------------|
|
||||
| S01 | Smarter Gmail import and matching | high | — | ✅ | User can connect Gmail, review likely messages or threads for a job, import a message or full thread, and trust linked Gmail threads to stay current on that job without manual re-import. |
|
||||
| S02 | Stronger AI application package drafting | high | S01 | ✅ | From an imported job plus profile/CV context, the app generates materially better tailored CV and cover-letter drafts that feel specific and usable. |
|
||||
| S03 | Reply and follow-up drafting from real thread context | medium | S01, S02 | ✅ | Inside a job, the user can generate follow-up and reply drafts grounded in imported and automatically refreshed correspondence plus saved application context, then edit them before sending manually. |
|
||||
| S04 | Daily control loop surfaces | medium | S01, S03 | ✅ | The job table works as the primary overview and the follow-up/dashboard surfaces clearly show what needs attention next for an individual user. |
|
||||
| S05 | End-to-end trust and workflow polish | low | S01, S02, S03, S04 | ✅ | The full loop works cleanly in a real environment: import job → generate package → apply externally → import/update correspondence automatically from linked Gmail threads → draft follow-up/reply → track progress confidently. |
|
||||
| S06 | Live environment stabilization and integrated acceptance rerun | high | S05 | ✅ | The real M001 environment runs without the current backend/frontend CORS/runtime blockage, and the full `/jobs` → workspace → Gmail continuity → follow-up → dashboard/reminders loop is re-checked live with recorded acceptance results. |
|
||||
| S07 | Daily-loop UAT artifact closure | medium | S06 | ✅ | The overview-surface/browser validation is captured as a real executed UAT artifact instead of a placeholder, proving the same job behaves coherently across table, dashboard, reminders, and workspace entry. |
|
||||
| S01 | Smarter Gmail import and matching | high | — | ✅ | TBD |
|
||||
| S02 | Stronger AI application package drafting | high | S01 | ✅ | TBD |
|
||||
| S03 | Reply and follow-up drafting from real thread context | medium | S01, S02 | ✅ | TBD |
|
||||
| S04 | Daily control loop surfaces | medium | S01, S03 | ✅ | TBD |
|
||||
| S05 | End-to-end trust and workflow polish | low | S01, S02, S03, S04 | ✅ | TBD |
|
||||
| S06 | Live environment stabilization and integrated acceptance rerun | high | S05 | ✅ | TBD |
|
||||
| S07 | Daily-loop UAT artifact closure | medium | S06 | ✅ | TBD |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# S01: Smarter Gmail import and matching
|
||||
|
||||
**Goal:** Finish S01 by turning the existing job-aware Gmail import flow into a live linked-thread continuity loop for one job workspace.
|
||||
**Demo:** After this: User can connect Gmail, review likely messages or threads for a job, import a message or full thread, and trust linked Gmail threads to stay current on that job without manual re-import.
|
||||
**Demo:** After this: TBD
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Add linked Gmail thread refresh to the backend contract** —
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.612Z
|
||||
completed_at: 2026-03-28T22:02:57.777Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.612Z
|
||||
completed_at: 2026-03-28T22:02:57.777Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# S02: Stronger AI application package drafting
|
||||
|
||||
**Goal:** Make the application package generator use imported job/correspondence context well enough that tailored CV, cover-letter, and recruiter-message drafts feel specific, credible, and worth starting from inside the job workspace.
|
||||
**Demo:** After this: From an imported job plus profile/CV context, the app generates materially better tailored CV and cover-letter drafts that feel specific and usable.
|
||||
**Demo:** After this: TBD
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Strengthen application-package context assembly and backend draft tests** —
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.613Z
|
||||
completed_at: 2026-03-28T22:02:57.777Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.613Z
|
||||
completed_at: 2026-03-28T22:02:57.777Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# S03: Reply and follow-up drafting from real thread context
|
||||
|
||||
**Goal:** Make follow-up drafting use imported correspondence and saved application material well enough that the job workspace can produce specific, trustworthy follow-up and reply drafts without crossing the manual-send boundary.
|
||||
**Demo:** After this: Inside a job, the user can generate follow-up and reply drafts grounded in imported and automatically refreshed correspondence plus saved application context, then edit them before sending manually.
|
||||
**Demo:** After this: TBD
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Strengthen follow-up draft context assembly and backend reply/follow-up tests** —
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.613Z
|
||||
completed_at: 2026-03-28T22:02:57.777Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.614Z
|
||||
completed_at: 2026-03-28T22:02:57.777Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# S04: Daily control loop surfaces
|
||||
|
||||
**Goal:** Make the job table, reminders view, and dashboard behave like one daily control loop so the user can scan what needs attention and jump directly into the right job workspace state.
|
||||
**Demo:** After this: The job table works as the primary overview and the follow-up/dashboard surfaces clearly show what needs attention next for an individual user.
|
||||
**Demo:** After this: TBD
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Turn reminders and dashboard into actionable entry surfaces** —
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.615Z
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.615Z
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# S05: End-to-end trust and workflow polish
|
||||
|
||||
**Goal:** Prove the full daily-use loop as one trustworthy workflow by tightening shared next-action/readiness signals, then validating overview → workspace → package → Gmail continuity → follow-up behavior without weakening the manual-send boundary.
|
||||
**Demo:** After this: The full loop works cleanly in a real environment: import job → generate package → apply externally → import/update correspondence automatically from linked Gmail threads → draft follow-up/reply → track progress confidently.
|
||||
**Demo:** After this: TBD
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Centralize workflow trust signals across overview and readiness surfaces** —
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.616Z
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-27T07:30:18.617Z
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
|
||||
@@ -1,51 +1,15 @@
|
||||
# S06: Live environment stabilization and integrated acceptance rerun
|
||||
|
||||
**Goal:** Live environment is repeatably startable and preflighted, seeded with acceptance-ready data, and the integrated daily loop is re-verified with a recorded artifact proving the manual-send boundary and individual-first workflow.
|
||||
**Demo:** After this: The real M001 environment runs without the current backend/frontend CORS/runtime blockage, and the full `/jobs` → workspace → Gmail continuity → follow-up → dashboard/reminders loop is re-checked live with recorded acceptance results.
|
||||
**Demo:** After this: TBD
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.** — Build a repeatable preflight script and doc so environment blockers are caught before browser UAT.
|
||||
- Why: avoid the ERR_CONNECTION_REFUSED/CORS/auth mismatch that currently blocks the UI.
|
||||
- Steps:
|
||||
1) Create `scripts/s06-preflight.sh` (bash, executable) that assumes backend already started; probes `/api/auth/config` and `/api/admin/system` on `http://localhost:5202/api`, printing database/auth/gmailConfigured/ai status and failing fast on unreachable endpoints.
|
||||
2) Ensure script respects `API_BASE` env override and uses `curl -f` with readable errors; no secrets logged.
|
||||
3) Add a short runbook snippet to `README.md` showing backend start command from `JobTrackerApi/` and how to run the preflight (including auth token note if required).
|
||||
4) Sanity-check CORS expectations vs `job-tracker-ui/src/api.ts` and document the required origin pairing (UI :3000, API :5202).
|
||||
- Failure Modes (Q5): API down → exit 1 with hint to start API; Auth required without token → script notes auth required and how to obtain; malformed JSON → show raw body and fail.
|
||||
- Load Profile (Q6): trivial single-user curl calls; no scaling concern.
|
||||
- Negative Tests (Q7): run script with API stopped (expect non-zero); run with wrong `API_BASE` (expect clear error message).
|
||||
- Must-haves: preflight script exists/executable; README runbook mentions backend start + preflight; script outputs gmailConfigured/auth/db/ai fields.
|
||||
- Verification: `bash scripts/s06-preflight.sh`
|
||||
- Estimate: 45m
|
||||
- [x] **T01: Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.** —
|
||||
- Files: scripts/s06-preflight.sh, README.md, job-tracker-ui/src/api.ts, JobTrackerApi/appsettings.Development.json
|
||||
- Verify: bash scripts/s06-preflight.sh
|
||||
- [x] **T02: Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.** — Create a seed script that prepares a richer acceptance fixture (job, correspondence, saved package, follow-up readiness) using live API calls.
|
||||
- Why: current DB has only 1 low-signal job; acceptance rerun needs actionable overview + workspace state.
|
||||
- Steps:
|
||||
1) Write `scripts/s06-acceptance-data.sh` (bash, executable) that requires `AUTH_TOKEN` env; uses `scripts/s06-preflight.sh` first, then POSTs to `/api/jobapplications` (or PUT existing ID) to create a job with saved package fields, correspondence entry, reminder/follow-up signals, and notes.
|
||||
2) Add curl helpers for adding correspondence (`/api/correspondence/{jobId}` or equivalent), saving package material, and setting workflow/readiness if needed; use deterministic titles so rerun is idempotent (update if exists).
|
||||
3) Emit a short summary of created/updated IDs so the acceptance run can target them; avoid logging token.
|
||||
4) Document any manual token retrieval step in script comments.
|
||||
- Failure Modes (Q5): missing AUTH_TOKEN → fail with guidance; 401/403 → explain token issue; 5xx → print response and fail; malformed response → show body and fail.
|
||||
- Load Profile (Q6): few API calls; minimal DB impact.
|
||||
- Negative Tests (Q7): run without AUTH_TOKEN (expect failure); rerun twice (should succeed idempotently); simulate 401 by bad token (expect clear message).
|
||||
- Must-haves: script seeds at least one job with saved package + correspondence + follow-up readiness; outputs job id for UAT; uses preflight.
|
||||
- Verification: `bash scripts/s06-acceptance-data.sh`
|
||||
- Estimate: 1h
|
||||
- [x] **T02: Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.** —
|
||||
- Files: scripts/s06-acceptance-data.sh, scripts/s06-preflight.sh, README.md
|
||||
- Verify: bash scripts/s06-acceptance-data.sh
|
||||
- [x] **T03: Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.** — Execute the live acceptance loop and record results as an artifact for S07/UAT handoff.
|
||||
- Why: prove the `/jobs → workspace → reminders/dashboard → follow-up/manual-send boundary` loop runs in the live stack after stabilization and seeding.
|
||||
- Steps:
|
||||
1) Create `scripts/s06-acceptance-run.sh` to orchestrate: ensure backend running, run preflight + seed scripts, then run existing automated regressions most relevant to the loop (e.g., `end-to-end-trust-loop.test.tsx`) and capture outputs.
|
||||
2) Perform a guided browser run (can use agent-browser/Playwright) hitting /jobs, /reminders, /dashboard, opening the seeded job workspace, inspecting Tailored CV, Correspondence (linked-thread status), Follow-up draft manual-send boundary; note Gmail continuity if blocked.
|
||||
3) Write findings and screenshots/links into `docs/s06-acceptance-run.md` (what passed, what blocked, manual-send boundary observation, Gmail continuity status). Call out any gaps explicitly.
|
||||
4) Ensure commands avoid leaking tokens; artifacts redact secrets.
|
||||
- Failure Modes (Q5): backend not running → script stops after preflight; tests fail → record failure in artifact; browser step blocked by auth → document and include auth instructions.
|
||||
- Load Profile (Q6): single-user flows; test runner CPU-bound but acceptable.
|
||||
- Negative Tests (Q7): note expected failure if Gmail remains unconfigured; ensure manual-send boundary not auto-triggered during run.
|
||||
- Must-haves: acceptance-run script exists; artifact populated with live results; manual-send boundary explicitly observed; Gmail continuity status recorded (even if blocked).
|
||||
- Verification: `bash scripts/s06-acceptance-run.sh` && `test -s docs/s06-acceptance-run.md`
|
||||
- Estimate: 1h30m
|
||||
- [x] **T03: Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.** —
|
||||
- Files: scripts/s06-acceptance-run.sh, docs/s06-acceptance-run.md, scripts/s06-preflight.sh, scripts/s06-acceptance-data.sh, job-tracker-ui/src/end-to-end-trust-loop.test.tsx
|
||||
- Verify: bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md
|
||||
|
||||
@@ -5,78 +5,18 @@ milestone: M001
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: [".gsd/milestones/M001/slices/S06/tasks/T01-SUMMARY.md", "scripts/s06-preflight.sh", "scripts/s06-preflight.test.sh", "README.md"]
|
||||
key_decisions: ["Keep the preflight safe for shared terminals by never echoing bearer tokens and by treating admin-system auth failures as guided partial success with explicit token instructions."]
|
||||
key_files: []
|
||||
key_decisions: []
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Started the API with dotnet run --project JobTrackerApi/JobTrackerApi.csproj, then ran bash scripts/s06-preflight.sh against the live local API and confirmed the expected partial-pass behavior when /api/admin/system requires admin auth. Ran bash scripts/s06-preflight.test.sh to verify API-down, wrong API_BASE, and malformed JSON negative paths. Also checked README.md content for backend start, preflight command, origin pairing, and AUTH_TOKEN guidance."
|
||||
completed_at: 2026-03-27T07:57:14.981Z
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.
|
||||
|
||||
> Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T01
|
||||
parent: S06
|
||||
milestone: M001
|
||||
key_files:
|
||||
- .gsd/milestones/M001/slices/S06/tasks/T01-SUMMARY.md
|
||||
- scripts/s06-preflight.sh
|
||||
- scripts/s06-preflight.test.sh
|
||||
- README.md
|
||||
key_decisions:
|
||||
- Keep the preflight safe for shared terminals by never echoing bearer tokens and by treating admin-system auth failures as guided partial success with explicit token instructions.
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-27T07:57:14.982Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.
|
||||
|
||||
**Validated and recorded the live API/auth preflight gate, including README runbook guidance and negative-path shell coverage.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Verified that this worktree already contained the planned preflight implementation. The existing scripts/s06-preflight.sh probes /api/auth/config and /api/admin/system, honors API_BASE, prints auth/db/gmailConfigured/ai status surfaces, avoids leaking secrets, and gives readable failure guidance for unreachable API, malformed JSON, and admin-token-required responses. README.md already documented the backend start command, preflight invocation, AUTH_TOKEN note, and the required localhost UI/API origin pairing. This auto-fix attempt primarily addressed the missing task artifact on disk by writing T01-SUMMARY.md after re-running the real verification commands.
|
||||
|
||||
## Verification
|
||||
|
||||
Started the API with dotnet run --project JobTrackerApi/JobTrackerApi.csproj, then ran bash scripts/s06-preflight.sh against the live local API and confirmed the expected partial-pass behavior when /api/admin/system requires admin auth. Ran bash scripts/s06-preflight.test.sh to verify API-down, wrong API_BASE, and malformed JSON negative paths. Also checked README.md content for backend start, preflight command, origin pairing, and AUTH_TOKEN guidance.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `bash scripts/s06-preflight.sh` | 0 | ✅ pass | 123ms |
|
||||
| 2 | `bash scripts/s06-preflight.test.sh` | 0 | ✅ pass | 1251ms |
|
||||
| 3 | `python3 README content check for backend start, preflight command, origin pair, and token note` | 0 | ✅ pass | 0ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
None. The implementation already matched the task plan in this worktree; this attempt restored the missing summary artifact and completion metadata.
|
||||
|
||||
## Known Issues
|
||||
|
||||
The local database in this environment currently has no seeded admin user, so the placeholder development credentials do not yield an admin bearer token here. Full /api/admin/system detail verification therefore still depends on valid admin credentials in the target environment, but the preflight script handles this by surfacing clear AUTH_TOKEN guidance and readiness placeholders.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `.gsd/milestones/M001/slices/S06/tasks/T01-SUMMARY.md`
|
||||
- `scripts/s06-preflight.sh`
|
||||
- `scripts/s06-preflight.test.sh`
|
||||
- `README.md`
|
||||
|
||||
|
||||
## Deviations
|
||||
None. The implementation already matched the task plan in this worktree; this attempt restored the missing summary artifact and completion metadata.
|
||||
|
||||
## Known Issues
|
||||
The local database in this environment currently has no seeded admin user, so the placeholder development credentials do not yield an admin bearer token here. Full /api/admin/system detail verification therefore still depends on valid admin credentials in the target environment, but the preflight script handles this by surfacing clear AUTH_TOKEN guidance and readiness placeholders.
|
||||
No summary recorded.
|
||||
|
||||
@@ -5,83 +5,18 @@ milestone: M001
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["scripts/s06-acceptance-data.sh", "scripts/s06-acceptance-data.test.sh", "README.md", ".gsd/KNOWLEDGE.md", ".gsd/DECISIONS.md", ".gsd/milestones/M001/slices/S06/tasks/T02-SUMMARY.md"]
|
||||
key_decisions: ["Seed acceptance data only through the live companies/jobapplications/correspondence API plus the dedicated tailored-cv, application-drafts, and followup endpoints, keyed by deterministic company/title/thread/message identifiers so reruns stay idempotent.", "Backdate both the seeded follow-up date and the latest correspondence timestamp past the active follow-up threshold so the acceptance fixture lands in workflowSignal.actionKey=follow-up instead of review-readiness."]
|
||||
key_files: []
|
||||
key_decisions: []
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Ran bash scripts/s06-acceptance-data.test.sh against the live API with a valid dev-signed bearer token to confirm missing-token, bad-token, and double-rerun behavior. Ran bash scripts/s06-acceptance-data.sh against the real backend and confirmed seed.result=success, a stable company/job fixture, one correspondence entry, seed.workflow.action=follow-up, seed.readiness.level=Ready, and seed.reminders=Waiting 14d. Verified README.md contains the acceptance-data runbook markers and token guidance."
|
||||
completed_at: 2026-03-27T08:09:46.052Z
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.
|
||||
|
||||
> Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S06
|
||||
milestone: M001
|
||||
key_files:
|
||||
- scripts/s06-acceptance-data.sh
|
||||
- scripts/s06-acceptance-data.test.sh
|
||||
- README.md
|
||||
- .gsd/KNOWLEDGE.md
|
||||
- .gsd/DECISIONS.md
|
||||
- .gsd/milestones/M001/slices/S06/tasks/T02-SUMMARY.md
|
||||
key_decisions:
|
||||
- Seed acceptance data only through the live companies/jobapplications/correspondence API plus the dedicated tailored-cv, application-drafts, and followup endpoints, keyed by deterministic company/title/thread/message identifiers so reruns stay idempotent.
|
||||
- Backdate both the seeded follow-up date and the latest correspondence timestamp past the active follow-up threshold so the acceptance fixture lands in workflowSignal.actionKey=follow-up instead of review-readiness.
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-27T08:09:46.053Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.
|
||||
|
||||
**Seeded acceptance-ready job data through the live API with deterministic rerun-safe ids and readiness output.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Implemented scripts/s06-acceptance-data.sh as an executable live seed flow that requires AUTH_TOKEN, runs the existing S06 preflight first, then creates or reuses a deterministic acceptance company and job via the real API, updates recruiter metadata, saves tailored CV and application package material through the dedicated endpoints, schedules an overdue follow-up, and maintains exactly one deterministic correspondence entry for the linked recruiter thread. Added scripts/s06-acceptance-data.test.sh to verify the missing-token and bad-token failure modes plus authenticated idempotent reruns, updated README.md with the seed command and token guidance, recorded the waiting-threshold gotcha in .gsd/KNOWLEDGE.md, and saved D013 in .gsd/DECISIONS.md for the live-API seeding pattern. After correcting the seeded activity dates to cross the real 14-day RulesEngine threshold, the live fixture now reports workflowSignal.actionKey=follow-up and Waiting 14d reminder output.
|
||||
|
||||
## Verification
|
||||
|
||||
Ran bash scripts/s06-acceptance-data.test.sh against the live API with a valid dev-signed bearer token to confirm missing-token, bad-token, and double-rerun behavior. Ran bash scripts/s06-acceptance-data.sh against the real backend and confirmed seed.result=success, a stable company/job fixture, one correspondence entry, seed.workflow.action=follow-up, seed.readiness.level=Ready, and seed.reminders=Waiting 14d. Verified README.md contains the acceptance-data runbook markers and token guidance.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `bash scripts/s06-acceptance-data.test.sh` | 0 | ✅ pass | 3885ms |
|
||||
| 2 | `bash scripts/s06-acceptance-data.sh` | 0 | ✅ pass | 1831ms |
|
||||
| 3 | `python3 README acceptance-data guidance check` | 0 | ✅ pass | 24ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Added scripts/s06-acceptance-data.test.sh and recorded one knowledge/decision entry beyond the plan’s expected output files so the negative-path and idempotence contract is executable and the follow-up-threshold gotcha is preserved for downstream work.
|
||||
|
||||
## Known Issues
|
||||
|
||||
The local seeded admin password from JobTrackerApi/appsettings.Development.json does not authenticate against this current DB snapshot, so the README and script comments document token retrieval in terms of the real local account or an already-authenticated browser session. Verification used a valid dev-signed JWT against the live API contract, which is sufficient for these user-scoped endpoints but does not prove the placeholder login credentials themselves.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `scripts/s06-acceptance-data.sh`
|
||||
- `scripts/s06-acceptance-data.test.sh`
|
||||
- `README.md`
|
||||
- `.gsd/KNOWLEDGE.md`
|
||||
- `.gsd/DECISIONS.md`
|
||||
- `.gsd/milestones/M001/slices/S06/tasks/T02-SUMMARY.md`
|
||||
|
||||
|
||||
## Deviations
|
||||
Added scripts/s06-acceptance-data.test.sh and recorded one knowledge/decision entry beyond the plan’s expected output files so the negative-path and idempotence contract is executable and the follow-up-threshold gotcha is preserved for downstream work.
|
||||
|
||||
## Known Issues
|
||||
The local seeded admin password from JobTrackerApi/appsettings.Development.json does not authenticate against this current DB snapshot, so the README and script comments document token retrieval in terms of the real local account or an already-authenticated browser session. Verification used a valid dev-signed JWT against the live API contract, which is sufficient for these user-scoped endpoints but does not prove the placeholder login credentials themselves.
|
||||
No summary recorded.
|
||||
|
||||
@@ -5,81 +5,18 @@ milestone: M001
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["scripts/s06-acceptance-run.sh", "docs/s06-acceptance-run.md", ".gsd/KNOWLEDGE.md", ".gsd/DECISIONS.md", ".gsd/milestones/M001/slices/S06/tasks/T03-SUMMARY.md"]
|
||||
key_decisions: ["Allow the S06 acceptance runner to mint a localhost-only admin JWT from the checked-in dev JWT settings plus the local SQLite admin record when AUTH_TOKEN is missing.", "Preserve the browser-observations section in docs/s06-acceptance-run.md across reruns so the verification command can refresh shell evidence without erasing manual UAT notes."]
|
||||
key_files: []
|
||||
key_decisions: []
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Verified bash scripts/s06-preflight.sh against the live API, reran bash scripts/s06-acceptance-data.sh using the locally minted admin token file, and passed bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md. In the browser, authenticated the local UI with the same localhost-only JWT model, confirmed the seeded acceptance job on /jobs and /reminders, confirmed dashboard analytics on /dashboard, and verified that opening the follow-up draft issued GET /api/jobapplications/3/followup-draft without any POST /api/jobapplications/3/send-followup. A clean dashboard reload also passed no_console_errors and no_failed_requests checks."
|
||||
completed_at: 2026-03-27T08:24:16.594Z
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.
|
||||
|
||||
> Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T03
|
||||
parent: S06
|
||||
milestone: M001
|
||||
key_files:
|
||||
- scripts/s06-acceptance-run.sh
|
||||
- docs/s06-acceptance-run.md
|
||||
- .gsd/KNOWLEDGE.md
|
||||
- .gsd/DECISIONS.md
|
||||
- .gsd/milestones/M001/slices/S06/tasks/T03-SUMMARY.md
|
||||
key_decisions:
|
||||
- Allow the S06 acceptance runner to mint a localhost-only admin JWT from the checked-in dev JWT settings plus the local SQLite admin record when AUTH_TOKEN is missing.
|
||||
- Preserve the browser-observations section in docs/s06-acceptance-run.md across reruns so the verification command can refresh shell evidence without erasing manual UAT notes.
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-27T08:24:16.596Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.
|
||||
|
||||
**Added a repeatable live acceptance runner and recorded real S06 browser evidence for the manual-send boundary and daily loop.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Built scripts/s06-acceptance-run.sh to orchestrate the live S06 acceptance rerun, capture preflight/seed/test logs, and keep docs/s06-acceptance-run.md current without overwriting the guided browser section. Added a localhost-only JWT fallback for the runner so the acceptance fixture and protected UI can be exercised repeatably even though the placeholder appsettings development password no longer authenticates against the current SQLite snapshot. Then ran the live browser flow against /jobs, the seeded workspace, /reminders, and /dashboard, captured debug bundles plus trace/timeline artifacts, and recorded the observed manual-send boundary and current Gmail-continuity limitation in the acceptance document.
|
||||
|
||||
## Verification
|
||||
|
||||
Verified bash scripts/s06-preflight.sh against the live API, reran bash scripts/s06-acceptance-data.sh using the locally minted admin token file, and passed bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md. In the browser, authenticated the local UI with the same localhost-only JWT model, confirmed the seeded acceptance job on /jobs and /reminders, confirmed dashboard analytics on /dashboard, and verified that opening the follow-up draft issued GET /api/jobapplications/3/followup-draft without any POST /api/jobapplications/3/send-followup. A clean dashboard reload also passed no_console_errors and no_failed_requests checks.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `bash scripts/s06-preflight.sh` | 0 | ✅ pass | 142ms |
|
||||
| 2 | `AUTH_TOKEN="$(python3 - <<'PY' ... PY)" bash scripts/s06-acceptance-data.sh` | 0 | ✅ pass | 2301ms |
|
||||
| 3 | `bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md` | 0 | ✅ pass | 5501ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Added a localhost-only JWT fallback inside scripts/s06-acceptance-run.sh because the current SQLite snapshot still contains the admin user while the placeholder development password no longer authenticates. This kept the task verification repeatable without changing scripts/s06-acceptance-data.sh's direct missing-token failure mode.
|
||||
|
||||
## Known Issues
|
||||
|
||||
This run did not prove a Gmail-connected continuity refresh. The seeded recruiter-thread correspondence is visible in the workspace, but the live browser session did not surface connected Gmail state or issue POST /api/gmail/refresh-linked-threads, so the acceptance artifact records Gmail continuity as not configured/not refreshed in this local run.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `scripts/s06-acceptance-run.sh`
|
||||
- `docs/s06-acceptance-run.md`
|
||||
- `.gsd/KNOWLEDGE.md`
|
||||
- `.gsd/DECISIONS.md`
|
||||
- `.gsd/milestones/M001/slices/S06/tasks/T03-SUMMARY.md`
|
||||
|
||||
|
||||
## Deviations
|
||||
Added a localhost-only JWT fallback inside scripts/s06-acceptance-run.sh because the current SQLite snapshot still contains the admin user while the placeholder development password no longer authenticates. This kept the task verification repeatable without changing scripts/s06-acceptance-data.sh's direct missing-token failure mode.
|
||||
|
||||
## Known Issues
|
||||
This run did not prove a Gmail-connected continuity refresh. The seeded recruiter-thread correspondence is visible in the workspace, but the live browser session did not surface connected Gmail state or issue POST /api/gmail/refresh-linked-threads, so the acceptance artifact records Gmail continuity as not configured/not refreshed in this local run.
|
||||
No summary recorded.
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
# S07: Daily-loop UAT artifact closure
|
||||
|
||||
**Goal:** Publish the executed daily-loop UAT closure artifact proving one seeded job stays coherent across /jobs, the job workspace, /reminders, and /dashboard using the existing S06 acceptance runner.
|
||||
**Demo:** After this: The overview-surface/browser validation is captured as a real executed UAT artifact instead of a placeholder, proving the same job behaves coherently across table, dashboard, reminders, and workspace entry.
|
||||
**Demo:** After this: TBD
|
||||
|
||||
## Tasks
|
||||
- [x] **T01: Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.** — Why: Give S07 its own UAT closure document that reuses the S06 acceptance runner as the evidence source and frames the daily-loop proof across /jobs → workspace → reminders → dashboard while preserving the manual-send and Gmail-continuity notes. Do: review docs/s06-acceptance-run.md and scripts/s06-acceptance-run.sh for generated/manual seams; create docs/s07-uat.md with sections for surfaces, job identity, manual-send boundary evidence, Gmail continuity status, rerun commands, and artifact links; note that evidence is imported from the acceptance run rather than duplicated. Done when docs/s07-uat.md exists with the sections above and references the seeded job (S06 Acceptance Backend Engineer) and acceptance artifact.
|
||||
- Estimate: 45m
|
||||
- [x] **T01: Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.** —
|
||||
- Files: docs/s07-uat.md, docs/s06-acceptance-run.md
|
||||
- Verify: test -s docs/s07-uat.md && grep -q "S06 Acceptance Backend Engineer" docs/s07-uat.md
|
||||
- [x] **T02: Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.** — Why: Refresh the live acceptance evidence and capture the manual-send boundary plus Gmail continuity status for S07. Do: run `bash scripts/s06-preflight.sh`; run `bash scripts/s06-acceptance-run.sh` (with AUTH_TOKEN if needed) to regenerate docs/s06-acceptance-run.md and artifacts; confirm the seeded job identity and cross-surface observations, and extract artifact links (logs, trace, timeline, screenshots/debug bundle); update docs/s07-uat.md with the latest evidence, explicitly stating the manual-send boundary (GET followup-draft seen, no POST send-followup) and the Gmail continuity limitation observed in this run. Failure modes: backend/API down → note preflight failure; auth/token missing → use runner fallback guidance; browser/assertion failures → capture log paths; malformed artifact paths → rerun and repair links. Negative checks: ensure acceptance run did not issue send-followup, and Gmail refresh absence is recorded, not implied passing. Done when both docs are updated with current run evidence and links.
|
||||
- Estimate: 1h
|
||||
- [x] **T02: Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.** —
|
||||
- Files: scripts/s06-preflight.sh, scripts/s06-acceptance-run.sh, docs/s06-acceptance-run.md, docs/s07-uat.md
|
||||
- Verify: bash scripts/s06-preflight.sh && bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md && grep -q "manual-send boundary" docs/s07-uat.md
|
||||
- [x] **T03: Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.** — Why: Anchor the S07 UAT closure to the existing focused UI regressions that encode the cross-surface contract. Do: from job-tracker-ui/, run `CI=true npm test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx`; capture pass/fail summaries and note any flake; update docs/s07-uat.md with the test command, date/time, and results so the UAT doc cites both live run evidence and deterministic regression coverage. Failure modes: missing node modules → npm install; test failures → log failing test output and blockers in the doc. Negative tests: ensure the doc notes what happens if these tests fail (e.g., stop claiming UAT closure). Done when tests pass and docs/s07-uat.md reflects the run and command used.
|
||||
- Estimate: 40m
|
||||
- [x] **T03: Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.** —
|
||||
- Files: job-tracker-ui/src/daily-control-loop.test.tsx, job-tracker-ui/src/workflow-trust-signals.test.tsx, docs/s07-uat.md
|
||||
- Verify: CI=true npm --prefix job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx && grep -q "UI regression results" docs/s07-uat.md
|
||||
|
||||
@@ -5,72 +5,18 @@ milestone: M001
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["docs/s07-uat.md", ".gsd/milestones/M001/slices/S07/tasks/T01-SUMMARY.md"]
|
||||
key_decisions: ["Reused docs/s06-acceptance-run.md as the canonical execution artifact and positioned docs/s07-uat.md as an imported-evidence closure document instead of duplicating generated run output."]
|
||||
key_files: []
|
||||
key_decisions: []
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Verified the task-plan must-have by confirming docs/s07-uat.md exists, is non-empty, and contains the seeded job identity string S06 Acceptance Backend Engineer."
|
||||
completed_at: 2026-03-27T08:36:36.287Z
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.
|
||||
|
||||
> Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T01
|
||||
parent: S07
|
||||
milestone: M001
|
||||
key_files:
|
||||
- docs/s07-uat.md
|
||||
- .gsd/milestones/M001/slices/S07/tasks/T01-SUMMARY.md
|
||||
key_decisions:
|
||||
- Reused docs/s06-acceptance-run.md as the canonical execution artifact and positioned docs/s07-uat.md as an imported-evidence closure document instead of duplicating generated run output.
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-27T08:36:36.289Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.
|
||||
|
||||
**Added docs/s07-uat.md to close S07 with imported acceptance-run evidence for the seeded daily-loop job.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Reviewed the S07 task contract together with docs/s06-acceptance-run.md and scripts/s06-acceptance-run.sh to confirm the runner-generated/manual seam. Created docs/s07-uat.md as a slice-specific closure artifact that references the canonical S06 acceptance run instead of duplicating it, names the seeded job S06 Acceptance Backend Engineer, summarizes the /jobs → workspace → /reminders → /dashboard coherence proof, preserves the manual-send boundary evidence, records the Gmail continuity limitation explicitly, and includes rerun commands plus artifact links.
|
||||
|
||||
## Verification
|
||||
|
||||
Verified the task-plan must-have by confirming docs/s07-uat.md exists, is non-empty, and contains the seeded job identity string S06 Acceptance Backend Engineer.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `test -s /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md && grep -q "S06 Acceptance Backend Engineer" /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md` | 0 | ✅ pass | 1ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `docs/s07-uat.md`
|
||||
- `.gsd/milestones/M001/slices/S07/tasks/T01-SUMMARY.md`
|
||||
|
||||
|
||||
## Deviations
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
No summary recorded.
|
||||
|
||||
@@ -5,78 +5,18 @@ milestone: M001
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["docs/s06-acceptance-run.md", "docs/s07-uat.md", ".gsd/milestones/M001/slices/S07/tasks/T02-SUMMARY.md"]
|
||||
key_decisions: ["Recorded the manual-send boundary for this rerun as a combination of live workspace UI evidence plus an authenticated browser-context GET check to /api/jobapplications/3/followup-draft, because this build did not emit a fresh captured request merely from tab selection."]
|
||||
key_files: []
|
||||
key_decisions: []
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Verified the task contract in the live worktree by first reproducing the initial failure mode (bash scripts/s06-preflight.sh while the API was down), then starting the local API/UI, rerunning the acceptance scripts, and executing the browser flow across /jobs, workspace, /reminders, and /dashboard. The final shell gate passed with bash scripts/s06-preflight.sh && bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md && grep -q "manual-send boundary" docs/s07-uat.md. Browser assertions passed for the seeded reminders row, dashboard counters/company activity, and clean dashboard reload diagnostics (no console errors, no failed requests). The follow-up draft state showed separate copy/send controls, the authenticated browser-context GET to /api/jobapplications/3/followup-draft returned 200 with subject/body present, and no send-followup request was triggered during the observed browser pass."
|
||||
completed_at: 2026-03-27T08:51:21.858Z
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-28T22:02:57.778Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.
|
||||
|
||||
> Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T02
|
||||
parent: S07
|
||||
milestone: M001
|
||||
key_files:
|
||||
- docs/s06-acceptance-run.md
|
||||
- docs/s07-uat.md
|
||||
- .gsd/milestones/M001/slices/S07/tasks/T02-SUMMARY.md
|
||||
key_decisions:
|
||||
- Recorded the manual-send boundary for this rerun as a combination of live workspace UI evidence plus an authenticated browser-context GET check to /api/jobapplications/3/followup-draft, because this build did not emit a fresh captured request merely from tab selection.
|
||||
duration: ""
|
||||
verification_result: mixed
|
||||
completed_at: 2026-03-27T08:51:21.858Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T02: Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.
|
||||
|
||||
**Re-ran the acceptance flow and refreshed the S07 UAT closure with current browser evidence, manual-send-boundary proof, and the Gmail continuity limitation.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Started from the task contract and verified the actual runner/doc seam before changing anything. The first verification attempt failed immediately because the API was not listening on http://localhost:5202, so I treated that as an execution-state issue, started the real ASP.NET Core API and React UI from this worktree, and reran the acceptance flow instead of documenting a stale failure. With the stack live, I ran scripts/s06-preflight.sh and scripts/s06-acceptance-run.sh to refresh docs/s06-acceptance-run.md and the acceptance log set. I then exercised the real authenticated browser flow across /jobs, the seeded job workspace, /reminders, and /dashboard, and captured fresh artifacts for the jobs/workspace view, follow-up draft state, reminders surface, dashboard surface, Playwright trace, and browser timeline. The browser pass confirmed the seeded row S06 Acceptance Labs • S06 Acceptance Backend Engineer still appears on /jobs with Follow up, CV ready, and Waiting; the workspace still shows the saved tailored CV content and seeded recruiter-thread message; /reminders still shows Follow up, Waiting 14d, and Follow-up: 10/03/2026; and /dashboard still reports Active applications = 2, Applied (30 days) = 2, Responses logged = 1, and includes S06 Acceptance Labs under top companies by activity. For the manual-send boundary, the follow-up draft UI rendered separate Copy Draft and Send And Log Email actions in the live workspace. In this build, selecting the draft tab did not itself emit a newly captured request, so I verified the draft endpoint from the authenticated browser context with GET /api/jobapplications/3/followup-draft -> 200 and recorded that no POST /api/jobapplications/3/send-followup was triggered during the observed browser pass. I also kept the Gmail continuity claim honest: the seeded correspondence was visible, but the Linked Gmail thread continuity banner was not shown and no linked-thread refresh activity was observed, so the docs now call Gmail continuity a limitation for this run rather than implying success. Finally, I updated both docs/s06-acceptance-run.md and docs/s07-uat.md so they reference the final verified shell rerun (20260327T084839Z), the fresh browser artifacts from this task, the explicit manual-send boundary wording, and the Gmail continuity limitation.
|
||||
|
||||
## Verification
|
||||
|
||||
Verified the task contract in the live worktree by first reproducing the initial failure mode (bash scripts/s06-preflight.sh while the API was down), then starting the local API/UI, rerunning the acceptance scripts, and executing the browser flow across /jobs, workspace, /reminders, and /dashboard. The final shell gate passed with bash scripts/s06-preflight.sh && bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md && grep -q "manual-send boundary" docs/s07-uat.md. Browser assertions passed for the seeded reminders row, dashboard counters/company activity, and clean dashboard reload diagnostics (no console errors, no failed requests). The follow-up draft state showed separate copy/send controls, the authenticated browser-context GET to /api/jobapplications/3/followup-draft returned 200 with subject/body present, and no send-followup request was triggered during the observed browser pass.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `bash scripts/s06-preflight.sh` | 1 | ❌ fail | 0ms |
|
||||
| 2 | `bash scripts/s06-preflight.sh && bash scripts/s06-acceptance-run.sh && test -s docs/s06-acceptance-run.md && grep -q "manual-send boundary" docs/s07-uat.md` | 0 | ✅ pass | 5783ms |
|
||||
| 3 | `browser_assert reminders: text_visible("S06 Acceptance Labs • S06 Acceptance Backend Engineer"), text_visible("Follow-up: 10/03/2026"), text_visible("Waiting 14d"), text_visible("Follow up")` | 0 | ✅ pass | 0ms |
|
||||
| 4 | `browser_assert dashboard: text_visible("Active applications"), text_visible("Responses logged"), text_visible("S06 Acceptance Labs"), no_console_errors, no_failed_requests` | 0 | ✅ pass | 0ms |
|
||||
| 5 | `browser_evaluate fetch('http://localhost:5202/api/jobapplications/3/followup-draft') with browser auth token` | 0 | ✅ pass | 0ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Started the API and UI locally before rerunning the task because the required backend was not listening on port 5202 at the start of execution. For browser auth, I used the same localhost-only JWT strategy already built into the acceptance runner because the checked-in placeholder password no longer authenticated against this DB snapshot. The manual-send section was documented as UI evidence plus a browser-context GET confirmation because this build did not emit a newly captured request solely from tab selection.
|
||||
|
||||
## Known Issues
|
||||
|
||||
The browser automation logs show a harmless early failed login attempt and an abandoned temporary-token experiment before the final auth path was corrected; those diagnostics were not used as acceptance evidence. Gmail continuity is still not proven live in this environment because the connected-Gmail banner/refresh activity did not surface during this run.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `docs/s06-acceptance-run.md`
|
||||
- `docs/s07-uat.md`
|
||||
- `.gsd/milestones/M001/slices/S07/tasks/T02-SUMMARY.md`
|
||||
|
||||
|
||||
## Deviations
|
||||
Started the API and UI locally before rerunning the task because the required backend was not listening on port 5202 at the start of execution. For browser auth, I used the same localhost-only JWT strategy already built into the acceptance runner because the checked-in placeholder password no longer authenticated against this DB snapshot. The manual-send section was documented as UI evidence plus a browser-context GET confirmation because this build did not emit a newly captured request solely from tab selection.
|
||||
|
||||
## Known Issues
|
||||
The browser automation logs show a harmless early failed login attempt and an abandoned temporary-token experiment before the final auth path was corrected; those diagnostics were not used as acceptance evidence. Gmail continuity is still not proven live in this environment because the connected-Gmail banner/refresh activity did not surface during this run.
|
||||
No summary recorded.
|
||||
|
||||
@@ -5,79 +5,18 @@ milestone: M001
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["docs/s07-uat.md", "job-tracker-ui/package-lock.json", ".gsd/KNOWLEDGE.md", ".gsd/milestones/M001/slices/S07/tasks/T03-SUMMARY.md"]
|
||||
key_decisions: ["No new architectural or product decision was needed; this task refreshed the UAT closure with deterministic regression evidence and documented the dependency-repair prerequisite actually required in this worktree."]
|
||||
key_files: []
|
||||
key_decisions: []
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Verified the task contract by first reproducing the planned-command failure (`react-scripts: not found`), then repairing dependencies with `npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui install`, rerunning the focused Jest command successfully, and finally running the slice/task verification gate equivalent: `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx && grep -q "UI regression results" /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md`. The final run passed with `2 passed, 2 total` suites and `6 passed, 6 total` tests."
|
||||
completed_at: 2026-03-27T08:55:15.905Z
|
||||
verification_result: ""
|
||||
completed_at: 2026-03-28T22:02:57.779Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.
|
||||
|
||||
> Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T03
|
||||
parent: S07
|
||||
milestone: M001
|
||||
key_files:
|
||||
- docs/s07-uat.md
|
||||
- job-tracker-ui/package-lock.json
|
||||
- .gsd/KNOWLEDGE.md
|
||||
- .gsd/milestones/M001/slices/S07/tasks/T03-SUMMARY.md
|
||||
key_decisions:
|
||||
- No new architectural or product decision was needed; this task refreshed the UAT closure with deterministic regression evidence and documented the dependency-repair prerequisite actually required in this worktree.
|
||||
duration: ""
|
||||
verification_result: mixed
|
||||
completed_at: 2026-03-27T08:55:15.906Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.
|
||||
|
||||
**Re-ran the focused daily-loop UI regressions, repaired the local CRA dependency state, and recorded the passing deterministic coverage in docs/s07-uat.md.**
|
||||
|
||||
## What Happened
|
||||
|
||||
Started from the task contract and verified the existing S07 UAT document before changing it. The first execution of the planned Jest command failed with the documented dependency failure mode: `react-scripts: not found`. I verified that this was a local install-state issue rather than a missing test target, repaired the UI package with `npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui install`, and reran the exact focused regression command from the plan. The rerun passed cleanly across both targeted suites, with only stable React Router future-flag warnings in console output. I then updated `docs/s07-uat.md` to add a dedicated UI regression results section that records the command used, the timestamp window, the initial failure/repair step, the final suite/test counts, the absence of observed flake, and the explicit guardrail that S07 UAT closure should not be claimed if this regression pair fails on a future rerun. Because the install-state quirk is non-obvious and likely to recur in this worktree, I also appended that rerun note to `.gsd/KNOWLEDGE.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
Verified the task contract by first reproducing the planned-command failure (`react-scripts: not found`), then repairing dependencies with `npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui install`, rerunning the focused Jest command successfully, and finally running the slice/task verification gate equivalent: `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx && grep -q "UI regression results" /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md`. The final run passed with `2 passed, 2 total` suites and `6 passed, 6 total` tests.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx` | 127 | ❌ fail | 11500ms |
|
||||
| 2 | `npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui install` | 0 | ✅ pass | 27000ms |
|
||||
| 3 | `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx` | 0 | ✅ pass | 5530ms |
|
||||
| 4 | `CI=true npm --prefix /home/pi/development/JobTracker/.gsd/worktrees/M001/job-tracker-ui test -- --runInBand --watch=false src/daily-control-loop.test.tsx src/workflow-trust-signals.test.tsx && grep -q "UI regression results" /home/pi/development/JobTracker/.gsd/worktrees/M001/docs/s07-uat.md` | 0 | ✅ pass | 4966ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
The task plan assumed the focused UI tests could run immediately, but this worktree required a dependency repair first even though `node_modules` already existed. I documented that observed prerequisite in both the task summary and project knowledge instead of treating it as an unrecorded transient.
|
||||
|
||||
## Known Issues
|
||||
|
||||
The focused suites still emit React Router v7 future-flag warnings during render. They are non-failing and stable, so they did not block UAT closure evidence, but they remain visible noise in the deterministic regression output.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `docs/s07-uat.md`
|
||||
- `job-tracker-ui/package-lock.json`
|
||||
- `.gsd/KNOWLEDGE.md`
|
||||
- `.gsd/milestones/M001/slices/S07/tasks/T03-SUMMARY.md`
|
||||
|
||||
|
||||
## Deviations
|
||||
The task plan assumed the focused UI tests could run immediately, but this worktree required a dependency repair first even though `node_modules` already existed. I documented that observed prerequisite in both the task summary and project knowledge instead of treating it as an unrecorded transient.
|
||||
|
||||
## Known Issues
|
||||
The focused suites still emit React Router v7 future-flag warnings during render. They are non-failing and stable, so they did not block UAT closure evidence, but they remain visible noise in the deterministic regression output.
|
||||
No summary recorded.
|
||||
|
||||
+883
-429
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
# CV Changes
|
||||
|
||||
## Requests
|
||||
|
||||
<!-- Add requested CV changes here as they come in -->
|
||||
|
||||
## Progress
|
||||
|
||||
- Created tracking file.
|
||||
@@ -24,6 +24,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<JobEvent> JobEvents => Set<JobEvent>();
|
||||
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
|
||||
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
|
||||
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -101,6 +102,19 @@ namespace JobTrackerApi.Data
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ArtifactId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
modelBuilder.Entity<TailoredCvDraft>()
|
||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<TailoredCvDraft>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<TailoredCvDraft>()
|
||||
.HasOne(x => x.JobApplication)
|
||||
.WithOne(j => j.TailoredCvDraft)
|
||||
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi", "JobTracker
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi.Tests", "JobTrackerApi.Tests\JobTrackerApi.Tests.csproj", "{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerBackend", "JobTrackerBackend\JobTrackerBackend.csproj", "{709F069F-DD13-42CC-9C5E-99923A545790}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -41,6 +43,18 @@ Global
|
||||
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.Build.0 = Release|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using System.Collections;
|
||||
using System.Linq.Expressions;
|
||||
@@ -133,6 +133,35 @@ public sealed class AuthAndSystemControllerTests
|
||||
Assert.Equal("person@example.com", result.GoogleLink.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_system_email_settings_falls_back_when_override_store_is_unavailable()
|
||||
{
|
||||
var emailSettings = new Mock<IEmailSettingsResolver>();
|
||||
emailSettings.Setup(x => x.GetAdminDtoAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("missing SystemEmailSettings"));
|
||||
|
||||
var cfg = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Email:Enabled"] = "false",
|
||||
["Email:FromName"] = "Jobbjakt"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var controller = new AdminSystemController(
|
||||
cfg,
|
||||
new AppPaths(cfg, new FakeHostEnv()),
|
||||
null!,
|
||||
Mock.Of<ISummarizerService>(),
|
||||
new FakeEnv(),
|
||||
emailSettings.Object);
|
||||
|
||||
var result = await controller.GetEmailSettings(CancellationToken.None);
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<EmailSettingsAdminDto>(ok.Value);
|
||||
Assert.False(dto.Enabled);
|
||||
Assert.Contains("fallback", dto.FromName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_system_probe_endpoint_runs_probe_once()
|
||||
{
|
||||
@@ -162,18 +191,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
new NullLogger<UserManager<ApplicationUser>>()
|
||||
);
|
||||
return TestHostFactory.CreateUserManager();
|
||||
}
|
||||
|
||||
private sealed class TestAsyncQueryProvider<TEntity> : IAsyncQueryProvider
|
||||
|
||||
@@ -2,11 +2,10 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
@@ -34,11 +33,6 @@ public sealed class BackupControllerTests
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(x => x.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
return TestHostFactory.CreateInMemoryDb();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvCorpusHarnessTests
|
||||
{
|
||||
private static readonly string CorpusRoot = "/home/pi/cvs";
|
||||
|
||||
[Fact]
|
||||
public async Task Local_cv_corpus_harness_produces_repeatable_parse_report_when_available()
|
||||
{
|
||||
if (!Directory.Exists(CorpusRoot)) return;
|
||||
|
||||
var files = Directory.EnumerateFiles(CorpusRoot, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.Where(path => path.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".docx", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0) return;
|
||||
|
||||
var outputRoot = ResolveOutputRoot();
|
||||
var outputsDir = Path.Combine(outputRoot, "outputs");
|
||||
var candidateFixturesDir = Path.Combine(outputRoot, "candidate-fixtures");
|
||||
var approvedFixturesDir = ResolveApprovedFixturesRoot(outputRoot);
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
Directory.CreateDirectory(outputsDir);
|
||||
Directory.CreateDirectory(candidateFixturesDir);
|
||||
Directory.CreateDirectory(approvedFixturesDir);
|
||||
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "seed" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), It.IsAny<string>(), 3200, 900)).ReturnsAsync(string.Empty);
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), It.IsAny<string>(), 2800, 900)).ReturnsAsync((string _, string text, int _, int __) => text);
|
||||
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var paths = CreatePaths(outputRoot);
|
||||
var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, null, NoOpCvAiClassifier.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
var extractMethod = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
var buildMethod = typeof(ProfileCvController).GetMethod("BuildStructuredCvAsync", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
Assert.NotNull(extractMethod);
|
||||
Assert.NotNull(buildMethod);
|
||||
|
||||
var entries = new List<CvBenchmarkEntry>();
|
||||
foreach (var path in files)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var fileName = Path.GetFileName(path);
|
||||
var formFile = new FormFile(stream, 0, stream.Length, "file", fileName)
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = GuessContentType(path)
|
||||
};
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
var extractTask = (Task<string>)extractMethod!.Invoke(null, new object[] { formFile, extension })!;
|
||||
var text = await extractTask;
|
||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||
|
||||
var buildTask = (Task<StructuredCvProfile>)buildMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!;
|
||||
var structured = StructuredCvProfileJson.Normalize(await buildTask);
|
||||
Assert.NotNull(structured);
|
||||
|
||||
var slug = Slugify(fileName);
|
||||
var normalizedJson = StructuredCvProfileJson.Serialize(structured);
|
||||
var outputPath = Path.Combine(outputsDir, $"{slug}.json");
|
||||
await File.WriteAllTextAsync(outputPath, PrettyJson(normalizedJson));
|
||||
|
||||
var approvedPath = Path.Combine(approvedFixturesDir, $"{slug}.json");
|
||||
var candidateFixturePath = Path.Combine(candidateFixturesDir, $"{slug}.json");
|
||||
string? diffSummary = null;
|
||||
var approvedExists = File.Exists(approvedPath);
|
||||
if (approvedExists)
|
||||
{
|
||||
var approvedJson = await File.ReadAllTextAsync(approvedPath);
|
||||
diffSummary = SummarizeDiff(approvedJson, normalizedJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
await File.WriteAllTextAsync(candidateFixturePath, PrettyJson(normalizedJson));
|
||||
diffSummary = "No approved fixture yet — candidate fixture written.";
|
||||
}
|
||||
|
||||
entries.Add(new CvBenchmarkEntry(
|
||||
FileName: fileName,
|
||||
Slug: slug,
|
||||
Extension: extension,
|
||||
Characters: text.Length,
|
||||
OutputPath: outputPath,
|
||||
ApprovedFixturePath: approvedExists ? approvedPath : null,
|
||||
CandidateFixturePath: approvedExists ? null : candidateFixturePath,
|
||||
ContactLocation: structured.Contact.Location,
|
||||
FirstJob: structured.Jobs.FirstOrDefault()?.Title,
|
||||
FirstJobLocation: structured.Jobs.FirstOrDefault()?.Location,
|
||||
FirstEducation: structured.Education.FirstOrDefault()?.Qualification,
|
||||
FirstEducationLocation: structured.Education.FirstOrDefault()?.Location,
|
||||
QualificationLevels: structured.Education.Select(x => x.QualificationLevel).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList(),
|
||||
SuspiciousLocations: structured.Jobs.Select(job => job.Location)
|
||||
.Concat(structured.Education.Select(education => education.Location))
|
||||
.Append(structured.Contact.Location)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Cast<string>()
|
||||
.Where(LooksSuspiciousLocation)
|
||||
.ToList(),
|
||||
CoverageScore: ComputeCoverageScore(structured),
|
||||
ConfidenceScore: ComputeConfidenceScore(structured),
|
||||
ConsistencyScore: ComputeConsistencyScore(structured),
|
||||
DiffSummary: diffSummary
|
||||
));
|
||||
}
|
||||
|
||||
var summary = new CvBenchmarkSummary(
|
||||
CorpusRoot,
|
||||
outputRoot,
|
||||
DateTimeOffset.UtcNow,
|
||||
entries.Count,
|
||||
Math.Round(entries.Average(x => x.CoverageScore), 3),
|
||||
Math.Round(entries.Average(x => x.ConfidenceScore), 3),
|
||||
Math.Round(entries.Average(x => x.ConsistencyScore), 3),
|
||||
entries.Count(x => x.SuspiciousLocations.Count > 0),
|
||||
entries.Count(x => x.ApprovedFixturePath is null),
|
||||
entries
|
||||
);
|
||||
|
||||
var indexPath = Path.Combine(outputRoot, "index.json");
|
||||
var reportPath = Path.Combine(outputRoot, "report.md");
|
||||
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true }));
|
||||
await File.WriteAllTextAsync(reportPath, RenderMarkdownReport(summary));
|
||||
|
||||
Assert.True(entries.Count > 0);
|
||||
}
|
||||
|
||||
private sealed record CvBenchmarkEntry(
|
||||
string FileName,
|
||||
string Slug,
|
||||
string Extension,
|
||||
int Characters,
|
||||
string OutputPath,
|
||||
string? ApprovedFixturePath,
|
||||
string? CandidateFixturePath,
|
||||
string? ContactLocation,
|
||||
string? FirstJob,
|
||||
string? FirstJobLocation,
|
||||
string? FirstEducation,
|
||||
string? FirstEducationLocation,
|
||||
List<string> QualificationLevels,
|
||||
List<string> SuspiciousLocations,
|
||||
double CoverageScore,
|
||||
double ConfidenceScore,
|
||||
double ConsistencyScore,
|
||||
string? DiffSummary);
|
||||
|
||||
private sealed record CvBenchmarkSummary(
|
||||
string CorpusRoot,
|
||||
string OutputRoot,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
int TotalFiles,
|
||||
double AverageCoverage,
|
||||
double AverageConfidence,
|
||||
double AverageConsistency,
|
||||
int FilesWithSuspiciousLocations,
|
||||
int MissingApprovedFixtures,
|
||||
List<CvBenchmarkEntry> Entries);
|
||||
|
||||
private static string ResolveOutputRoot()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_OUTPUT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim();
|
||||
return Path.Combine(Path.GetTempPath(), "jobtracker-cv-benchmark", DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
|
||||
}
|
||||
|
||||
private static string ResolveApprovedFixturesRoot(string outputRoot)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_APPROVED_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim();
|
||||
return Path.Combine(outputRoot, "approved-fixtures");
|
||||
}
|
||||
|
||||
private static string PrettyJson(string normalizedJson)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(normalizedJson);
|
||||
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static string SummarizeDiff(string approvedJson, string actualJson)
|
||||
{
|
||||
if (JsonDocument.Parse(approvedJson).RootElement.ToString() == JsonDocument.Parse(actualJson).RootElement.ToString())
|
||||
{
|
||||
return "Matches approved fixture.";
|
||||
}
|
||||
|
||||
var approvedHash = Hash(approvedJson);
|
||||
var actualHash = Hash(actualJson);
|
||||
return $"Fixture differs (approved {approvedHash[..8]}, actual {actualHash[..8]}).";
|
||||
}
|
||||
|
||||
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private static double ComputeCoverageScore(StructuredCvProfile structured)
|
||||
{
|
||||
var signals = new[]
|
||||
{
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.FullName),
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.Email),
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.Location),
|
||||
structured.Summary.Count > 0,
|
||||
structured.Skills.Count > 0,
|
||||
structured.Jobs.Count > 0,
|
||||
structured.Education.Count > 0,
|
||||
structured.Certifications.Count > 0 || structured.Projects.Count > 0 || structured.OtherSections.Count > 0,
|
||||
};
|
||||
return signals.Count(x => x) / (double)signals.Length;
|
||||
}
|
||||
|
||||
private static double ComputeConfidenceScore(StructuredCvProfile structured)
|
||||
{
|
||||
var confidences = structured.Metadata.Fields.Values.Select(x => x.Confidence).Where(x => x.HasValue).Select(x => x!.Value).ToList();
|
||||
return confidences.Count == 0 ? 0.55 : Math.Clamp(confidences.Average(), 0, 1);
|
||||
}
|
||||
|
||||
private static double ComputeConsistencyScore(StructuredCvProfile structured)
|
||||
{
|
||||
var penalties = 0;
|
||||
penalties += structured.Jobs.Count(job => LooksSuspiciousLocation(job.Location));
|
||||
penalties += structured.Education.Count(education => LooksSuspiciousLocation(education.Location));
|
||||
penalties += LooksSuspiciousLocation(structured.Contact.Location) ? 1 : 0;
|
||||
penalties += structured.Education.Count(education => string.IsNullOrWhiteSpace(education.QualificationLevel) && !string.IsNullOrWhiteSpace(education.Qualification));
|
||||
return Math.Max(0, 1 - (penalties * 0.12));
|
||||
}
|
||||
|
||||
private static string RenderMarkdownReport(CvBenchmarkSummary summary)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
"# CV benchmark report",
|
||||
string.Empty,
|
||||
$"- Generated: {summary.GeneratedAtUtc:O}",
|
||||
$"- Corpus root: `{summary.CorpusRoot}`",
|
||||
$"- Output root: `{summary.OutputRoot}`",
|
||||
$"- Files: {summary.TotalFiles}",
|
||||
$"- Average coverage: {summary.AverageCoverage:P0}",
|
||||
$"- Average confidence: {summary.AverageConfidence:P0}",
|
||||
$"- Average consistency: {summary.AverageConsistency:P0}",
|
||||
$"- Files with suspicious locations: {summary.FilesWithSuspiciousLocations}",
|
||||
$"- Missing approved fixtures: {summary.MissingApprovedFixtures}",
|
||||
string.Empty,
|
||||
"| File | Coverage | Confidence | Consistency | Suspicious locations | Fixture |",
|
||||
"|---|---:|---:|---:|---:|---|",
|
||||
};
|
||||
|
||||
lines.AddRange(summary.Entries.Select(entry =>
|
||||
$"| {entry.FileName} | {entry.CoverageScore:P0} | {entry.ConfidenceScore:P0} | {entry.ConsistencyScore:P0} | {entry.SuspiciousLocations.Count} | {entry.DiffSummary} |"));
|
||||
|
||||
lines.Add(string.Empty);
|
||||
lines.Add("## Notes");
|
||||
lines.Add("- `outputs/*.json` contains the latest normalized parser output for each CV.");
|
||||
lines.Add("- `candidate-fixtures/*.json` is created when no approved fixture exists yet.");
|
||||
lines.Add("- To build a regression baseline, review a candidate fixture and copy it into the approved-fixtures directory used by the runner.");
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray());
|
||||
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
private static bool LooksSuspiciousLocation(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
return value.Contains("Python", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains("Ruby", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains(" S A L E S ", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Any(char.IsDigit);
|
||||
}
|
||||
|
||||
private static string GuessContentType(string path)
|
||||
{
|
||||
return Path.GetExtension(path).ToLowerInvariant() switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".md" => "text/markdown",
|
||||
_ => "text/plain"
|
||||
};
|
||||
}
|
||||
|
||||
private static AppPaths CreatePaths(string outputRoot)
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Data:Root"] = tempRoot,
|
||||
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"),
|
||||
["Data:CvBenchmarksRoot"] = outputRoot,
|
||||
})
|
||||
.Build();
|
||||
|
||||
var env = new Mock<IHostEnvironment>();
|
||||
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
|
||||
return new AppPaths(config, env.Object);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -520,12 +521,7 @@ public sealed class GmailControllerTests
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
return TestHostFactory.CreateInMemoryDb();
|
||||
}
|
||||
|
||||
private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig()
|
||||
|
||||
@@ -3,12 +3,12 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -234,9 +234,232 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
Assert.Contains("Owned .NET API delivery across multiple services.", capturedContext);
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
|
||||
[Fact]
|
||||
public async Task Get_tailored_cv_draft_returns_legacy_fallback_when_no_structured_draft_exists()
|
||||
{
|
||||
var controller = new JobApplicationsController(db, summarizer, Mock.Of<IAppEmailSender>(), CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance);
|
||||
await using var db = CreateDb();
|
||||
var company = new Company
|
||||
{
|
||||
Name = "Acme",
|
||||
OwnerUserId = "user-1"
|
||||
};
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
TailoredCvText = "Existing tailored CV text",
|
||||
TailoredCvUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
|
||||
var result = await controller.GetTailoredCvDraft(job.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(ok.Value);
|
||||
Assert.True(payload.IsLegacyFallback);
|
||||
Assert.Equal("legacy-text", payload.TemplateId);
|
||||
Assert.Contains("Existing tailored CV text", payload.RenderedText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generate_and_save_tailored_cv_draft_persists_job_scoped_document_without_mutating_master_cv()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company
|
||||
{
|
||||
Name = "Acme",
|
||||
OwnerUserId = "user-1"
|
||||
};
|
||||
db.Companies.Add(company);
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
UserName = "user@example.test",
|
||||
Email = "user@example.test",
|
||||
CurrentCvProfileVersion = 7,
|
||||
ProfileCvText = "Built APIs and owned backend delivery.",
|
||||
ProfileCvStructureJson = """
|
||||
{
|
||||
"version": "1",
|
||||
"contact": {
|
||||
"fullName": "Demo User",
|
||||
"headline": "Backend Developer"
|
||||
},
|
||||
"summary": ["Backend-focused developer with API delivery experience."],
|
||||
"jobs": [
|
||||
{
|
||||
"title": "System Developer",
|
||||
"company": "Acme Consulting",
|
||||
"location": "Oslo",
|
||||
"start": "2021",
|
||||
"end": "2024",
|
||||
"isCurrent": false,
|
||||
"bullets": ["Owned .NET API delivery across multiple services."],
|
||||
"skills": [".NET", "SQL", "APIs"]
|
||||
}
|
||||
],
|
||||
"education": [],
|
||||
"skills": [".NET", "SQL", "APIs"],
|
||||
"languages": [{ "name": "English", "level": "Native" }],
|
||||
"interests": [],
|
||||
"otherSections": []
|
||||
}
|
||||
"""
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
Description = "Need .NET API ownership and strong SQL skills."
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var summarizer = new Mock<ISummarizerService>();
|
||||
summarizer
|
||||
.Setup(service => service.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>()))
|
||||
.ReturnsAsync((string instruction, string _, int __, int ___) =>
|
||||
{
|
||||
if (instruction.Contains("headline", StringComparison.OrdinalIgnoreCase)) return "Senior Backend Engineer";
|
||||
if (instruction.Contains("summary bullets", StringComparison.OrdinalIgnoreCase)) return "Led backend API delivery.\nImproved SQL-backed workflows.";
|
||||
return "Draft";
|
||||
});
|
||||
|
||||
var controller = CreateController(db, summarizer.Object, "user-1");
|
||||
var generateResult = await controller.GenerateTailoredCvDraft(job.Id, "ats", CancellationToken.None);
|
||||
var generateOk = Assert.IsType<OkObjectResult>(generateResult.Result);
|
||||
var generated = Assert.IsType<JobApplicationsController.TailoredCvDraftDto>(generateOk.Value);
|
||||
|
||||
Assert.False(generated.IsLegacyFallback);
|
||||
Assert.Equal(7, generated.CanonicalProfileVersion);
|
||||
Assert.Equal("Senior Backend Engineer", generated.Headline);
|
||||
Assert.Contains("Led backend API delivery.", generated.RenderedText);
|
||||
|
||||
var saveResult = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest(
|
||||
generated.TemplateId,
|
||||
"Principal Backend Engineer",
|
||||
new List<string> { "Own backend delivery for critical APIs." },
|
||||
new List<string> { ".NET", "SQL" },
|
||||
generated.Experience,
|
||||
generated.Education,
|
||||
generated.CustomSections,
|
||||
generated.RenderOptions,
|
||||
"edited"), CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(saveResult);
|
||||
|
||||
var savedDraft = await db.TailoredCvDrafts.SingleAsync();
|
||||
var savedJob = await db.JobApplications.SingleAsync();
|
||||
var savedUser = await db.Users.SingleAsync();
|
||||
|
||||
Assert.Equal("edited", savedDraft.Status);
|
||||
Assert.Equal(7, savedDraft.CanonicalProfileVersion);
|
||||
Assert.Contains("Principal Backend Engineer", savedJob.TailoredCvText);
|
||||
Assert.Equal("Built APIs and owned backend delivery.", savedUser.ProfileCvText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_and_export_tailored_cv_use_same_renderer_contract_and_profile_avatar_default()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
db.Users.Add(new ApplicationUser
|
||||
{
|
||||
Id = "user-1",
|
||||
UserName = "user@example.test",
|
||||
Email = "user@example.test",
|
||||
AvatarImageDataUrl = "data:image/png;base64,abc123"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
JobTitle = "Backend Developer",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-1",
|
||||
TailoredCvText = "Saved tailored CV"
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var renderer = new TestCvTemplateRenderer();
|
||||
var exporter = new TestCvPdfExporter();
|
||||
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1", renderer, exporter);
|
||||
var request = new JobApplicationsController.TailoredCvRenderRequest(
|
||||
"ats-minimal",
|
||||
"Backend Engineer",
|
||||
new List<string> { "Built APIs" },
|
||||
new List<string> { ".NET" },
|
||||
new List<TailoredCvExperienceItem>(),
|
||||
new List<TailoredCvEducationItem>(),
|
||||
new List<TailoredCvCustomSection>(),
|
||||
new TailoredCvRenderOptions { ShowPhoto = true, AccentColor = "#123456" },
|
||||
null,
|
||||
true);
|
||||
|
||||
var previewResult = await controller.PreviewTailoredCv(job.Id, request, CancellationToken.None);
|
||||
var ok = Assert.IsType<OkObjectResult>(previewResult.Result);
|
||||
var preview = Assert.IsType<JobApplicationsController.TailoredCvPreviewDto>(ok.Value);
|
||||
Assert.Equal("ats-minimal", preview.TemplateId);
|
||||
Assert.Equal("preview.pdf", preview.SuggestedFileName);
|
||||
Assert.Equal("data:image/png;base64,abc123", renderer.LastPhotoDataUrl);
|
||||
|
||||
var exportResult = await controller.ExportTailoredCvPdf(job.Id, request, CancellationToken.None);
|
||||
var file = Assert.IsType<FileContentResult>(exportResult);
|
||||
Assert.Equal("application/pdf", file.ContentType);
|
||||
Assert.Equal("preview.pdf", file.FileDownloadName);
|
||||
Assert.NotNull(exporter.LastRenderResult);
|
||||
Assert.Equal(preview.Html, exporter.LastRenderResult!.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_renderer_supports_named_variants()
|
||||
{
|
||||
var renderer = new CvTemplateRenderer();
|
||||
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||
{
|
||||
TemplateId = "harvard",
|
||||
Headline = "Product Manager",
|
||||
Summary = new List<string> { "Built and shipped product roadmaps." },
|
||||
SelectedSkills = new List<string> { "Strategy", "Stakeholder management" },
|
||||
Experience = new List<TailoredCvExperienceItem>
|
||||
{
|
||||
new() { Title = "Product Manager", Company = "Acme", Start = "2022", End = "2025", Bullets = new List<string> { "Launched new product line." } }
|
||||
}
|
||||
});
|
||||
|
||||
var harvard = renderer.Render(document, "harvard", "Andrew O'Sullivan", "Product Manager", "Acme", null);
|
||||
var auckland = renderer.Render(document, "auckland", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc");
|
||||
var edinburgh = renderer.Render(document, "edinburgh", "Andrew O'Sullivan", "Product Manager", "Acme", "data:image/png;base64,abc");
|
||||
|
||||
Assert.Equal("harvard", harvard.TemplateId);
|
||||
Assert.Contains("Template: ATS Minimal", renderer.Render(document, "ats-minimal", "Andrew O'Sullivan", "Product Manager", "Acme", null).Html);
|
||||
Assert.Contains("Andrew O'Sullivan", harvard.Html);
|
||||
Assert.Contains("sidebar", auckland.Html, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId, ICvTemplateRenderer? renderer = null, ICvPdfExporter? exporter = null)
|
||||
{
|
||||
var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId);
|
||||
var controller = new JobApplicationsController(
|
||||
db,
|
||||
summarizer,
|
||||
Mock.Of<IAppEmailSender>(),
|
||||
CreateUserManager(user).Object,
|
||||
NullLogger<JobApplicationsController>.Instance,
|
||||
renderer ?? new TestCvTemplateRenderer(),
|
||||
exporter ?? new TestCvPdfExporter());
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
@@ -252,26 +475,33 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
return TestHostFactory.CreateInMemoryDb();
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? user = null)
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
new NullLogger<UserManager<ApplicationUser>>());
|
||||
return TestHostFactory.CreateUserManager(user);
|
||||
}
|
||||
|
||||
private sealed class TestCvTemplateRenderer : ICvTemplateRenderer
|
||||
{
|
||||
public string? LastPhotoDataUrl { get; private set; }
|
||||
|
||||
public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
|
||||
{
|
||||
LastPhotoDataUrl = photoDataUrl;
|
||||
return new TailoredCvRenderResult(templateId ?? "ats-minimal", "preview.pdf", $"<html><body>{candidateName}|{jobTitle}|{companyName}|{document?.Headline}|{photoDataUrl}</body></html>");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCvPdfExporter : ICvPdfExporter
|
||||
{
|
||||
public TailoredCvRenderResult? LastRenderResult { get; private set; }
|
||||
|
||||
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRenderResult = renderResult;
|
||||
return Task.FromResult(new CvPdfArtifact("preview.pdf", "/tmp/preview.pdf", new byte[] { 1, 2, 3 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class JobApplicationsControllerTests
|
||||
Assert.NotNull(type);
|
||||
|
||||
var ctor = type!.GetConstructors().Single();
|
||||
var parameters = ctor.GetParameters().Select(x => x.Name).ToArray();
|
||||
var parameters = ctor.GetParameters().Select(x => x.Name).Where(x => x is not null).Select(x => x!).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("coverLetterText", parameters);
|
||||
Assert.Contains("notes", parameters);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -77,26 +78,11 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
||||
|
||||
private static Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<Microsoft.AspNetCore.Identity.IUserStore<ApplicationUser>>();
|
||||
return new Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!);
|
||||
return TestHostFactory.CreateUserManager();
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(x => x.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
return TestHostFactory.CreateInMemoryDb();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -122,27 +121,11 @@ public sealed class JobApplicationsFollowUpDraftTests
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
return TestHostFactory.CreateInMemoryDb();
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
new NullLogger<UserManager<ApplicationUser>>()
|
||||
);
|
||||
return TestHostFactory.CreateUserManager();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -53,26 +54,11 @@ public sealed class JobApplicationsMariaDraftTests
|
||||
|
||||
private static Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<Microsoft.AspNetCore.Identity.IUserStore<ApplicationUser>>();
|
||||
return new Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!);
|
||||
return TestHostFactory.CreateUserManager();
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(x => x.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
return TestHostFactory.CreateInMemoryDb();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -126,27 +126,11 @@ public sealed class JobApplicationsWorkflowSignalsTests
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
return TestHostFactory.CreateInMemoryDb();
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
new NullLogger<UserManager<ApplicationUser>>()
|
||||
);
|
||||
return TestHostFactory.CreateUserManager();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -14,14 +14,12 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.14" />
|
||||
<PackageReference Include="xunit.abstractions" Version="2.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\JobTrackerApi\JobTrackerApi.csproj" />
|
||||
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,14 +5,13 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -280,7 +279,7 @@ public sealed class ProfileCvControllerTests
|
||||
[Fact]
|
||||
public async Task Upload_populates_structured_fields_from_flattened_cv_when_ai_json_is_invalid()
|
||||
{
|
||||
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E D U C A T I O N E X T E N D E D D I P L O M A N V Q L E V E L 3 I N I C T 2012 - 2015 F O L L O W A B O U T M E Mid-level system developer with eight years of experience in UK local government, with expertise in full-stack development, backend, frontend and server administration. I N T E R E S T S I am interested in PC and board games, as well as cooking and learning new skills. E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK C O N T A C T Native English speaker, Norwegian level A2/B1.";
|
||||
var rawExtraction = "connor.babbington@cesnimda.co.uk cesnimda.co.uk +47 41 33 44 70 E D U C A T I O N E X T E N D E D D I P L O M A N V Q L E V E L 3 I N I C T 2012 - 2015 F O L L O W A B O U T M E Mid-level system developer with eight years of experience in UK local government, with expertise in full-stack development, backend, frontend and server administration. I N T E R E S T S I am interested in PC and board games, as well as cooking and learning new skills. E X P E R I E N C E S Y S T E M D E V E L O P E R 2015 - 2023 Developed and maintained multiple full-stack applications using C#, Python, Ruby on Rails, SQL, and JavaScript. + Warwickshire County Council, UK C O N T A C T Native English speaker, Norwegian level A2/B1, C#, SQL, and public speaking.";
|
||||
|
||||
var user = new ApplicationUser { Id = "user-1" };
|
||||
var userManager = CreateUserManager();
|
||||
@@ -320,9 +319,219 @@ public sealed class ProfileCvControllerTests
|
||||
Assert.Contains(structured.Interests, item => item.Contains("board games", StringComparison.OrdinalIgnoreCase) || item.Contains("cooking", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(structured.Languages, item => item.Name != null && item.Name.Equals("English", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(structured.Languages, item => item.Name != null && item.Name.StartsWith("Norwegian", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Equals("C#", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Equals("SQL", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(structured.Languages, item => item.Name != null && item.Name.Contains("public speaking", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(structured.Sections, section => section.Name == "General");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Structured_cv_normalization_keeps_human_languages_and_drops_skill_noise()
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize("""
|
||||
{
|
||||
"version": "1",
|
||||
"contact": {},
|
||||
"summary": [],
|
||||
"jobs": [],
|
||||
"education": [],
|
||||
"skills": [],
|
||||
"languages": [
|
||||
{ "name": "English", "level": "Native" },
|
||||
{ "name": "Native Norwegian speaker", "level": null },
|
||||
{ "name": "French", "level": null },
|
||||
{ "name": "C#", "level": "Advanced" },
|
||||
{ "name": "Leadership", "level": null }
|
||||
],
|
||||
"interests": [],
|
||||
"otherSections": []
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Collection(
|
||||
structured.Languages.OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase),
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("English", first.Name);
|
||||
Assert.Equal("Native", first.Level);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("Norwegian", second.Name);
|
||||
Assert.Equal("Native", second.Level);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Structured_cv_normalization_separates_job_title_company_and_tasks()
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize("""
|
||||
{
|
||||
"version": "1",
|
||||
"contact": {},
|
||||
"summary": [],
|
||||
"jobs": [
|
||||
{
|
||||
"title": "Acme Ltd",
|
||||
"company": "Senior Backend Developer",
|
||||
"location": "Oslo",
|
||||
"start": "2022",
|
||||
"end": "2024",
|
||||
"isCurrent": false,
|
||||
"bullets": [
|
||||
"Senior Backend Developer",
|
||||
"Acme Ltd",
|
||||
"2022 - 2024",
|
||||
"Built API integrations for recruiter workflows and reduced manual follow-up churn."
|
||||
],
|
||||
"skills": [".NET", "SQL"]
|
||||
},
|
||||
{
|
||||
"title": "Lead Engineer at Northwind Council",
|
||||
"company": null,
|
||||
"location": "Remote",
|
||||
"start": "2020",
|
||||
"end": "Present",
|
||||
"isCurrent": true,
|
||||
"bullets": [
|
||||
"Led platform delivery across case-management and reporting surfaces.",
|
||||
"Skills: C#, SQL"
|
||||
],
|
||||
"skills": ["C#", "SQL"]
|
||||
}
|
||||
],
|
||||
"education": [],
|
||||
"skills": [],
|
||||
"languages": [],
|
||||
"interests": [],
|
||||
"otherSections": []
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Collection(
|
||||
structured.Jobs,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("Senior Backend Developer", first.Title);
|
||||
Assert.Equal("Acme Ltd", first.Company);
|
||||
Assert.Equal(new[] { "Built API integrations for recruiter workflows and reduced manual follow-up churn." }, first.Bullets);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("Lead Engineer", second.Title);
|
||||
Assert.Equal("Northwind Council", second.Company);
|
||||
Assert.Equal(new[] { "Led platform delivery across case-management and reporting surfaces." }, second.Bullets);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Structured_cv_normalization_hardens_contact_links_locations_and_dates()
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize("""
|
||||
{
|
||||
"version": "1",
|
||||
"contact": {
|
||||
"location": "Python,Ruby",
|
||||
"website": "https://cesnimda.co.uk/about",
|
||||
"linkedin": "linkedin.com/in/demo-user?trk=foo"
|
||||
},
|
||||
"summary": [],
|
||||
"jobs": [
|
||||
{
|
||||
"title": "System Developer",
|
||||
"company": "Warwickshire County Council",
|
||||
"location": "Warwickshire, England, UK",
|
||||
"start": "Sept 2023",
|
||||
"end": "1/1/2024",
|
||||
"isCurrent": false,
|
||||
"bullets": ["Built APIs"],
|
||||
"skills": []
|
||||
},
|
||||
{
|
||||
"title": "Developer",
|
||||
"company": "Demo Co",
|
||||
"location": "Remote 123",
|
||||
"start": "Spring 2024",
|
||||
"end": "Later",
|
||||
"isCurrent": false,
|
||||
"bullets": ["Kept services running"],
|
||||
"skills": []
|
||||
},
|
||||
{
|
||||
"title": "Developer",
|
||||
"company": "Demo Co",
|
||||
"location": "Warwickshire College, UK S A L E S R E P R E S E N T A T I V E",
|
||||
"start": "2021",
|
||||
"end": "2022",
|
||||
"isCurrent": false,
|
||||
"bullets": ["Managed account handovers"],
|
||||
"skills": []
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"qualification": "Warwickshire College",
|
||||
"institution": "ICT Level 3",
|
||||
"location": "Warwickshire College, UK S A L E S R E P R E S E N T A T I V E",
|
||||
"start": "2012",
|
||||
"end": "2015",
|
||||
"details": []
|
||||
}
|
||||
],
|
||||
"skills": [],
|
||||
"languages": [],
|
||||
"interests": [],
|
||||
"otherSections": []
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Null(structured.Contact.Location);
|
||||
Assert.Equal("cesnimda.co.uk", structured.Contact.Website);
|
||||
Assert.Equal("https://www.linkedin.com/in/demo-user", structured.Contact.LinkedIn);
|
||||
Assert.Equal("Warwickshire, England, UK", structured.Jobs[0].Location);
|
||||
Assert.Equal("Sept 2023", structured.Jobs[0].Start);
|
||||
Assert.Equal("1/1/2024", structured.Jobs[0].End);
|
||||
Assert.Null(structured.Jobs[1].Location);
|
||||
Assert.Null(structured.Jobs[1].Start);
|
||||
Assert.Null(structured.Jobs[1].End);
|
||||
Assert.Equal("Warwickshire College, UK", structured.Jobs[2].Location);
|
||||
Assert.Equal("ICT Level 3", structured.Education[0].Qualification);
|
||||
Assert.Equal("Warwickshire College", structured.Education[0].Institution);
|
||||
Assert.Equal("Warwickshire College, UK", structured.Education[0].Location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rewrite_section_can_target_saved_job_context_and_whole_cv()
|
||||
{
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "Professional Summary\nBuilt backend systems." };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Harvard template", StringComparison.Ordinal) && instruction.Contains("Senior Backend Engineer", StringComparison.Ordinal)), It.Is<string>(text => text.Contains("Professional Summary", StringComparison.Ordinal)), 1800, 400))
|
||||
.ReturnsAsync("Professional Summary\nSharper backend platform positioning.");
|
||||
|
||||
await using var db = CreateDb();
|
||||
db.Companies.Add(new Company { Id = 7, Name = "Acme Systems", OwnerUserId = "user-1" });
|
||||
db.JobApplications.Add(new JobApplication { Id = 42, JobTitle = "Senior Backend Engineer", Description = "Build API integrations and platform workflows.", OwnerUserId = "user-1", CompanyId = 7, Status = "Waiting", DateApplied = DateTime.UtcNow });
|
||||
await db.SaveChangesAsync();
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest
|
||||
{
|
||||
Style = "harvard",
|
||||
JobApplicationId = JsonDocument.Parse("42").RootElement.Clone(),
|
||||
TemplateId = "harvard",
|
||||
});
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
Assert.Contains("Sharper backend platform positioning", json);
|
||||
Assert.Contains("harvard", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("42", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_returns_structured_cv_and_persists_it()
|
||||
{
|
||||
@@ -425,6 +634,119 @@ public sealed class ProfileCvControllerTests
|
||||
Assert.Equal("Connor Babbington", structured.Contact.FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_uses_classifier_fallback_when_plain_text_has_no_real_sections()
|
||||
{
|
||||
var source = "Senior Platform Engineer at Atlas Systems\nOslo\n2019 - Present\nBuilt event-driven APIs and migration tooling.\n\nPython\nSQL\nAzure";
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
|
||||
.ReturnsAsync("not-json");
|
||||
|
||||
var classifier = new Mock<ICvAiClassifier>();
|
||||
classifier
|
||||
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Atlas Systems", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CvBlockClassificationResult("Work Experience", 0.93, "job block", "Senior Platform Engineer", "Atlas Systems", "Oslo", "2019", "Present", new List<string> { "Built event-driven APIs and migration tooling." }, null, new List<string> { "Python", "SQL" }));
|
||||
classifier
|
||||
.Setup(x => x.ClassifyBlockAsync(It.Is<string>(block => block.Contains("Python", StringComparison.Ordinal)), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CvBlockClassificationResult("Skills", 0.88, "skills block", null, null, null, null, null, new List<string>(), null, new List<string> { "Python", "SQL", "Azure" }));
|
||||
|
||||
await using var db = CreateDb();
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
Assert.Contains("Senior Platform Engineer", json);
|
||||
Assert.Contains("Atlas Systems", json);
|
||||
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var matchedJob = structured.Jobs.FirstOrDefault(job => job.Title == "Senior Platform Engineer");
|
||||
Assert.NotNull(matchedJob);
|
||||
Assert.Contains("Atlas Systems", matchedJob!.Company ?? string.Empty, StringComparison.Ordinal);
|
||||
Assert.Contains("Python", structured.Skills);
|
||||
Assert.Contains("SQL", structured.Skills);
|
||||
Assert.Equal("classifier", structured.Metadata.Fields["jobs[0].title"].Method);
|
||||
Assert.Equal("block-1", structured.Metadata.Fields["jobs[0].title"].SourceBlockId);
|
||||
classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.AtLeastOnce());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_uses_classifier_fallback_for_education_blocks_without_real_sections()
|
||||
{
|
||||
var source = "BSc Computer Science\nUniversity of Oslo\nOslo\n2016 - 2019\nGraduated with focus on distributed systems.";
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
|
||||
.ReturnsAsync("not-json");
|
||||
|
||||
var classifier = new Mock<ICvAiClassifier>();
|
||||
classifier
|
||||
.Setup(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CvBlockClassificationResult("Education", 0.87, "education block", "BSc Computer Science", "University of Oslo", "Oslo", "2016", "2019", new List<string> { "Graduated with focus on distributed systems." }, null, null));
|
||||
|
||||
await using var db = CreateDb();
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
Assert.Contains("BSc Computer Science", json);
|
||||
Assert.Contains("University of Oslo", json);
|
||||
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
Assert.Single(structured.Education);
|
||||
Assert.Equal("BSc Computer Science", structured.Education[0].Qualification);
|
||||
Assert.Equal("University of Oslo", structured.Education[0].Institution);
|
||||
Assert.Equal("classifier", structured.Metadata.Fields["education[0].qualification"].Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parse_keeps_general_fallback_when_classifier_returns_nothing()
|
||||
{
|
||||
var source = "Independent consultant building internal platforms for public-sector clients across data, delivery, and migration work.";
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = source };
|
||||
var userManager = CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
var aiService = new Mock<ISummarizerService>();
|
||||
aiService
|
||||
.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)), source, 3200, 900))
|
||||
.ReturnsAsync("not-json");
|
||||
|
||||
var classifier = new Mock<ICvAiClassifier>();
|
||||
classifier
|
||||
.Setup(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((CvBlockClassificationResult?)null);
|
||||
|
||||
await using var db = CreateDb();
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths, classifier.Object);
|
||||
|
||||
var result = await controller.Parse(new ProfileCvController.ParseCvRequest(source));
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
Assert.Contains("Professional Summary", json);
|
||||
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
Assert.Contains(structured.Summary, item => item.Contains("Independent consultant", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.NotNull(user.ProfileCvStructureJson);
|
||||
classifier.Verify(x => x.ClassifyBlockAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.AtLeastOnce());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_accepts_markdown_cv_and_saves_text()
|
||||
{
|
||||
@@ -468,9 +790,9 @@ public sealed class ProfileCvControllerTests
|
||||
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
|
||||
}
|
||||
|
||||
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths)
|
||||
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null)
|
||||
{
|
||||
return new ProfileCvController(userManager, aiService, db, paths)
|
||||
return new ProfileCvController(userManager, aiService, db, paths, null, cvAiClassifier ?? NoOpCvAiClassifier.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
@@ -478,12 +800,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
private static JobTrackerContext CreateDb(string userId = "user-1")
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(x => x.UserId).Returns(userId);
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
return TestHostFactory.CreateInMemoryDb(userId);
|
||||
}
|
||||
|
||||
private static AppPaths CreatePaths()
|
||||
@@ -506,17 +823,6 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
new NullLogger<UserManager<ApplicationUser>>()
|
||||
);
|
||||
return TestHostFactory.CreateUserManager();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace JobTrackerApi.Tests.TestSupport;
|
||||
|
||||
public static class TestHostFactory
|
||||
{
|
||||
// Keep the EF-backed controller tests on the same minimal setup so they fail for product
|
||||
// reasons, not because each file drifted into a slightly different fake host configuration.
|
||||
public static JobTrackerContext CreateInMemoryDb(string userId = "user-1")
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns(userId);
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
}
|
||||
|
||||
public static Mock<UserManager<ApplicationUser>> CreateUserManager(ApplicationUser? lookupUser = null)
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
var manager = new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object,
|
||||
Options.Create(new IdentityOptions()),
|
||||
new PasswordHasher<ApplicationUser>(),
|
||||
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
new NullLogger<UserManager<ApplicationUser>>()
|
||||
);
|
||||
|
||||
if (lookupUser is not null)
|
||||
{
|
||||
manager
|
||||
.Setup(x => x.FindByIdAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string id) => lookupUser.Id == id ? lookupUser : null);
|
||||
}
|
||||
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public sealed class AdminSystemController : ControllerBase
|
||||
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 CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc);
|
||||
public sealed record SystemStatusDto(
|
||||
string Environment,
|
||||
string ContentRoot,
|
||||
@@ -64,6 +65,50 @@ public sealed class AdminSystemController : ControllerBase
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private EmailSettingsSnapshot BuildFallbackEmailSettingsSnapshot()
|
||||
{
|
||||
var host = (_cfg["Email:SmtpHost"] ?? string.Empty).Trim();
|
||||
var user = (_cfg["Email:SmtpUser"] ?? string.Empty).Trim();
|
||||
var password = (_cfg["Email:SmtpPassword"] ?? string.Empty).Trim();
|
||||
var from = (_cfg["Email:From"] ?? user).Trim();
|
||||
var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim();
|
||||
var port = _cfg.GetValue("Email:SmtpPort", 587);
|
||||
if (port <= 0) port = 587;
|
||||
var enableSsl = _cfg.GetValue("Email:SmtpEnableSsl", true);
|
||||
var timeoutMs = _cfg.GetValue("Email:SmtpTimeoutMs", 15000);
|
||||
if (timeoutMs <= 0) timeoutMs = 15000;
|
||||
var enabled = _cfg.GetValue("Email:Enabled", false);
|
||||
|
||||
return new EmailSettingsSnapshot(
|
||||
Enabled: enabled,
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: user,
|
||||
Password: password,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
EnableSsl: enableSsl,
|
||||
TimeoutMs: timeoutMs,
|
||||
UsesOverrides: false,
|
||||
HasPassword: !string.IsNullOrWhiteSpace(password));
|
||||
}
|
||||
|
||||
private EmailSettingsAdminDto BuildFallbackEmailSettings(string? reason = null)
|
||||
{
|
||||
var snapshot = BuildFallbackEmailSettingsSnapshot();
|
||||
return new EmailSettingsAdminDto(
|
||||
Enabled: snapshot.Enabled,
|
||||
Host: snapshot.Host,
|
||||
Port: snapshot.Port,
|
||||
User: snapshot.User,
|
||||
From: snapshot.From,
|
||||
FromName: string.IsNullOrWhiteSpace(reason) ? snapshot.FromName : $"{snapshot.FromName} (fallback)",
|
||||
EnableSsl: snapshot.EnableSsl,
|
||||
TimeoutMs: snapshot.TimeoutMs,
|
||||
UsesOverrides: snapshot.UsesOverrides,
|
||||
HasPassword: snapshot.HasPassword);
|
||||
}
|
||||
|
||||
[HttpPost("ai/probe")]
|
||||
[HttpPost("summarizer/probe")]
|
||||
public async Task<IActionResult> RunSummarizerProbe(CancellationToken cancellationToken)
|
||||
@@ -75,7 +120,14 @@ public sealed class AdminSystemController : ControllerBase
|
||||
[HttpGet("email-settings")]
|
||||
public async Task<ActionResult<EmailSettingsAdminDto>> GetEmailSettings(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
|
||||
try
|
||||
{
|
||||
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Ok(BuildFallbackEmailSettings(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("email-settings")]
|
||||
@@ -86,6 +138,22 @@ public sealed class AdminSystemController : ControllerBase
|
||||
return Ok(await _emailSettings.UpdateAsync(request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("cv-benchmark")]
|
||||
public async Task<ActionResult<CvBenchmarkStatusDto>> GetCvBenchmarkStatus(CancellationToken cancellationToken)
|
||||
{
|
||||
var indexPath = Path.Combine(_paths.CvBenchmarksRoot, "index.json");
|
||||
var reportPath = Path.Combine(_paths.CvBenchmarksRoot, "report.md");
|
||||
var indexJson = System.IO.File.Exists(indexPath) ? await System.IO.File.ReadAllTextAsync(indexPath, cancellationToken) : null;
|
||||
var reportMarkdown = System.IO.File.Exists(reportPath) ? await System.IO.File.ReadAllTextAsync(reportPath, cancellationToken) : null;
|
||||
var lastUpdated = new[]
|
||||
{
|
||||
System.IO.File.Exists(indexPath) ? System.IO.File.GetLastWriteTimeUtc(indexPath) : (DateTime?)null,
|
||||
System.IO.File.Exists(reportPath) ? System.IO.File.GetLastWriteTimeUtc(reportPath) : (DateTime?)null,
|
||||
}.Where(value => value.HasValue).Select(value => value!.Value).DefaultIfEmpty().Max();
|
||||
|
||||
return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc))));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -124,6 +192,14 @@ public sealed class AdminSystemController : ControllerBase
|
||||
GpuName: null,
|
||||
OcrAvailable: false,
|
||||
OcrLanguages: null,
|
||||
OllamaConfigured: null,
|
||||
OllamaReachable: null,
|
||||
OllamaModel: null,
|
||||
OllamaModelAvailable: null,
|
||||
OllamaVersion: null,
|
||||
OllamaInstalledModels: Array.Empty<string>(),
|
||||
OllamaLoadedModels: Array.Empty<string>(),
|
||||
OllamaLoadedCount: 0,
|
||||
HealthLatencyMs: null,
|
||||
ProbeLatencyMs: null,
|
||||
LastProbeAt: null,
|
||||
@@ -207,7 +283,15 @@ public sealed class AdminSystemController : ControllerBase
|
||||
|
||||
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
|
||||
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
|
||||
var emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken);
|
||||
EmailSettingsSnapshot emailSettings;
|
||||
try
|
||||
{
|
||||
emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
emailSettings = BuildFallbackEmailSettingsSnapshot();
|
||||
}
|
||||
|
||||
return Ok(new SystemStatusDto(
|
||||
Environment: _env.EnvironmentName,
|
||||
|
||||
@@ -5,6 +5,7 @@ using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.JobImport;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -20,14 +21,26 @@ namespace JobTrackerApi.Controllers
|
||||
private readonly IAppEmailSender _email;
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly ILogger<JobApplicationsController> _logger;
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger)
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
||||
{
|
||||
_db = db;
|
||||
_summarizer = summarizer;
|
||||
_email = email;
|
||||
_users = users;
|
||||
_logger = logger;
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
}
|
||||
|
||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||
{
|
||||
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
|
||||
}
|
||||
}
|
||||
|
||||
private string? CurrentUserId =>
|
||||
@@ -153,6 +166,275 @@ namespace JobTrackerApi.Controllers
|
||||
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
||||
}
|
||||
|
||||
private static string ComputeGenerationContextHash(string value)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable<string> matchedTags)
|
||||
{
|
||||
var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)))
|
||||
.ToLowerInvariant();
|
||||
var score = 0;
|
||||
foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag)))
|
||||
{
|
||||
if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4;
|
||||
}
|
||||
score += Math.Min(job.Bullets.Count, 4);
|
||||
return score;
|
||||
}
|
||||
|
||||
private static List<string> SelectTailoredSkills(StructuredCvProfile structured, string jobText)
|
||||
{
|
||||
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var prioritized = structured.Skills
|
||||
.Select(skill => new
|
||||
{
|
||||
Skill = skill,
|
||||
Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0
|
||||
})
|
||||
.OrderByDescending(entry => entry.Score)
|
||||
.ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => entry.Skill)
|
||||
.ToList();
|
||||
|
||||
if (prioritized.Count == 0)
|
||||
{
|
||||
prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
return prioritized.Take(10).ToList();
|
||||
}
|
||||
|
||||
private static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job)
|
||||
{
|
||||
var text = (job.TailoredCvText ?? string.Empty).Trim();
|
||||
var document = new TailoredCvDocument
|
||||
{
|
||||
Headline = job.JobTitle,
|
||||
CustomSections = string.IsNullOrWhiteSpace(text)
|
||||
? new List<TailoredCvCustomSection>()
|
||||
: new List<TailoredCvCustomSection>
|
||||
{
|
||||
new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Legacy draft text",
|
||||
Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
|
||||
}
|
||||
}
|
||||
};
|
||||
return TailoredCvDraftJson.Normalize(document);
|
||||
}
|
||||
|
||||
private static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft)
|
||||
{
|
||||
var document = TailoredCvDraftJson.FromDraft(draft);
|
||||
return new TailoredCvDraftDto(
|
||||
draft.Id,
|
||||
draft.CanonicalProfileVersion,
|
||||
draft.TemplateId,
|
||||
document.Headline,
|
||||
document.Summary,
|
||||
document.SelectedSkills,
|
||||
document.Experience,
|
||||
document.Education,
|
||||
document.CustomSections,
|
||||
document.RenderOptions,
|
||||
draft.GenerationContextHash,
|
||||
draft.LastGeneratedAtUtc,
|
||||
draft.LastEditedAtUtc,
|
||||
draft.Status,
|
||||
TailoredCvDraftJson.RenderPlainText(document),
|
||||
false);
|
||||
}
|
||||
|
||||
private static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job)
|
||||
{
|
||||
var document = BuildLegacyTailoredCvFallback(job);
|
||||
return new TailoredCvDraftDto(
|
||||
null,
|
||||
null,
|
||||
"legacy-text",
|
||||
document.Headline,
|
||||
document.Summary,
|
||||
document.SelectedSkills,
|
||||
document.Experience,
|
||||
document.Education,
|
||||
document.CustomSections,
|
||||
document.RenderOptions,
|
||||
null,
|
||||
null,
|
||||
job.TailoredCvUpdatedAt,
|
||||
string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import",
|
||||
TailoredCvDraftJson.RenderPlainText(document),
|
||||
true);
|
||||
}
|
||||
|
||||
private static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job)
|
||||
{
|
||||
var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job);
|
||||
if (request is null)
|
||||
{
|
||||
return baseDocument;
|
||||
}
|
||||
|
||||
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||
{
|
||||
TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal",
|
||||
Headline = request.Headline ?? baseDocument.Headline,
|
||||
Summary = request.Summary ?? baseDocument.Summary,
|
||||
SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills,
|
||||
Experience = request.Experience ?? baseDocument.Experience,
|
||||
Education = request.Education ?? baseDocument.Education,
|
||||
CustomSections = request.CustomSections ?? baseDocument.CustomSections,
|
||||
RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions,
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TailoredCvDraft?> FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken);
|
||||
}
|
||||
|
||||
private TailoredCvRenderResult RenderTailoredCv(JobApplication job, TailoredCvDocument document, ApplicationUser? user, string? photoDataUrl)
|
||||
{
|
||||
return _cvTemplateRenderer.Render(
|
||||
document,
|
||||
document.TemplateId,
|
||||
GetPreferredDisplayName(user),
|
||||
job.JobTitle,
|
||||
job.Company?.Name,
|
||||
photoDataUrl);
|
||||
}
|
||||
|
||||
public sealed record TailoredCvPreviewDto(string TemplateId, string Html, string SuggestedFileName);
|
||||
public sealed record TailoredCvRenderRequest(
|
||||
string? TemplateId,
|
||||
string? Headline,
|
||||
List<string>? Summary,
|
||||
List<string>? SelectedSkills,
|
||||
List<TailoredCvExperienceItem>? Experience,
|
||||
List<TailoredCvEducationItem>? Education,
|
||||
List<TailoredCvCustomSection>? CustomSections,
|
||||
TailoredCvRenderOptions? RenderOptions,
|
||||
string? PhotoDataUrl,
|
||||
bool? UseProfileAvatar);
|
||||
|
||||
private async Task<TailoredCvDraft> UpsertGeneratedTailoredCvDraftAsync(JobApplication job, ApplicationUser user, string? mode, CancellationToken cancellationToken)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
var structuredCvContext = BuildStructuredCvContext(user);
|
||||
var generationContext = $@"Job title: {job.JobTitle}
|
||||
Company: {job.Company?.Name}
|
||||
Status: {job.Status}
|
||||
Generation mode: {mode ?? "default"}
|
||||
|
||||
Job context:
|
||||
{jobText}
|
||||
|
||||
Canonical profile:
|
||||
{structuredCvContext}
|
||||
";
|
||||
|
||||
var headline = await _summarizer.SummarizeSectionAsync(
|
||||
"Write a short, role-specific CV headline for this candidate. Keep it factual, scannable, and under 12 words. Return headline text only.",
|
||||
generationContext,
|
||||
48,
|
||||
24);
|
||||
|
||||
var summary = await BuildListFromAiAsync(
|
||||
$"Write 4 short CV summary bullets tailored to this job. Use only facts supported by the canonical profile. Keep each line tight and credible. {BuildPackageModeInstruction(mode)}",
|
||||
generationContext,
|
||||
cancellationToken,
|
||||
fallbackPrefix: job.JobTitle);
|
||||
|
||||
var selectedSkills = SelectTailoredSkills(structured, jobText);
|
||||
var matchedTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var experience = structured.Jobs
|
||||
.OrderByDescending(entry => ScoreTailoredExperience(entry, matchedTags))
|
||||
.ThenByDescending(entry => entry.IsCurrent)
|
||||
.Take(4)
|
||||
.Select(entry => new TailoredCvExperienceItem
|
||||
{
|
||||
Title = entry.Title,
|
||||
Company = entry.Company,
|
||||
Location = entry.Location,
|
||||
Start = entry.Start,
|
||||
End = entry.End,
|
||||
IsCurrent = entry.IsCurrent,
|
||||
Bullets = entry.Bullets.Take(4).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var education = structured.Education
|
||||
.Take(3)
|
||||
.Select(entry => new TailoredCvEducationItem
|
||||
{
|
||||
Qualification = entry.Qualification,
|
||||
Institution = entry.Institution,
|
||||
Location = entry.Location,
|
||||
Start = entry.Start,
|
||||
End = entry.End,
|
||||
Details = entry.Details.Take(3).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var customSections = new List<TailoredCvCustomSection>();
|
||||
if (structured.Languages.Count > 0)
|
||||
{
|
||||
customSections.Add(new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Languages",
|
||||
Items = structured.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||
});
|
||||
}
|
||||
customSections.AddRange(structured.OtherSections.Take(2).Select(section => new TailoredCvCustomSection
|
||||
{
|
||||
Title = section.Title,
|
||||
Items = section.Items.Take(4).ToList(),
|
||||
}));
|
||||
|
||||
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||
{
|
||||
TemplateId = "ats-minimal",
|
||||
Headline = string.IsNullOrWhiteSpace(headline) ? structured.Contact.Headline ?? job.JobTitle : headline.Trim(),
|
||||
Summary = summary,
|
||||
SelectedSkills = selectedSkills,
|
||||
Experience = experience,
|
||||
Education = education,
|
||||
CustomSections = customSections,
|
||||
RenderOptions = new TailoredCvRenderOptions(),
|
||||
});
|
||||
|
||||
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == job.Id, cancellationToken)
|
||||
?? new TailoredCvDraft
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
JobApplicationId = job.Id,
|
||||
};
|
||||
|
||||
draft.OwnerUserId = user.Id;
|
||||
draft.CanonicalProfileVersion = user.CurrentCvProfileVersion;
|
||||
draft.GenerationContextHash = ComputeGenerationContextHash(generationContext);
|
||||
draft.LastGeneratedAtUtc = DateTimeOffset.UtcNow;
|
||||
draft.Status = "generated";
|
||||
TailoredCvDraftJson.ApplyToDraft(draft, document);
|
||||
|
||||
if (draft.Id == 0)
|
||||
{
|
||||
_db.TailoredCvDrafts.Add(draft);
|
||||
}
|
||||
|
||||
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return draft;
|
||||
}
|
||||
|
||||
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
|
||||
{
|
||||
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
||||
@@ -1729,6 +2011,33 @@ namespace JobTrackerApi.Controllers
|
||||
string? CoverLetterDraft,
|
||||
string? RecruiterMessageDraft);
|
||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||
public sealed record TailoredCvDraftDto(
|
||||
int? Id,
|
||||
int? CanonicalProfileVersion,
|
||||
string TemplateId,
|
||||
string? Headline,
|
||||
List<string> Summary,
|
||||
List<string> SelectedSkills,
|
||||
List<TailoredCvExperienceItem> Experience,
|
||||
List<TailoredCvEducationItem> Education,
|
||||
List<TailoredCvCustomSection> CustomSections,
|
||||
TailoredCvRenderOptions RenderOptions,
|
||||
string? GenerationContextHash,
|
||||
DateTimeOffset? LastGeneratedAtUtc,
|
||||
DateTimeOffset? LastEditedAtUtc,
|
||||
string Status,
|
||||
string RenderedText,
|
||||
bool IsLegacyFallback);
|
||||
public sealed record SaveTailoredCvDraftRequest(
|
||||
string? TemplateId,
|
||||
string? Headline,
|
||||
List<string>? Summary,
|
||||
List<string>? SelectedSkills,
|
||||
List<TailoredCvExperienceItem>? Experience,
|
||||
List<TailoredCvEducationItem>? Education,
|
||||
List<TailoredCvCustomSection>? CustomSections,
|
||||
TailoredCvRenderOptions? RenderOptions,
|
||||
string? Status);
|
||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
|
||||
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
|
||||
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
|
||||
@@ -2029,6 +2338,155 @@ Candidate master CV:
|
||||
return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/tailored-cv-draft")]
|
||||
public async Task<ActionResult<TailoredCvDraftDto>> GetTailoredCvDraft([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var draft = await _db.TailoredCvDrafts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken);
|
||||
|
||||
return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft));
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/tailored-cv-preview")]
|
||||
public async Task<ActionResult<TailoredCvPreviewDto>> PreviewTailoredCv([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
|
||||
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
|
||||
request.TemplateId,
|
||||
request.Headline,
|
||||
request.Summary,
|
||||
request.SelectedSkills,
|
||||
request.Experience,
|
||||
request.Education,
|
||||
request.CustomSections,
|
||||
request.RenderOptions,
|
||||
draft?.Status ?? "generated"), draft, job);
|
||||
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
|
||||
? request!.PhotoDataUrl
|
||||
: request?.UseProfileAvatar == false
|
||||
? null
|
||||
: user.AvatarImageDataUrl;
|
||||
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
||||
return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName));
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/export-tailored-cv-pdf")]
|
||||
public async Task<IActionResult> ExportTailoredCvPdf([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
|
||||
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
|
||||
request.TemplateId,
|
||||
request.Headline,
|
||||
request.Summary,
|
||||
request.SelectedSkills,
|
||||
request.Experience,
|
||||
request.Education,
|
||||
request.CustomSections,
|
||||
request.RenderOptions,
|
||||
draft?.Status ?? "generated"), draft, job);
|
||||
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
|
||||
? request!.PhotoDataUrl
|
||||
: request?.UseProfileAvatar == false
|
||||
? null
|
||||
: user.AvatarImageDataUrl;
|
||||
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
||||
var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken);
|
||||
return File(artifact.Bytes, "application/pdf", artifact.FileName);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/generate-tailored-cv-draft")]
|
||||
public async Task<ActionResult<TailoredCvDraftDto>> GenerateTailoredCvDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText))
|
||||
{
|
||||
return BadRequest("Add your profile CV text on the Profile page before generating a tailored CV draft.");
|
||||
}
|
||||
|
||||
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
if (structured.Summary.Count == 0 && structured.Jobs.Count == 0 && structured.Skills.Count == 0)
|
||||
{
|
||||
return BadRequest("Build and review your canonical structured CV on the Profile page before generating a tailored draft.");
|
||||
}
|
||||
|
||||
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken);
|
||||
return Ok(ToTailoredCvDraftDto(draft));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/tailored-cv-draft")]
|
||||
public async Task<IActionResult> SaveTailoredCvDraft([FromRoute] int id, [FromBody] SaveTailoredCvDraftRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var user = await GetCurrentUserAsync(cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken)
|
||||
?? new TailoredCvDraft
|
||||
{
|
||||
OwnerUserId = user.Id,
|
||||
JobApplicationId = id,
|
||||
CanonicalProfileVersion = user.CurrentCvProfileVersion,
|
||||
};
|
||||
|
||||
var document = new TailoredCvDocument
|
||||
{
|
||||
TemplateId = request.TemplateId ?? draft.TemplateId,
|
||||
Headline = request.Headline,
|
||||
Summary = request.Summary ?? new List<string>(),
|
||||
SelectedSkills = request.SelectedSkills ?? new List<string>(),
|
||||
Experience = request.Experience ?? new List<TailoredCvExperienceItem>(),
|
||||
Education = request.Education ?? new List<TailoredCvEducationItem>(),
|
||||
CustomSections = request.CustomSections ?? new List<TailoredCvCustomSection>(),
|
||||
RenderOptions = request.RenderOptions ?? new TailoredCvRenderOptions(),
|
||||
};
|
||||
|
||||
draft.OwnerUserId = user.Id;
|
||||
draft.CanonicalProfileVersion ??= user.CurrentCvProfileVersion;
|
||||
draft.Status = string.IsNullOrWhiteSpace(request.Status) ? "edited" : request.Status.Trim();
|
||||
draft.LastEditedAtUtc = DateTimeOffset.UtcNow;
|
||||
TailoredCvDraftJson.ApplyToDraft(draft, document);
|
||||
|
||||
if (draft.Id == 0)
|
||||
{
|
||||
_db.TailoredCvDrafts.Add(draft);
|
||||
}
|
||||
|
||||
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
||||
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/tailored-cv")]
|
||||
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -61,21 +61,48 @@ public sealed class ProfileCvController : ControllerBase
|
||||
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly ISummarizerService _aiService;
|
||||
private readonly ICvAiClassifier _cvAiClassifier;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly AppPaths _paths;
|
||||
private readonly ILogger<ProfileCvController> _logger;
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
|
||||
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths)
|
||||
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
||||
{
|
||||
_users = users;
|
||||
_aiService = aiService;
|
||||
_cvAiClassifier = cvAiClassifier ?? NoOpCvAiClassifier.Instance;
|
||||
_db = db;
|
||||
_paths = paths;
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<ProfileCvController>.Instance;
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
}
|
||||
|
||||
public sealed record RewriteSectionRequest(string SectionName, string? Style, string? TargetRole);
|
||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||
{
|
||||
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RewriteSectionRequest
|
||||
{
|
||||
public string? SectionName { get; set; }
|
||||
public string? Style { get; set; }
|
||||
public string? TargetRole { get; set; }
|
||||
public JsonElement? JobApplicationId { get; set; }
|
||||
public string? TemplateId { get; set; }
|
||||
public string? SourceText { get; set; }
|
||||
}
|
||||
public sealed record ParseCvRequest(string? Text);
|
||||
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
|
||||
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
|
||||
|
||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
public sealed record CvExtractionRunListItem(
|
||||
int Id,
|
||||
string Trigger,
|
||||
@@ -271,24 +298,143 @@ public sealed class ProfileCvController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rewriting a section.");
|
||||
|
||||
var sectionName = string.IsNullOrWhiteSpace(request.SectionName) ? "Professional Summary" : request.SectionName.Trim();
|
||||
var style = string.IsNullOrWhiteSpace(request.Style) ? "balanced" : request.Style.Trim();
|
||||
var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var sourceText = string.IsNullOrWhiteSpace(request.SourceText)
|
||||
? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim())
|
||||
: request.SourceText.Trim();
|
||||
if (string.IsNullOrWhiteSpace(sourceText) && structuredCv.Sections.Count == 0)
|
||||
{
|
||||
return BadRequest("Add or import CV text before rewriting your CV.");
|
||||
}
|
||||
|
||||
var sectionName = NormalizeRewriteSectionName(request.SectionName);
|
||||
var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim();
|
||||
var templateId = NormalizeTemplateId(request.TemplateId ?? style);
|
||||
var targetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? null : request.TargetRole.Trim();
|
||||
var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId);
|
||||
var jobContext = jobApplicationId.HasValue
|
||||
? await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Include(job => job.Company)
|
||||
.Where(job => job.Id == jobApplicationId.Value && job.OwnerUserId == user.Id)
|
||||
.Select(job => new
|
||||
{
|
||||
job.Id,
|
||||
job.JobTitle,
|
||||
job.Description,
|
||||
job.TranslatedDescription,
|
||||
job.ShortSummary,
|
||||
job.Notes,
|
||||
job.JobUrl,
|
||||
job.Status,
|
||||
CompanyName = job.Company != null ? job.Company.Name : null,
|
||||
RecruiterName = job.Company != null ? job.Company.RecruiterName : null,
|
||||
RecruiterEmail = job.Company != null ? job.Company.RecruiterEmail : null
|
||||
})
|
||||
.FirstOrDefaultAsync(HttpContext.RequestAborted)
|
||||
: null;
|
||||
|
||||
var effectiveTargetRole = targetRole ?? jobContext?.JobTitle;
|
||||
var rewriteSource = BuildRewriteSourceText(sectionName, sourceText, structuredCv);
|
||||
var templateGuidance = DescribeRewriteTemplate(templateId);
|
||||
var roleGuidance = jobContext is not null
|
||||
? $"Target this toward the saved job '{jobContext.JobTitle}' at '{jobContext.CompanyName ?? "Unknown company"}'. Use the full job record below to sharpen wording without inventing facts.\nJob status: {jobContext.Status}\nJob summary: {jobContext.ShortSummary ?? "-"}\nJob description: {jobContext.Description ?? "-"}\nTranslated description: {jobContext.TranslatedDescription ?? "-"}\nNotes: {jobContext.Notes ?? "-"}\nJob URL: {jobContext.JobUrl ?? "-"}\nRecruiter name: {jobContext.RecruiterName ?? "-"}\nRecruiter email: {jobContext.RecruiterEmail ?? "-"}"
|
||||
: effectiveTargetRole is not null
|
||||
? $"Target role: {effectiveTargetRole}. Keep it broadly reusable but clearly aligned to that role family."
|
||||
: "Keep it broadly reusable for future tailoring.";
|
||||
|
||||
var subject = sectionName is null ? "this CV" : $"the '{sectionName}' section of this CV";
|
||||
var instruction = $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} Return only the rewritten text with clean headings and bullets when useful.";
|
||||
var rewritten = await _aiService.SummarizeSectionAsync(
|
||||
$"Rewrite only the '{sectionName}' section of this CV. Preserve facts, avoid inventing employers or metrics, and output only the rewritten section text. Style: {style}. {(targetRole is not null ? $"Target role: {targetRole}." : "Make it broadly reusable for future tailoring.")}",
|
||||
user.ProfileCvText,
|
||||
900,
|
||||
180);
|
||||
instruction,
|
||||
rewriteSource,
|
||||
sectionName is null ? 1800 : 900,
|
||||
sectionName is null ? 400 : 180);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rewritten))
|
||||
{
|
||||
return BadRequest("The AI service could not rewrite that CV section right now.");
|
||||
_logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount}",
|
||||
sectionName ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "The AI service could not rewrite your CV right now.");
|
||||
}
|
||||
|
||||
return Ok(new { sectionName, style, targetRole, text = rewritten.Trim() });
|
||||
return Ok(new
|
||||
{
|
||||
sectionName,
|
||||
style,
|
||||
templateId,
|
||||
targetRole = effectiveTargetRole,
|
||||
jobApplicationId = jobContext?.Id,
|
||||
text = rewritten.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("templates")]
|
||||
public ActionResult<IEnumerable<CvTemplateDescriptor>> GetTemplates()
|
||||
{
|
||||
return Ok(GetCvTemplateDescriptors());
|
||||
}
|
||||
|
||||
[HttpPost("rewrite-preview")]
|
||||
public async Task<ActionResult<ProfileCvPreviewDto>> BuildRewritePreview([FromBody] RewriteSectionRequest request)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var sourceText = string.IsNullOrWhiteSpace(request.SourceText)
|
||||
? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim())
|
||||
: request.SourceText.Trim();
|
||||
if (string.IsNullOrWhiteSpace(sourceText) && structuredCv.Sections.Count == 0)
|
||||
{
|
||||
return BadRequest("Add or import CV text before rewriting your CV.");
|
||||
}
|
||||
|
||||
var sectionName = NormalizeRewriteSectionName(request.SectionName);
|
||||
var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim();
|
||||
var templateId = NormalizeTemplateId(request.TemplateId ?? style);
|
||||
var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId);
|
||||
var job = jobApplicationId.HasValue
|
||||
? await _db.JobApplications.AsNoTracking().Include(job => job.Company)
|
||||
.FirstOrDefaultAsync(job => job.Id == jobApplicationId.Value && job.OwnerUserId == user.Id, HttpContext.RequestAborted)
|
||||
: null;
|
||||
var effectiveTargetRole = string.IsNullOrWhiteSpace(request.TargetRole)
|
||||
? job?.JobTitle
|
||||
: request.TargetRole.Trim();
|
||||
|
||||
var rewriteResult = await RewriteSection(request);
|
||||
if (rewriteResult is not OkObjectResult ok) return StatusCode((rewriteResult as ObjectResult)?.StatusCode ?? 500, (rewriteResult as ObjectResult)?.Value);
|
||||
|
||||
var rewrittenText = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)).RootElement.GetProperty("text").GetString()?.Trim() ?? string.Empty;
|
||||
var baseText = string.IsNullOrWhiteSpace(sourceText)
|
||||
? string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}"))
|
||||
: sourceText!;
|
||||
var fullText = sectionName is null ? rewrittenText : ReplaceOrAppendCvSection(baseText, sectionName, rewrittenText);
|
||||
var previewStructured = await BuildStructuredCvAsync(fullText, HttpContext.RequestAborted);
|
||||
var document = BuildMasterCvDocument(previewStructured, templateId, effectiveTargetRole, job?.JobTitle, job?.Company?.Name);
|
||||
var rendered = RenderProfileCv(document, user, effectiveTargetRole ?? user.DisplayName ?? "General CV", job?.Company?.Name);
|
||||
|
||||
return Ok(new ProfileCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName, fullText, rewrittenText, sectionName, previewStructured, document, effectiveTargetRole, job?.Id));
|
||||
}
|
||||
|
||||
[HttpPost("export-pdf")]
|
||||
public async Task<IActionResult> ExportProfileCvPdf([FromBody] RewriteSectionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var previewResult = await BuildRewritePreview(request);
|
||||
if (previewResult.Result is ObjectResult errorResult && errorResult.StatusCode >= 400)
|
||||
{
|
||||
return StatusCode(errorResult.StatusCode ?? 500, errorResult.Value);
|
||||
}
|
||||
|
||||
var ok = previewResult.Result as OkObjectResult;
|
||||
if (ok?.Value is not ProfileCvPreviewDto preview)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "The CV preview could not be prepared for PDF export.");
|
||||
}
|
||||
|
||||
var artifact = await _cvPdfExporter.ExportAsync(new TailoredCvRenderResult(preview.TemplateId, preview.SuggestedFileName, preview.Html), cancellationToken);
|
||||
return File(artifact.Bytes, "application/pdf", artifact.FileName);
|
||||
}
|
||||
|
||||
[HttpPost("parse")]
|
||||
@@ -335,10 +481,198 @@ public sealed class ProfileCvController : ControllerBase
|
||||
return Ok(new { improved = true, characters = user.ProfileCvText.Length, text = user.ProfileCvText, structuredCv, sections = structuredCv.Sections, extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
|
||||
}
|
||||
|
||||
private static string BuildRewriteSourceText(string? sectionName, string? sourceText, StructuredCvProfile structuredCv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sectionName))
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(sourceText)
|
||||
? sourceText.Trim()
|
||||
: string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}"));
|
||||
}
|
||||
|
||||
var matchingSection = structuredCv.Sections.FirstOrDefault(section => string.Equals(section.Name, sectionName, StringComparison.OrdinalIgnoreCase));
|
||||
if (matchingSection is not null && !string.IsNullOrWhiteSpace(matchingSection.Content))
|
||||
{
|
||||
return $"## {matchingSection.Name}\n{matchingSection.Content}";
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(sourceText)
|
||||
? sourceText.Trim()
|
||||
: string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}"));
|
||||
}
|
||||
|
||||
private static string DescribeRewriteTemplate(string templateId)
|
||||
{
|
||||
return templateId.ToLowerInvariant() switch
|
||||
{
|
||||
"harvard" => "Harvard template: refined, traditional, strong hierarchy, restrained and credible.",
|
||||
"auckland" => "Auckland template: modern sidebar layout, crisp highlights, confident but readable.",
|
||||
"edinburgh" => "Edinburgh template: polished editorial layout with stronger visual personality and premium spacing.",
|
||||
"monarch" => "Monarch template: executive, premium, high-contrast emphasis on summary and leadership signals.",
|
||||
"fjord" => "Fjord template: calm technical layout with clear information density and practical scanability.",
|
||||
_ => "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor."
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeTemplateId(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"base" => "ats-minimal",
|
||||
"legacy-text" => "ats-minimal",
|
||||
"harvard" => "harvard",
|
||||
"auckland" => "auckland",
|
||||
"edinburgh" => "edinburgh",
|
||||
"monarch" => "monarch",
|
||||
"fjord" => "fjord",
|
||||
_ => "ats-minimal"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeRewriteSectionName(string? value)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) return null;
|
||||
return SectionAliases.TryGetValue(trimmed, out var canonical) ? canonical : trimmed;
|
||||
}
|
||||
|
||||
private static int? ParseFlexibleNullableInt(JsonElement? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value.Value.ValueKind == JsonValueKind.Number && value.Value.TryGetInt32(out var number)) return number;
|
||||
if (value.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = value.Value.GetString();
|
||||
if (int.TryParse(raw, out var parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReplaceOrAppendCvSection(string source, string sectionName, string sectionDraft)
|
||||
{
|
||||
var trimmedSource = (source ?? string.Empty).Trim();
|
||||
var trimmedDraft = (sectionDraft ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedDraft)) return trimmedSource;
|
||||
if (string.IsNullOrWhiteSpace(trimmedSource)) return $"## {sectionName}\n{trimmedDraft}";
|
||||
|
||||
var normalizedHeading = sectionName.Trim().ToLowerInvariant();
|
||||
var headingPattern = new Regex(@"^(##\s+|#\s+)?(?<name>[A-Z][A-Za-z &/]+):?\s*$", RegexOptions.Multiline);
|
||||
var matches = headingPattern.Matches(trimmedSource).ToList();
|
||||
var targetIndex = matches.FindIndex(match => string.Equals(match.Groups["name"].Value.Trim(), normalizedHeading, StringComparison.OrdinalIgnoreCase));
|
||||
if (targetIndex < 0)
|
||||
{
|
||||
return $"{trimmedSource}\n\n## {sectionName}\n{trimmedDraft}".Trim();
|
||||
}
|
||||
|
||||
var start = matches[targetIndex].Index;
|
||||
var end = targetIndex + 1 < matches.Count ? matches[targetIndex + 1].Index : trimmedSource.Length;
|
||||
var before = trimmedSource[..start].TrimEnd();
|
||||
var after = trimmedSource[end..].TrimStart();
|
||||
return string.Join("\n\n", new[] { before, $"## {sectionName}\n{trimmedDraft}", after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CvTemplateDescriptor> GetCvTemplateDescriptors()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }),
|
||||
new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List<string> { "Classic serif rhythm", "Strong chronology", "Credible tone" }),
|
||||
new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List<string> { "Sidebar details", "Compact highlights", "Modern contrast" }),
|
||||
new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List<string> { "Premium spacing", "Stronger personality", "Readable density" }),
|
||||
new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List<string> { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }),
|
||||
new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List<string> { "Technical depth", "Dense but readable", "Practical hierarchy" }),
|
||||
};
|
||||
}
|
||||
|
||||
private TailoredCvRenderResult RenderProfileCv(TailoredCvDocument document, ApplicationUser user, string targetRole, string? companyName)
|
||||
{
|
||||
var candidateName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.DisplayName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.UserName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.Email?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = "Your Name";
|
||||
return _cvTemplateRenderer.Render(document, document.TemplateId, candidateName!, targetRole, companyName, user.AvatarImageDataUrl);
|
||||
}
|
||||
|
||||
private static TailoredCvDocument BuildMasterCvDocument(StructuredCvProfile structuredCv, string templateId, string? targetRole, string? fallbackHeadline, string? companyName)
|
||||
{
|
||||
var normalized = StructuredCvProfileJson.Normalize(structuredCv);
|
||||
var customSections = new List<TailoredCvCustomSection>();
|
||||
if (normalized.Certifications.Count > 0)
|
||||
{
|
||||
customSections.Add(new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Certifications",
|
||||
Items = normalized.Certifications.Select(certification => string.Join(" | ", new[] { certification.Name, certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||
});
|
||||
}
|
||||
if (normalized.Projects.Count > 0)
|
||||
{
|
||||
customSections.Add(new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Projects",
|
||||
Items = normalized.Projects.Select(project => string.Join(" | ", new[] { project.Name, project.Role, project.Location, FormatDateRangeForSection(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||
});
|
||||
}
|
||||
if (normalized.Languages.Count > 0)
|
||||
{
|
||||
customSections.Add(new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Languages",
|
||||
Items = normalized.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||
});
|
||||
}
|
||||
customSections.AddRange(normalized.OtherSections.Select(section => new TailoredCvCustomSection { Title = section.Title, Items = section.Items }));
|
||||
|
||||
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||
{
|
||||
TemplateId = templateId,
|
||||
Headline = normalized.Contact.Headline ?? targetRole ?? fallbackHeadline ?? companyName,
|
||||
Summary = normalized.Summary,
|
||||
SelectedSkills = normalized.Skills,
|
||||
Experience = normalized.Jobs.Select(job => new TailoredCvExperienceItem
|
||||
{
|
||||
Title = job.Title,
|
||||
Company = job.Company,
|
||||
Location = job.Location,
|
||||
Start = job.Start,
|
||||
End = job.End,
|
||||
IsCurrent = job.IsCurrent,
|
||||
Bullets = job.Bullets,
|
||||
}).ToList(),
|
||||
Education = normalized.Education.Select(education => new TailoredCvEducationItem
|
||||
{
|
||||
Qualification = education.Qualification,
|
||||
QualificationLevel = education.QualificationLevel,
|
||||
Institution = education.Institution,
|
||||
Location = education.Location,
|
||||
Start = education.Start,
|
||||
End = education.End,
|
||||
Details = education.Details,
|
||||
}).ToList(),
|
||||
CustomSections = customSections,
|
||||
RenderOptions = new TailoredCvRenderOptions
|
||||
{
|
||||
ShowPhoto = true,
|
||||
AccentColor = templateId switch
|
||||
{
|
||||
"harvard" => "brick",
|
||||
"auckland" => "emerald",
|
||||
"edinburgh" => "plum",
|
||||
"monarch" => "#7c2d12",
|
||||
"fjord" => "#0f4c5c",
|
||||
_ => "slate",
|
||||
},
|
||||
SectionOrder = new List<string> { "summary", "skills", "experience", "education", "custom" },
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<StructuredCvProfile> BuildStructuredCvAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
var parseSource = NormalizeTextForStructuredParsing(text);
|
||||
var fallbackSections = ParseSections(parseSource)
|
||||
var parsedSections = ParseSections(parseSource)
|
||||
.Select(section => new StructuredCvSection
|
||||
{
|
||||
Name = section.Name,
|
||||
@@ -346,6 +680,22 @@ public sealed class ProfileCvController : ControllerBase
|
||||
WordCount = CountWords(section.Content),
|
||||
})
|
||||
.ToList();
|
||||
var hasRealSections = parsedSections.Any(section => !string.Equals(section.Name, "General", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
List<ClassifiedCvBlock> classifiedBlocks = new();
|
||||
List<StructuredCvSection> fallbackSections = parsedSections;
|
||||
StructuredCvProfile? classifierFallback = null;
|
||||
|
||||
if (!hasRealSections)
|
||||
{
|
||||
classifiedBlocks = await ClassifyBlocksAsync(parseSource, cancellationToken);
|
||||
var hasMeaningfulClassifierStructure = classifiedBlocks.Any(block => !string.Equals(block.SectionName, "General", StringComparison.OrdinalIgnoreCase));
|
||||
if (hasMeaningfulClassifierStructure)
|
||||
{
|
||||
fallbackSections = BuildSectionsFromClassifiedBlocks(classifiedBlocks);
|
||||
classifierFallback = BuildStructuredCvFromClassifiedBlocks(classifiedBlocks);
|
||||
}
|
||||
}
|
||||
|
||||
var sectionFallback = StructuredCvProfileJson.FromSections(fallbackSections);
|
||||
AnnotateStructuredCv(sectionFallback, "repair", 0.56);
|
||||
@@ -353,6 +703,10 @@ public sealed class ProfileCvController : ControllerBase
|
||||
AnnotateStructuredCv(heuristicFallback, "deterministic", 0.68);
|
||||
heuristicFallback.Sections = new List<StructuredCvSection>();
|
||||
var fallback = StructuredCvProfileJson.Merge(heuristicFallback, sectionFallback);
|
||||
if (classifierFallback is not null)
|
||||
{
|
||||
fallback = StructuredCvProfileJson.Merge(classifierFallback, fallback);
|
||||
}
|
||||
fallback.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(fallback.Contact.Email);
|
||||
var extracted = await TryExtractStructuredCvAsync(parseSource, cancellationToken);
|
||||
var merged = StructuredCvProfileJson.Merge(extracted, fallback);
|
||||
@@ -512,7 +866,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
private async Task<StructuredCvProfile?> TryExtractStructuredCvAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
var structuredJson = await _aiService.SummarizeSectionAsync(
|
||||
"Extract this CV into structured JSON. Return only valid JSON with this exact top-level shape: { \"version\": \"1\", \"contact\": { \"fullName\": string|null, \"headline\": string|null, \"email\": string|null, \"phone\": string|null, \"location\": string|null, \"website\": string|null, \"linkedin\": string|null }, \"summary\": string[], \"jobs\": [{ \"title\": string|null, \"company\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"isCurrent\": boolean, \"bullets\": string[], \"skills\": string[] }], \"education\": [{ \"qualification\": string|null, \"institution\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"details\": string[] }], \"skills\": string[], \"languages\": [{ \"name\": string|null, \"level\": string|null, \"notes\": string|null }], \"interests\": string[], \"otherSections\": [{ \"title\": string|null, \"items\": string[] }] }. Preserve facts only. Do not invent anything. If a field is unknown, use null or an empty array. Keep wording close to the source. Put unmatched content in otherSections.",
|
||||
"Extract this CV into structured JSON. Return only valid JSON with this exact top-level shape: { \"version\": \"1\", \"contact\": { \"fullName\": string|null, \"headline\": string|null, \"email\": string|null, \"phone\": string|null, \"location\": string|null, \"website\": string|null, \"linkedin\": string|null }, \"summary\": string[], \"jobs\": [{ \"title\": string|null, \"company\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"isCurrent\": boolean, \"bullets\": string[], \"skills\": string[] }], \"education\": [{ \"qualification\": string|null, \"qualificationLevel\": \"Secondary\"|\"Diploma/Certificate\"|\"Bachelor\"|\"Master\"|\"PhD\"|\"Other\"|null, \"institution\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"details\": string[] }], \"certifications\": [{ \"name\": string|null, \"issuer\": string|null, \"location\": string|null, \"date\": string|null, \"details\": string[] }], \"projects\": [{ \"name\": string|null, \"role\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"bullets\": string[], \"skills\": string[] }], \"skills\": string[], \"languages\": [{ \"name\": string|null, \"level\": string|null, \"notes\": string|null }], \"interests\": string[], \"otherSections\": [{ \"title\": string|null, \"items\": string[] }] }. Preserve facts only. Do not invent anything. If a field is unknown, use null or an empty array. Keep wording close to the source. Profile location should only be the candidate's current/home location. Education location must be the institution location. Work location must be employer/job location. Never place skill lists such as Python or Ruby into location fields. Preserve the original qualification text in education. Set qualificationLevel to the normalized enum when you can infer it, otherwise null. Put unmatched content in otherSections.",
|
||||
text,
|
||||
3200,
|
||||
900);
|
||||
@@ -631,9 +985,20 @@ public sealed class ProfileCvController : ControllerBase
|
||||
profile.Contact.Website = NullIfWhitespace(Regex.Match(rawSource, @"\b(?:https?://)?(?:www\.)?[A-Z0-9.-]+\.[A-Z]{2,}(?:/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]*)?", RegexOptions.IgnoreCase).Value);
|
||||
profile.Contact.LinkedIn = NullIfWhitespace(Regex.Match(rawSource, @"(?:linkedin(?:\.com)?/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]+)", RegexOptions.IgnoreCase).Value);
|
||||
profile.Contact.FullName = GuessFullName(rawSource) ?? GuessFullNameFromEmail(profile.Contact.Email);
|
||||
profile.Contact.Location = NullIfWhitespace(Regex.Match(rawSource, @"\b[A-Z][a-z]+(?:[\s-][A-Z][a-z]+)*,\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b").Value);
|
||||
|
||||
var sections = ParseSections(normalized);
|
||||
var contactSection = sections.FirstOrDefault(section => section.Name == "Contact");
|
||||
if (!string.IsNullOrWhiteSpace(contactSection.Content))
|
||||
{
|
||||
var contactFallback = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Contact", Content = contactSection.Content } });
|
||||
profile.Contact.Location = contactFallback.Contact.Location;
|
||||
profile.Contact.Headline ??= contactFallback.Contact.Headline;
|
||||
}
|
||||
else
|
||||
{
|
||||
profile.Contact.Location = NullIfWhitespace(Regex.Match(rawSource, @"\b[A-Z][a-z]+(?:[\s-][A-Z][a-z]+)*(?:,\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*){1,2}\b").Value);
|
||||
}
|
||||
|
||||
var summarySection = sections.FirstOrDefault(section => section.Name == "Professional Summary");
|
||||
var flattenedSummary = Regex.Match(
|
||||
rawSource,
|
||||
@@ -688,6 +1053,18 @@ public sealed class ProfileCvController : ControllerBase
|
||||
profile.Education = ParseEducationHeuristically(educationSection.Content);
|
||||
}
|
||||
|
||||
var certificationsSection = sections.FirstOrDefault(section => section.Name == "Certifications");
|
||||
if (!string.IsNullOrWhiteSpace(certificationsSection.Content))
|
||||
{
|
||||
profile.Certifications = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Certifications", Content = certificationsSection.Content } }).Certifications;
|
||||
}
|
||||
|
||||
var projectsSection = sections.FirstOrDefault(section => section.Name == "Projects");
|
||||
if (!string.IsNullOrWhiteSpace(projectsSection.Content))
|
||||
{
|
||||
profile.Projects = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Projects", Content = projectsSection.Content } }).Projects;
|
||||
}
|
||||
|
||||
var experienceSection = sections.FirstOrDefault(section => section.Name == "Work Experience");
|
||||
if (!string.IsNullOrWhiteSpace(experienceSection.Content))
|
||||
{
|
||||
@@ -729,12 +1106,19 @@ public sealed class ProfileCvController : ControllerBase
|
||||
private static List<StructuredCvLanguage> ParseLanguagesHeuristically(string content)
|
||||
{
|
||||
var languages = new List<StructuredCvLanguage>();
|
||||
foreach (Match match in Regex.Matches(content, @"\b(English|Norwegian|Norsk|German|French|Spanish|Swedish|Danish)\b(?:[^\n.,;:]*?\b(Native|Fluent|Advanced|Intermediate|Beginner|A1|A2|B1|B2|C1|C2|Native speaker)\b)?", RegexOptions.IgnoreCase))
|
||||
var candidates = Regex.Split(content.Replace("\r\n", "\n"), @"[\n,;]+|(?<=[.!?])\s+")
|
||||
.Select(item => item.Trim())
|
||||
.Where(item => item.Length > 1);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var name = NullIfWhitespace(match.Groups[1].Value);
|
||||
var level = NullIfWhitespace(match.Groups[2].Value);
|
||||
if (name is null) continue;
|
||||
languages.Add(new StructuredCvLanguage { Name = name, Level = level });
|
||||
var level = HumanLanguageCatalog.ExtractLevel(candidate);
|
||||
if (level is null) continue;
|
||||
|
||||
foreach (var name in HumanLanguageCatalog.ExtractLanguageNames(candidate))
|
||||
{
|
||||
languages.Add(new StructuredCvLanguage { Name = name, Level = level });
|
||||
}
|
||||
}
|
||||
|
||||
return languages
|
||||
@@ -765,6 +1149,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
items.Add(new StructuredCvEducation
|
||||
{
|
||||
Qualification = TitleCasePreservingAcronyms(qualificationLine),
|
||||
QualificationLevel = InferQualificationLevel(qualificationLine),
|
||||
Institution = TitleCasePreservingAcronyms(institutionLine),
|
||||
Start = dateMatch.Success ? dateMatch.Groups[1].Value : null,
|
||||
End = dateMatch.Success ? dateMatch.Groups[2].Value : null,
|
||||
@@ -817,6 +1202,18 @@ public sealed class ProfileCvController : ControllerBase
|
||||
return string.Join(" ", words);
|
||||
}
|
||||
|
||||
private static string? InferQualificationLevel(string? value)
|
||||
{
|
||||
var candidate = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidate)) return null;
|
||||
if (Regex.IsMatch(candidate, @"\b(phd|doctorate|dphil)\b", RegexOptions.IgnoreCase)) return "PhD";
|
||||
if (Regex.IsMatch(candidate, @"\b(master(?:'s)?|msc|m\.sc|ma|m\.a|mba|meng)\b", RegexOptions.IgnoreCase)) return "Master";
|
||||
if (Regex.IsMatch(candidate, @"\b(bachelor(?:'s)?|bsc|b\.sc|ba|b\.a|beng|degree)\b", RegexOptions.IgnoreCase)) return "Bachelor";
|
||||
if (Regex.IsMatch(candidate, @"\b(diploma|certificate|certification|nvq|btec|level\s*\d+|apprenticeship|associate)\b", RegexOptions.IgnoreCase)) return "Diploma/Certificate";
|
||||
if (Regex.IsMatch(candidate, @"\b(gcse|a-?level|secondary|high school)\b", RegexOptions.IgnoreCase)) return "Secondary";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
private static int CountWords(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return 0;
|
||||
@@ -872,6 +1269,279 @@ public sealed class ProfileCvController : ControllerBase
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<StructuredCvSection> BuildSectionsFromClassifiedBlocks(List<ClassifiedCvBlock> classifiedBlocks)
|
||||
{
|
||||
var sectionBuckets = new List<StructuredCvSection>();
|
||||
foreach (var block in classifiedBlocks)
|
||||
{
|
||||
var existing = sectionBuckets.FirstOrDefault(section => section.Name == block.SectionName);
|
||||
if (existing is null)
|
||||
{
|
||||
sectionBuckets.Add(new StructuredCvSection { Name = block.SectionName, Content = block.Content, WordCount = CountWords(block.Content) });
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Content = $"{existing.Content}\n\n{block.Content}".Trim();
|
||||
existing.WordCount = CountWords(existing.Content);
|
||||
}
|
||||
}
|
||||
|
||||
return sectionBuckets.Where(section => !string.IsNullOrWhiteSpace(section.Content)).ToList();
|
||||
}
|
||||
|
||||
private static StructuredCvProfile BuildStructuredCvFromClassifiedBlocks(List<ClassifiedCvBlock> classifiedBlocks)
|
||||
{
|
||||
var profile = new StructuredCvProfile();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var summary = new List<string>();
|
||||
var skills = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var block in classifiedBlocks)
|
||||
{
|
||||
switch (block.SectionName)
|
||||
{
|
||||
case "Professional Summary":
|
||||
foreach (var item in (block.Classification?.Summary is { Count: > 0 }
|
||||
? block.Classification.Summary
|
||||
: SplitClassifierContent(block.Content, 5)))
|
||||
{
|
||||
summary.Add(item);
|
||||
}
|
||||
ApplyClassifierFieldMetadata(profile, "summary", summary.FirstOrDefault(), block, now);
|
||||
break;
|
||||
case "Skills":
|
||||
foreach (var item in (block.Classification?.Skills is { Count: > 0 }
|
||||
? block.Classification.Skills.Where(skill => !string.IsNullOrWhiteSpace(skill)).Select(skill => skill.Trim())
|
||||
: SplitClassifierSkills(block.Content)))
|
||||
{
|
||||
skills.Add(item);
|
||||
}
|
||||
ApplyClassifierFieldMetadata(profile, "skills", skills.FirstOrDefault(), block, now);
|
||||
break;
|
||||
case "Work Experience":
|
||||
var job = BuildJobFromClassifiedBlock(block);
|
||||
if (job is not null)
|
||||
{
|
||||
var index = profile.Jobs.Count;
|
||||
profile.Jobs.Add(job);
|
||||
ApplyClassifierFieldMetadata(profile, $"jobs[{index}].title", job.Title, block, now);
|
||||
ApplyClassifierFieldMetadata(profile, $"jobs[{index}].company", job.Company, block, now);
|
||||
ApplyClassifierFieldMetadata(profile, $"jobs[{index}].location", job.Location, block, now);
|
||||
}
|
||||
break;
|
||||
case "Education":
|
||||
var education = BuildEducationFromClassifiedBlock(block);
|
||||
if (education is not null)
|
||||
{
|
||||
var index = profile.Education.Count;
|
||||
profile.Education.Add(education);
|
||||
ApplyClassifierFieldMetadata(profile, $"education[{index}].qualification", education.Qualification, block, now);
|
||||
ApplyClassifierFieldMetadata(profile, $"education[{index}].institution", education.Institution, block, now);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (!string.IsNullOrWhiteSpace(block.Content))
|
||||
{
|
||||
profile.OtherSections.Add(new StructuredCvOtherSection
|
||||
{
|
||||
Title = block.SectionName,
|
||||
Items = SplitClassifierContent(block.Content, 6)
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
profile.Summary = summary.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
profile.Skills = skills.ToList();
|
||||
profile.Sections = BuildSectionsFromClassifiedBlocks(classifiedBlocks);
|
||||
|
||||
var averageConfidence = classifiedBlocks
|
||||
.Select(block => block.Classification?.Confidence)
|
||||
.Where(value => value.HasValue)
|
||||
.Select(value => value!.Value)
|
||||
.DefaultIfEmpty(0.74)
|
||||
.Average();
|
||||
AnnotateStructuredCv(profile, "classifier", averageConfidence);
|
||||
return StructuredCvProfileJson.Normalize(profile);
|
||||
}
|
||||
|
||||
private static StructuredCvJob? BuildJobFromClassifiedBlock(ClassifiedCvBlock block)
|
||||
{
|
||||
var classification = block.Classification;
|
||||
if (classification is null) return null;
|
||||
|
||||
var bullets = classification.Bullets is { Count: > 0 }
|
||||
? classification.Bullets.Where(bullet => !string.IsNullOrWhiteSpace(bullet)).Select(bullet => bullet.Trim()).ToList()
|
||||
: SplitClassifierContent(block.OriginalBlock, 6);
|
||||
|
||||
var job = new StructuredCvJob
|
||||
{
|
||||
Title = NullIfWhitespace(classification.Title),
|
||||
Company = NullIfWhitespace(classification.Company),
|
||||
Location = NullIfWhitespace(classification.Location),
|
||||
Start = NullIfWhitespace(classification.Start),
|
||||
End = NullIfWhitespace(classification.End),
|
||||
IsCurrent = string.Equals(classification.End, "Present", StringComparison.OrdinalIgnoreCase) || string.Equals(classification.End, "Current", StringComparison.OrdinalIgnoreCase),
|
||||
Bullets = bullets,
|
||||
Skills = classification.Skills is { Count: > 0 }
|
||||
? classification.Skills.Where(skill => !string.IsNullOrWhiteSpace(skill)).Select(skill => skill.Trim()).ToList()
|
||||
: SplitClassifierSkills(block.OriginalBlock)
|
||||
};
|
||||
|
||||
return StructuredCvProfileJson.Normalize(new StructuredCvProfile { Jobs = new List<StructuredCvJob> { job } }).Jobs.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static StructuredCvEducation? BuildEducationFromClassifiedBlock(ClassifiedCvBlock block)
|
||||
{
|
||||
var classification = block.Classification;
|
||||
if (classification is null) return null;
|
||||
|
||||
var education = new StructuredCvEducation
|
||||
{
|
||||
Qualification = NullIfWhitespace(classification.Title),
|
||||
Institution = NullIfWhitespace(classification.Company),
|
||||
Location = NullIfWhitespace(classification.Location),
|
||||
Start = NullIfWhitespace(classification.Start),
|
||||
End = NullIfWhitespace(classification.End),
|
||||
Details = classification.Bullets is { Count: > 0 }
|
||||
? classification.Bullets.Where(bullet => !string.IsNullOrWhiteSpace(bullet)).Select(bullet => bullet.Trim()).ToList()
|
||||
: SplitClassifierContent(block.OriginalBlock, 5)
|
||||
};
|
||||
|
||||
return StructuredCvProfileJson.Normalize(new StructuredCvProfile { Education = new List<StructuredCvEducation> { education } }).Education.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static List<string> SplitClassifierContent(string content, int limit)
|
||||
{
|
||||
return content
|
||||
.Replace("\r\n", "\n")
|
||||
.Split(new[] { '\n', '•' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.SelectMany(line => line.Contains(". ", StringComparison.Ordinal)
|
||||
? Regex.Split(line, @"(?<=[.!?])\s+")
|
||||
: new[] { line })
|
||||
.Select(item => item.Trim().TrimStart('-', '•', '*', '+', ' '))
|
||||
.Where(item => item.Length > 2)
|
||||
.Take(limit)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<string> SplitClassifierSkills(string content)
|
||||
{
|
||||
return content
|
||||
.Replace("\r\n", "\n")
|
||||
.Split(new[] { '\n', ',', ';', '•' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(item => item.Trim().TrimStart('-', '•', '*', '+', ' '))
|
||||
.Where(item => item.Length > 1 && item.Length <= 48 && !LooksLikeDateLikeValue(item) && !item.Contains('@'))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool LooksLikeDateLikeValue(string value)
|
||||
{
|
||||
return Regex.IsMatch(value, @"^(?:\d{4}|(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}|Present|Current)(?:\s*[-–]\s*(?:\d{4}|(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}|Present|Current))?$", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static void ApplyClassifierFieldMetadata(StructuredCvProfile profile, string key, string? value, ClassifiedCvBlock block, DateTimeOffset now)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return;
|
||||
|
||||
profile.Metadata.Fields[key] = new StructuredCvFieldMetadata
|
||||
{
|
||||
Confidence = block.Classification?.Confidence ?? 0.74,
|
||||
Method = "classifier",
|
||||
SourceSnippet = block.OriginalBlock.Length > 180 ? block.OriginalBlock[..180] : block.OriginalBlock,
|
||||
SourceBlockId = $"block-{block.Index}",
|
||||
ReviewState = string.Equals(block.SectionName, "General", StringComparison.OrdinalIgnoreCase) ? "needs-review" : "suggested",
|
||||
LastUpdatedAtUtc = now,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<ClassifiedCvBlock>> ClassifyBlocksAsync(string parseSource, CancellationToken cancellationToken)
|
||||
{
|
||||
var blocks = Regex.Split(parseSource.Replace("\r\n", "\n"), @"\n\s*\n")
|
||||
.Select(block => block.Trim())
|
||||
.Where(block => block.Length >= 24)
|
||||
.ToList();
|
||||
|
||||
if (blocks.Count == 0) return new List<ClassifiedCvBlock>();
|
||||
|
||||
var results = new List<ClassifiedCvBlock>();
|
||||
for (var index = 0; index < blocks.Count; index++)
|
||||
{
|
||||
var block = blocks[index];
|
||||
var classification = await _cvAiClassifier.ClassifyBlockAsync(block, cancellationToken);
|
||||
var sectionName = classification?.Section;
|
||||
if (!string.IsNullOrWhiteSpace(sectionName) && SectionAliases.TryGetValue(sectionName, out var canonical))
|
||||
{
|
||||
sectionName = canonical;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sectionName) || string.Equals(sectionName, "Other", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sectionName = "General";
|
||||
}
|
||||
|
||||
var content = block;
|
||||
if (string.Equals(sectionName, "Work Experience", StringComparison.OrdinalIgnoreCase) && classification is not null)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(classification.Title)) lines.Add($"### {classification.Title.Trim()}");
|
||||
var endIsCurrent = string.Equals(classification.End, "Present", StringComparison.OrdinalIgnoreCase) || string.Equals(classification.End, "Current", StringComparison.OrdinalIgnoreCase);
|
||||
var dateRange = FormatDateRangeForSection(classification.Start, classification.End, endIsCurrent);
|
||||
var meta = string.Join(" | ", new[] { classification.Company, classification.Location, dateRange }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
if (!string.IsNullOrWhiteSpace(meta)) lines.Add(meta);
|
||||
if (classification.Bullets is not null)
|
||||
{
|
||||
lines.AddRange(classification.Bullets.Where(bullet => !string.IsNullOrWhiteSpace(bullet)).Select(bullet => $"- {bullet.Trim()}"));
|
||||
}
|
||||
if (lines.Count > 0) content = string.Join("\n", lines);
|
||||
}
|
||||
else if (string.Equals(sectionName, "Education", StringComparison.OrdinalIgnoreCase) && classification is not null)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(classification.Title)) lines.Add($"### {classification.Title.Trim()}");
|
||||
var dateRange = FormatDateRangeForSection(classification.Start, classification.End, false);
|
||||
var meta = string.Join(" | ", new[] { classification.Company, classification.Location, dateRange }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
if (!string.IsNullOrWhiteSpace(meta)) lines.Add(meta);
|
||||
if (classification.Bullets is not null)
|
||||
{
|
||||
lines.AddRange(classification.Bullets.Where(bullet => !string.IsNullOrWhiteSpace(bullet)).Select(bullet => $"- {bullet.Trim()}"));
|
||||
}
|
||||
if (lines.Count > 0) content = string.Join("\n", lines);
|
||||
}
|
||||
else if (string.Equals(sectionName, "Skills", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var items = classification?.Skills is { Count: > 0 }
|
||||
? classification.Skills.Where(skill => !string.IsNullOrWhiteSpace(skill)).Select(skill => skill.Trim()).ToList()
|
||||
: SplitClassifierSkills(block);
|
||||
if (items.Count > 0) content = string.Join("\n", items);
|
||||
}
|
||||
else if (string.Equals(sectionName, "Professional Summary", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var items = classification?.Summary is { Count: > 0 }
|
||||
? classification.Summary.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => $"- {line.Trim()}")
|
||||
: classification?.Bullets is { Count: > 0 }
|
||||
? classification.Bullets.Where(bullet => !string.IsNullOrWhiteSpace(bullet)).Select(bullet => $"- {bullet.Trim()}")
|
||||
: Enumerable.Empty<string>();
|
||||
var materialized = items.ToList();
|
||||
if (materialized.Count > 0) content = string.Join("\n", materialized);
|
||||
}
|
||||
|
||||
results.Add(new ClassifiedCvBlock(index + 1, block, sectionName, content, classification));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static string? FormatDateRangeForSection(string? start, string? end, bool isCurrent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null;
|
||||
if (string.IsNullOrWhiteSpace(start)) return end;
|
||||
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
||||
}
|
||||
|
||||
private async Task<string> MaybeReconstructStructuredCvAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = text.Trim();
|
||||
|
||||
@@ -8,17 +8,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Data\**\*.cs" />
|
||||
<Compile Include="..\Models\**\*.cs" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<Compile Remove="Controllers\**\*.cs" />
|
||||
<Compile Remove="Services\**\*.cs" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0-preview.3.efcore.9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -30,6 +30,8 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
||||
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
|
||||
@@ -130,6 +132,7 @@ builder.Services.AddHttpClient("ai-service", client =>
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
|
||||
@@ -673,6 +676,31 @@ CREATE TABLE IF NOT EXISTS "CvExtractionRuns" (
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"CanonicalProfileVersion" INTEGER NULL,
|
||||
"TemplateId" TEXT NOT NULL,
|
||||
"Headline" TEXT NULL,
|
||||
"SummaryJson" TEXT NULL,
|
||||
"SelectedSkillsJson" TEXT NULL,
|
||||
"ExperienceJson" TEXT NULL,
|
||||
"EducationJson" TEXT NULL,
|
||||
"CustomSectionsJson" TEXT NULL,
|
||||
"RenderOptionsJson" TEXT NULL,
|
||||
"GenerationContextHash" TEXT NULL,
|
||||
"LastGeneratedAtUtc" TEXT NULL,
|
||||
"LastEditedAtUtc" TEXT NULL,
|
||||
"Status" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");""");
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
@@ -911,6 +939,32 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`CanonicalProfileVersion` int NULL,
|
||||
`TemplateId` varchar(100) NOT NULL,
|
||||
`Headline` longtext NULL,
|
||||
`SummaryJson` longtext NULL,
|
||||
`SelectedSkillsJson` longtext NULL,
|
||||
`ExperienceJson` longtext NULL,
|
||||
`EducationJson` longtext NULL,
|
||||
`CustomSectionsJson` longtext NULL,
|
||||
`RenderOptionsJson` longtext NULL,
|
||||
`GenerationContextHash` longtext NULL,
|
||||
`LastGeneratedAtUtc` datetime(6) NULL,
|
||||
`LastEditedAtUtc` datetime(6) NULL,
|
||||
`Status` varchar(100) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -945,6 +999,20 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
||||
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace JobTrackerApi.Services
|
||||
public string DataRoot { get; }
|
||||
public string AttachmentsRoot { get; }
|
||||
public string CvArtifactsRoot { get; }
|
||||
public string CvExportsRoot { get; }
|
||||
public string CvBenchmarksRoot { get; }
|
||||
|
||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||
{
|
||||
@@ -31,6 +33,20 @@ namespace JobTrackerApi.Services
|
||||
|
||||
Directory.CreateDirectory(cvArtifactsRoot);
|
||||
CvArtifactsRoot = cvArtifactsRoot;
|
||||
|
||||
var cvExportsRoot = (cfg["Data:CvExportsRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(cvExportsRoot)) cvExportsRoot = Path.Combine(DataRoot, "CvExports");
|
||||
if (!Path.IsPathRooted(cvExportsRoot)) cvExportsRoot = Path.Combine(env.ContentRootPath, cvExportsRoot);
|
||||
|
||||
Directory.CreateDirectory(cvExportsRoot);
|
||||
CvExportsRoot = cvExportsRoot;
|
||||
|
||||
var cvBenchmarksRoot = (cfg["Data:CvBenchmarksRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(DataRoot, "CvBenchmarks");
|
||||
if (!Path.IsPathRooted(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(env.ContentRootPath, cvBenchmarksRoot);
|
||||
|
||||
Directory.CreateDirectory(cvBenchmarksRoot);
|
||||
CvBenchmarksRoot = cvBenchmarksRoot;
|
||||
}
|
||||
|
||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record CvBlockClassificationResult(
|
||||
string? Section,
|
||||
double? Confidence,
|
||||
string? Reason,
|
||||
string? Title,
|
||||
string? Company,
|
||||
string? Location,
|
||||
string? Start,
|
||||
string? End,
|
||||
List<string>? Bullets,
|
||||
List<string>? Summary,
|
||||
List<string>? Skills);
|
||||
|
||||
public interface ICvAiClassifier
|
||||
{
|
||||
Task<CvBlockClassificationResult?> ClassifyBlockAsync(string block, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class CvAiClassifier : ICvAiClassifier
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public CvAiClassifier(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task<CvBlockClassificationResult?> ClassifyBlockAsync(string block, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(block)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("ai-service");
|
||||
var payload = JsonSerializer.Serialize(new { block });
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync("/cv/classify-block", content, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
var parsed = await JsonSerializer.DeserializeAsync<CvBlockClassificationResult>(stream, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
}, cancellationToken);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoOpCvAiClassifier : ICvAiClassifier
|
||||
{
|
||||
public static NoOpCvAiClassifier Instance { get; } = new();
|
||||
private NoOpCvAiClassifier() { }
|
||||
public Task<CvBlockClassificationResult?> ClassifyBlockAsync(string block, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<CvBlockClassificationResult?>(null);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record TailoredCvRenderResult(string TemplateId, string SuggestedFileName, string Html);
|
||||
|
||||
public interface ICvTemplateRenderer
|
||||
{
|
||||
TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null);
|
||||
}
|
||||
|
||||
public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
{
|
||||
public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
|
||||
{
|
||||
var normalized = TailoredCvDraftJson.Normalize(document);
|
||||
var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId);
|
||||
normalized.TemplateId = effectiveTemplateId;
|
||||
var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf";
|
||||
var html = effectiveTemplateId switch
|
||||
{
|
||||
"harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName),
|
||||
"auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false),
|
||||
"edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true),
|
||||
"monarch" => RenderMonarch(normalized, candidateName, jobTitle, companyName, photoDataUrl),
|
||||
"fjord" => RenderFjord(normalized, candidateName, jobTitle, companyName, photoDataUrl),
|
||||
_ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl)
|
||||
};
|
||||
return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html);
|
||||
}
|
||||
|
||||
private static string NormalizeTemplateId(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"base" => "ats-minimal",
|
||||
"legacy-text" => "ats-minimal",
|
||||
"harvard" => "harvard",
|
||||
"auckland" => "auckland",
|
||||
"edinburgh" => "edinburgh",
|
||||
"monarch" => "monarch",
|
||||
"fjord" => "fjord",
|
||||
_ => "ats-minimal"
|
||||
};
|
||||
}
|
||||
|
||||
private static string RenderAtsMinimal(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "caps-rule");
|
||||
var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>Company focus: {Encode(companyName)}</span>";
|
||||
var photoMarkup = showPhoto ? $"<div class=\"photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — ATS Minimal</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#111827; --muted:#4b5563; --line:#d1d5db; --paper:#fff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#eef2f7; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
|
||||
.header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; border-bottom:2px solid var(--accent); padding-bottom:8mm; margin-bottom:7mm; }}
|
||||
.name {{ margin:0; font-size:25pt; letter-spacing:.02em; }}
|
||||
.headline {{ margin-top:2mm; color:var(--muted); font-size:11pt; }}
|
||||
.meta {{ margin-top:3mm; display:flex; flex-wrap:wrap; gap:3mm; color:var(--muted); font-size:9pt; }}
|
||||
.photo {{ width:28mm; height:36mm; border-radius:5mm; overflow:hidden; border:1px solid var(--line); }}
|
||||
.photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||
{BaseSectionCss(accent, "caps-rule")}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<header class=""header"">
|
||||
<div>
|
||||
<h1 class=""name"">{Encode(candidateName)}</h1>
|
||||
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
<div class=""meta""><span>Target role: {Encode(jobTitle)}</span>{companyFocusMarkup}<span>Template: ATS Minimal</span></div>
|
||||
</div>
|
||||
{photoMarkup}
|
||||
</header>
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderHarvard(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "harvard");
|
||||
var contactLine = string.Join(" • ", new[]
|
||||
{
|
||||
string.IsNullOrWhiteSpace(companyName) ? null : $"Targeting {Encode(companyName)}",
|
||||
Encode(jobTitle)
|
||||
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — Harvard</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#111; --muted:#333; --line:#111; --paper:#fff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#f5f5f5; color:var(--ink); font-family:Georgia, 'Times New Roman', serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; padding:16mm 18mm; }}
|
||||
.header {{ text-align:center; margin-bottom:6mm; }}
|
||||
.name {{ margin:0; font-size:23pt; font-weight:700; }}
|
||||
.headline {{ margin-top:2mm; font-size:10pt; font-style:italic; }}
|
||||
.meta {{ margin-top:4mm; font-size:9pt; }}
|
||||
{BaseSectionCss(accent, "harvard")}
|
||||
.section-title {{ color:var(--ink); }}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<header class=""header"">
|
||||
<h1 class=""name"">{Encode(candidateName)}</h1>
|
||||
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
<div class=""meta"">{contactLine}</div>
|
||||
</header>
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderSidebar(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl, string templateLabel, bool roundedPhoto, bool curvedHeader)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var sidebarSections = new StringBuilder();
|
||||
sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[]
|
||||
{
|
||||
$"Name\n{Encode(candidateName)}",
|
||||
$"Target role\n{Encode(jobTitle)}",
|
||||
string.IsNullOrWhiteSpace(companyName) ? null : $"Company focus\n{Encode(companyName)}"
|
||||
}));
|
||||
if (document.CustomSections.Count > 0)
|
||||
{
|
||||
foreach (var section in document.CustomSections.Take(2))
|
||||
{
|
||||
sidebarSections.Append(RenderSidebarMetaSection(section.Title ?? "Additional", section.Items));
|
||||
}
|
||||
}
|
||||
if (document.SelectedSkills.Count > 0)
|
||||
{
|
||||
sidebarSections.Append(RenderSidebarMetaSection("Skills", document.SelectedSkills.Take(8)));
|
||||
}
|
||||
|
||||
var main = RenderMainSections(document, accent, headingStyle: "sidebar");
|
||||
var photoClass = roundedPhoto ? "photo round" : "photo";
|
||||
var photoMarkup = showPhoto ? $"<div class=\"{photoClass}\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
var heroClass = curvedHeader ? "hero curved" : "hero";
|
||||
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — {Encode(templateLabel)}</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#1f2937; --muted:#4b5563; --line:#d1d5db; --sidebar:#f3f4f6; --paper:#fff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#edf2f7; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:#fff; display:grid; grid-template-columns:34% 66%; }}
|
||||
.sidebar {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 8%, white)); color:#fff; padding:12mm 8mm 12mm 10mm; }}
|
||||
.hero {{ margin:-12mm -8mm 8mm -10mm; padding:10mm 10mm 8mm 10mm; background:var(--accent); }}
|
||||
.hero.curved {{ border-bottom-right-radius:28mm; }}
|
||||
.name {{ margin:0; font-size:18pt; letter-spacing:.08em; font-weight:700; }}
|
||||
.headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
|
||||
.photo {{ width:34mm; height:34mm; margin-top:6mm; border:2px solid rgba(255,255,255,.85); overflow:hidden; }}
|
||||
.photo.round {{ border-radius:50%; }}
|
||||
.photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||
.sidebar-section {{ margin-top:7mm; }}
|
||||
.sidebar-title {{ margin:0 0 3mm 0; font-size:9pt; text-transform:uppercase; letter-spacing:.16em; }}
|
||||
.sidebar-item {{ margin:0 0 2.4mm 0; font-size:8.8pt; line-height:1.4; white-space:pre-line; }}
|
||||
.content {{ padding:14mm 14mm 14mm 10mm; }}
|
||||
{BaseSectionCss(accent, "sidebar")}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<aside class=""sidebar"">
|
||||
<div class=""{heroClass}"">
|
||||
<h1 class=""name"">{Encode(candidateName)}</h1>
|
||||
<div class=""headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
{photoMarkup}
|
||||
</div>
|
||||
{sidebarSections}
|
||||
</aside>
|
||||
<section class=""content"">
|
||||
{main}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderMonarch(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var photoMarkup = showPhoto ? $"<div class=\"monarch-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
|
||||
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<div class=\"monarch-company\">Tailored toward {Encode(companyName)}</div>";
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — Monarch</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#1c1917; --muted:#57534e; --paper:#fffdf8; --panel:#f7efe6; --line:#d6c1a8; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#efe7dc; color:var(--ink); font-family:'Times New Roman', Georgia, serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
|
||||
.monarch-shell {{ border:1px solid var(--line); padding:10mm; position:relative; }}
|
||||
.monarch-shell::before {{ content:''; position:absolute; inset:6mm; border:1px solid color-mix(in srgb, var(--line) 70%, white); pointer-events:none; }}
|
||||
.monarch-header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; align-items:center; margin-bottom:8mm; }}
|
||||
.monarch-kicker {{ display:inline-block; text-transform:uppercase; letter-spacing:.3em; font-size:8pt; color:var(--accent); margin-bottom:2mm; }}
|
||||
.monarch-name {{ margin:0; font-size:28pt; line-height:1.05; }}
|
||||
.monarch-headline {{ margin-top:2mm; font-size:11pt; color:var(--muted); max-width:130mm; }}
|
||||
.monarch-company {{ margin-top:2mm; font-size:9pt; color:var(--accent); text-transform:uppercase; letter-spacing:.16em; }}
|
||||
.monarch-photo {{ width:30mm; height:38mm; border:1px solid var(--line); background:var(--panel); overflow:hidden; }}
|
||||
.monarch-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||
.monarch-summary {{ margin-bottom:5mm; padding:4mm 5mm; background:var(--panel); border-left:3px solid var(--accent); font-size:10pt; color:var(--muted); }}
|
||||
{BaseSectionCss(accent, "harvard")}
|
||||
.section-title {{ text-transform:uppercase; letter-spacing:.12em; font-size:10pt; }}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<section class=""monarch-shell"">
|
||||
<header class=""monarch-header"">
|
||||
<div>
|
||||
<span class=""monarch-kicker"">Executive CV</span>
|
||||
<h1 class=""monarch-name"">{Encode(candidateName)}</h1>
|
||||
<div class=""monarch-headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
{companyMarkup}
|
||||
</div>
|
||||
{photoMarkup}
|
||||
</header>
|
||||
{(!string.IsNullOrWhiteSpace(jobTitle) ? $"<div class=\"monarch-summary\">Primary role target: {Encode(jobTitle)}</div>" : string.Empty)}
|
||||
{body}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderFjord(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
|
||||
var photoMarkup = showPhoto ? $"<div class=\"fjord-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>{Encode(companyName)}</span>";
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — Fjord</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#102a43; --muted:#486581; --panel:#e6f1f3; --line:#9fb3c8; --paper:#fbfdff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#d9e8ef; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:0; }}
|
||||
.fjord-grid {{ display:grid; grid-template-columns:72mm 1fr; min-height:297mm; }}
|
||||
.fjord-rail {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 15%, white)); color:white; padding:16mm 8mm; }}
|
||||
.fjord-name {{ margin:0; font-size:21pt; line-height:1.08; }}
|
||||
.fjord-headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
|
||||
.fjord-meta {{ margin-top:4mm; font-size:8.5pt; display:flex; flex-direction:column; gap:1.2mm; opacity:.9; }}
|
||||
.fjord-photo {{ width:28mm; height:28mm; border-radius:50%; overflow:hidden; border:2px solid rgba(255,255,255,.65); margin-top:6mm; }}
|
||||
.fjord-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||
.fjord-main {{ padding:14mm 14mm 14mm 10mm; }}
|
||||
{BaseSectionCss(accent, "sidebar")}
|
||||
.section {{ margin-top:5mm; }}
|
||||
.skills {{ gap:1.5mm; }}
|
||||
.skill-pill {{ background:var(--panel); border-color:transparent; color:var(--ink); }}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<section class=""fjord-grid"">
|
||||
<aside class=""fjord-rail"">
|
||||
<h1 class=""fjord-name"">{Encode(candidateName)}</h1>
|
||||
<div class=""fjord-headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
<div class=""fjord-meta""><span>{Encode(jobTitle)}</span>{companyMarkup}<span>Template: Fjord</span></div>
|
||||
{photoMarkup}
|
||||
</aside>
|
||||
<section class=""fjord-main"">{body}</section>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle)
|
||||
{
|
||||
var sectionOrder = document.RenderOptions.SectionOrder.Count == 0
|
||||
? new List<string> { "summary", "skills", "experience", "education", "custom" }
|
||||
: document.RenderOptions.SectionOrder;
|
||||
|
||||
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["summary"] = RenderListSection("Profile", document.Summary, bulletList: true),
|
||||
["skills"] = RenderSkillSection(document.SelectedSkills),
|
||||
["experience"] = RenderExperienceSection(document.Experience),
|
||||
["education"] = RenderEducationSection(document.Education),
|
||||
["custom"] = string.Join(string.Empty, document.CustomSections.Select(RenderCustomSection)),
|
||||
};
|
||||
|
||||
return string.Join(string.Empty, sectionOrder
|
||||
.Select(key => sections.TryGetValue(key, out var section) ? section : string.Empty)
|
||||
.Where(section => !string.IsNullOrWhiteSpace(section)));
|
||||
}
|
||||
|
||||
private static string BaseSectionCss(string accent, string headingStyle)
|
||||
{
|
||||
var headingCss = headingStyle switch
|
||||
{
|
||||
"harvard" => ".section-title{font-size:17pt;font-weight:700;border-bottom:1.5px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}",
|
||||
"sidebar" => ".section-title{font-size:14pt;font-weight:700;letter-spacing:.02em;margin-bottom:3mm;}",
|
||||
_ => ".section-title{font-size:9pt;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);border-bottom:1px solid var(--line);padding-bottom:1.5mm;margin-bottom:3mm;}"
|
||||
};
|
||||
|
||||
return $@"
|
||||
.section{{margin-top:6mm;}}
|
||||
{headingCss}
|
||||
.summary,.custom-list,.education-list,.experience-bullets{{margin:0;padding-left:4.5mm;}}
|
||||
.summary li,.custom-list li,.education-list li,.experience-bullets li{{margin:0 0 1.6mm 0;line-height:1.42;}}
|
||||
.skills{{list-style:none;padding-left:0;display:flex;flex-wrap:wrap;gap:2mm;}}
|
||||
.skill-pill{{border:1px solid var(--line);border-radius:999px;padding:1mm 2.4mm;font-size:9pt;}}
|
||||
.entry{{margin-bottom:4.8mm;}}
|
||||
.entry-header{{display:flex;justify-content:space-between;gap:4mm;align-items:baseline;margin-bottom:1.2mm;}}
|
||||
.entry-title{{font-weight:700;font-size:11pt;}}
|
||||
.entry-meta{{color:var(--muted);font-size:9pt;text-align:right;white-space:nowrap;}}
|
||||
.entry-subtitle{{color:var(--muted);font-size:9.5pt;margin-bottom:1.3mm;}}";
|
||||
}
|
||||
|
||||
private static string RenderSidebarMetaSection(string title, IEnumerable<string?> items)
|
||||
{
|
||||
var content = string.Join(string.Empty, items.Where(item => !string.IsNullOrWhiteSpace(item)).Select(item => $"<p class=\"sidebar-item\">{item}</p>"));
|
||||
if (string.IsNullOrWhiteSpace(content)) return string.Empty;
|
||||
return $"<section class=\"sidebar-section\"><h2 class=\"sidebar-title\">{Encode(title)}</h2>{content}</section>";
|
||||
}
|
||||
|
||||
private static string RenderListSection(string title, IReadOnlyCollection<string> items, bool bulletList)
|
||||
{
|
||||
if (items.Count == 0) return string.Empty;
|
||||
var tag = bulletList ? "summary" : "custom-list";
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(title)}</h2><ul class=\"{tag}\">{string.Join(string.Empty, items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
|
||||
}
|
||||
|
||||
private static string RenderSkillSection(IReadOnlyCollection<string> skills)
|
||||
{
|
||||
if (skills.Count == 0) return string.Empty;
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">Skills</h2><ul class=\"skills\">{string.Join(string.Empty, skills.Select(skill => $"<li class=\"skill-pill\">{Encode(skill)}</li>"))}</ul></section>";
|
||||
}
|
||||
|
||||
private static string RenderExperienceSection(IReadOnlyCollection<TailoredCvExperienceItem> experience)
|
||||
{
|
||||
if (experience.Count == 0) return string.Empty;
|
||||
var items = new StringBuilder();
|
||||
foreach (var entry in experience)
|
||||
{
|
||||
var subtitle = string.Join(" · ", new[] { entry.Company, entry.Location }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(Encode));
|
||||
var dateRange = FormatDateRange(entry.Start, entry.End, entry.IsCurrent);
|
||||
items.Append("<article class=\"entry\">");
|
||||
items.Append($"<div class=\"entry-header\"><div class=\"entry-title\">{Encode(entry.Title)}</div><div class=\"entry-meta\">{Encode(dateRange)}</div></div>");
|
||||
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
|
||||
if (entry.Bullets.Count > 0) items.Append($"<ul class=\"experience-bullets\">{string.Join(string.Empty, entry.Bullets.Select(bullet => $"<li>{Encode(bullet)}</li>"))}</ul>");
|
||||
items.Append("</article>");
|
||||
}
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">Professional Experience</h2>{items}</section>";
|
||||
}
|
||||
|
||||
private static string RenderEducationSection(IReadOnlyCollection<TailoredCvEducationItem> education)
|
||||
{
|
||||
if (education.Count == 0) return string.Empty;
|
||||
var items = new StringBuilder();
|
||||
foreach (var entry in education)
|
||||
{
|
||||
var subtitle = string.Join(" · ", new[] { entry.Institution, entry.Location, FormatDateRange(entry.Start, entry.End, false) }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(Encode));
|
||||
items.Append("<article class=\"entry\">");
|
||||
var title = string.IsNullOrWhiteSpace(entry.QualificationLevel)
|
||||
? entry.Qualification
|
||||
: $"{entry.Qualification} ({entry.QualificationLevel})";
|
||||
items.Append($"<div class=\"entry-title\">{Encode(title)}</div>");
|
||||
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
|
||||
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
|
||||
items.Append("</article>");
|
||||
}
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">Education</h2>{items}</section>";
|
||||
}
|
||||
|
||||
private static string RenderCustomSection(TailoredCvCustomSection section)
|
||||
{
|
||||
if (section.Items.Count == 0) return string.Empty;
|
||||
return $"<section class=\"section\"><h2 class=\"section-title\">{Encode(section.Title ?? "Additional Information")}</h2><ul class=\"custom-list\">{string.Join(string.Empty, section.Items.Select(item => $"<li>{Encode(item)}</li>"))}</ul></section>";
|
||||
}
|
||||
|
||||
private static string FormatDateRange(string? start, string? end, bool isCurrent)
|
||||
{
|
||||
var normalizedStart = (start ?? string.Empty).Trim();
|
||||
var normalizedEnd = (end ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedStart) && string.IsNullOrWhiteSpace(normalizedEnd)) return string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalizedStart)) return normalizedEnd;
|
||||
return $"{normalizedStart} - {(isCurrent ? "Present" : string.IsNullOrWhiteSpace(normalizedEnd) ? "Present" : normalizedEnd)}";
|
||||
}
|
||||
|
||||
private static string ResolveAccent(string? accentColor)
|
||||
{
|
||||
var normalized = (accentColor ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"slate" => "#334155",
|
||||
"blue" => "#1d4ed8",
|
||||
"emerald" => "#047857",
|
||||
"plum" => "#7c3aed",
|
||||
"brick" => "#b45309",
|
||||
_ when normalized.StartsWith("#") => normalized,
|
||||
_ => "#334155"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "'", StringComparison.Ordinal);
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray());
|
||||
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] Bytes);
|
||||
|
||||
public interface ICvPdfExporter
|
||||
{
|
||||
Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
{
|
||||
private readonly AppPaths _paths;
|
||||
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
|
||||
|
||||
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger)
|
||||
{
|
||||
_paths = paths;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
|
||||
Directory.CreateDirectory(folder);
|
||||
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
||||
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
|
||||
: renderResult.SuggestedFileName;
|
||||
var storagePath = Path.Combine(folder, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
using var playwright = await Playwright.CreateAsync();
|
||||
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
});
|
||||
var page = await browser.NewPageAsync();
|
||||
await page.SetContentAsync(renderResult.Html, new PageSetContentOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.Load,
|
||||
});
|
||||
var bytes = await page.PdfAsync(new PagePdfOptions
|
||||
{
|
||||
Format = "A4",
|
||||
PrintBackground = true,
|
||||
Margin = new()
|
||||
{
|
||||
Top = "0",
|
||||
Right = "0",
|
||||
Bottom = "0",
|
||||
Left = "0",
|
||||
}
|
||||
});
|
||||
await File.WriteAllBytesAsync(storagePath, bytes, cancellationToken);
|
||||
return new CvPdfArtifact(fileName, storagePath, bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
|
||||
throw new InvalidOperationException("CV PDF export is unavailable. Ensure Chromium is installed for Playwright on this machine.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,14 @@ namespace JobTrackerApi.Services
|
||||
string? GpuName,
|
||||
bool? OcrAvailable,
|
||||
string? OcrLanguages,
|
||||
bool? OllamaConfigured,
|
||||
bool? OllamaReachable,
|
||||
string? OllamaModel,
|
||||
bool? OllamaModelAvailable,
|
||||
string? OllamaVersion,
|
||||
IReadOnlyList<string>? OllamaInstalledModels,
|
||||
IReadOnlyList<string>? OllamaLoadedModels,
|
||||
int? OllamaLoadedCount,
|
||||
double? HealthLatencyMs,
|
||||
double? ProbeLatencyMs,
|
||||
DateTimeOffset? LastProbeAt,
|
||||
@@ -62,6 +70,7 @@ namespace JobTrackerApi.Services
|
||||
|
||||
public interface ISummarizerService : IAiService
|
||||
{
|
||||
new Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
|
||||
}
|
||||
|
||||
public class SummarizerService : ISummarizerService
|
||||
@@ -310,6 +319,14 @@ namespace JobTrackerApi.Services
|
||||
string? gpuName = null;
|
||||
bool? ocrAvailable = null;
|
||||
string? ocrLanguages = null;
|
||||
bool? ollamaConfigured = null;
|
||||
bool? ollamaReachable = null;
|
||||
string? ollamaModel = null;
|
||||
bool? ollamaModelAvailable = null;
|
||||
string? ollamaVersion = null;
|
||||
List<string>? ollamaInstalledModels = null;
|
||||
List<string>? ollamaLoadedModels = null;
|
||||
int? ollamaLoadedCount = null;
|
||||
double? healthLatencyMs = null;
|
||||
var healthy = false;
|
||||
string? healthError = null;
|
||||
@@ -332,6 +349,20 @@ namespace JobTrackerApi.Services
|
||||
if (doc.RootElement.TryGetProperty("gpu_name", out var gpuNameEl)) gpuName = gpuNameEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ocr_available", out var ocrAvailableEl) && ocrAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ocrAvailable = ocrAvailableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ocr_languages", out var ocrLanguagesEl)) ocrLanguages = ocrLanguagesEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ollama_configured", out var ollamaConfiguredEl) && ollamaConfiguredEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaConfigured = ollamaConfiguredEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_reachable", out var ollamaReachableEl) && ollamaReachableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaReachable = ollamaReachableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_model", out var ollamaModelEl)) ollamaModel = ollamaModelEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ollama_model_available", out var ollamaModelAvailableEl) && ollamaModelAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaModelAvailable = ollamaModelAvailableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_version", out var ollamaVersionEl)) ollamaVersion = ollamaVersionEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ollama_installed_models", out var ollamaInstalledModelsEl) && ollamaInstalledModelsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ollamaInstalledModels = ollamaInstalledModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("ollama_loaded_models", out var ollamaLoadedModelsEl) && ollamaLoadedModelsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ollamaLoadedModels = ollamaLoadedModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("ollama_loaded_count", out var ollamaLoadedCountEl) && ollamaLoadedCountEl.ValueKind == JsonValueKind.Number) ollamaLoadedCount = ollamaLoadedCountEl.GetInt32();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -390,6 +421,14 @@ namespace JobTrackerApi.Services
|
||||
GpuName: gpuName,
|
||||
OcrAvailable: ocrAvailable,
|
||||
OcrLanguages: ocrLanguages,
|
||||
OllamaConfigured: ollamaConfigured,
|
||||
OllamaReachable: ollamaReachable,
|
||||
OllamaModel: ollamaModel,
|
||||
OllamaModelAvailable: ollamaModelAvailable,
|
||||
OllamaVersion: ollamaVersion,
|
||||
OllamaInstalledModels: ollamaInstalledModels,
|
||||
OllamaLoadedModels: ollamaLoadedModels,
|
||||
OllamaLoadedCount: ollamaLoadedCount,
|
||||
HealthLatencyMs: healthLatencyMs,
|
||||
ProbeLatencyMs: probeLatencyMs,
|
||||
LastProbeAt: lastProbeAt,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<!--
|
||||
Transitional shared-backend project.
|
||||
The API host and test project both reference this library so controller/service code can
|
||||
be exercised without dragging the web-entry project into every test build.
|
||||
Files still live in their original folders for now; a later refactor can move them physically.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RestoreIgnoreFailedSources>true</RestoreIgnoreFailedSources>
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Data\**\*.cs" />
|
||||
<Compile Include="..\Models\**\*.cs" />
|
||||
<Compile Include="..\JobTrackerApi\Controllers\**\*.cs" />
|
||||
<Compile Include="..\JobTrackerApi\Services\**\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public static class HumanLanguageCatalog
|
||||
{
|
||||
private static readonly Dictionary<string, string> LanguageLookup = BuildLanguageLookup();
|
||||
|
||||
private static readonly Regex WordRegex = new(@"\p{L}+", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex LevelRegex = new(
|
||||
@"\b(native(?:\s+speaker)?|fluent|advanced|intermediate|beginner|basic|conversational|elementary|professional\s+working\s+proficiency|working\s+proficiency|limited\s+working\s+proficiency|full\s+professional\s+proficiency|a1|a2|b1|b2|c1|c2|a1\s*/\s*a2|a2\s*/\s*b1|b1\s*/\s*b2|b2\s*/\s*c1|c1\s*/\s*c2)\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static string? NormalizeLanguageName(string? raw)
|
||||
{
|
||||
var matches = ExtractLanguageNames(raw);
|
||||
return matches.Count == 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> ExtractLanguageNames(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
|
||||
|
||||
var words = WordRegex.Matches(raw)
|
||||
.Select(match => match.Value)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToList();
|
||||
|
||||
if (words.Count == 0) return Array.Empty<string>();
|
||||
|
||||
var matches = new List<(int Start, int Size, string Canonical)>();
|
||||
for (var size = Math.Min(4, words.Count); size >= 1; size--)
|
||||
{
|
||||
for (var start = 0; start <= words.Count - size; start++)
|
||||
{
|
||||
var phrase = string.Join(" ", words.Skip(start).Take(size));
|
||||
if (!LanguageLookup.TryGetValue(NormalizeKey(phrase), out var canonical)) continue;
|
||||
if (matches.Any(existing => RangesOverlap(existing.Start, existing.Size, start, size))) continue;
|
||||
matches.Add((start, size, canonical));
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
.OrderBy(match => match.Start)
|
||||
.Select(match => match.Canonical)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool HasRecognizedLevel(string? raw)
|
||||
{
|
||||
return ExtractLevel(raw) is not null;
|
||||
}
|
||||
|
||||
public static string? ExtractLevel(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
|
||||
var match = LevelRegex.Match(raw);
|
||||
if (!match.Success) return null;
|
||||
|
||||
var value = match.Groups[1].Value.Trim();
|
||||
var compact = Regex.Replace(value, @"\s+", " ");
|
||||
return compact.ToLowerInvariant() switch
|
||||
{
|
||||
"native speaker" => "Native",
|
||||
"native" => "Native",
|
||||
"fluent" => "Fluent",
|
||||
"advanced" => "Advanced",
|
||||
"intermediate" => "Intermediate",
|
||||
"beginner" => "Beginner",
|
||||
"basic" => "Basic",
|
||||
"conversational" => "Conversational",
|
||||
"elementary" => "Elementary",
|
||||
"professional working proficiency" => "Professional working proficiency",
|
||||
"working proficiency" => "Working proficiency",
|
||||
"limited working proficiency" => "Limited working proficiency",
|
||||
"full professional proficiency" => "Full professional proficiency",
|
||||
_ when Regex.IsMatch(compact, @"^[ABC][12](?:\s*/\s*[ABC][12])?$", RegexOptions.IgnoreCase) => compact.ToUpperInvariant().Replace(" ", string.Empty),
|
||||
_ => compact,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool RangesOverlap(int startA, int sizeA, int startB, int sizeB)
|
||||
{
|
||||
var endA = startA + sizeA;
|
||||
var endB = startB + sizeB;
|
||||
return startA < endB && startB < endA;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildLanguageLookup()
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Add(string? alias, string? canonical)
|
||||
{
|
||||
var normalizedAlias = NormalizeKey(alias);
|
||||
var normalizedCanonical = NormalizeDisplayName(canonical);
|
||||
if (string.IsNullOrWhiteSpace(normalizedAlias) || string.IsNullOrWhiteSpace(normalizedCanonical)) return;
|
||||
map.TryAdd(normalizedAlias, normalizedCanonical);
|
||||
}
|
||||
|
||||
foreach (var culture in CultureInfo.GetCultures(CultureTypes.NeutralCultures | CultureTypes.SpecificCultures))
|
||||
{
|
||||
var english = CleanCultureLanguageName(culture.EnglishName);
|
||||
var native = CleanCultureLanguageName(culture.NativeName);
|
||||
Add(english, english);
|
||||
Add(native, english);
|
||||
}
|
||||
|
||||
Add("norsk", "Norwegian");
|
||||
Add("bokmal", "Norwegian");
|
||||
Add("bokmål", "Norwegian");
|
||||
Add("nynorsk", "Norwegian");
|
||||
Add("mandarin", "Chinese");
|
||||
Add("cantonese", "Chinese");
|
||||
Add("farsi", "Persian");
|
||||
Add("persian", "Persian");
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string? CleanCultureLanguageName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
|
||||
var cleaned = value.Trim();
|
||||
var parenIndex = cleaned.IndexOf('(');
|
||||
if (parenIndex > 0) cleaned = cleaned[..parenIndex].Trim();
|
||||
var commaIndex = cleaned.IndexOf(',');
|
||||
if (commaIndex > 0) cleaned = cleaned[..commaIndex].Trim();
|
||||
return NormalizeDisplayName(cleaned);
|
||||
}
|
||||
|
||||
private static string? NormalizeDisplayName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var cleaned = Regex.Replace(value.Trim(), @"\s+", " ");
|
||||
return string.Join(" ", cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(word => word.Length <= 3 && word.All(char.IsUpper)
|
||||
? word
|
||||
: char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant()));
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
|
||||
var decomposed = value.Trim().Normalize(NormalizationForm.FormD);
|
||||
var builder = new StringBuilder(decomposed.Length);
|
||||
foreach (var ch in decomposed)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark) continue;
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
}
|
||||
|
||||
return Regex.Replace(builder.ToString().Normalize(NormalizationForm.FormC), @"[^\p{L}]+", " ").Trim();
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ public class JobApplication
|
||||
public DateTime? TailoredCvUpdatedAt { get; set; }
|
||||
public DateTime? LastReminderEmailSentAt { get; set; }
|
||||
|
||||
public TailoredCvDraft? TailoredCvDraft { get; set; }
|
||||
public List<Correspondence> Messages { get; set; } = new();
|
||||
public List<Attachment> Attachments { get; set; } = new();
|
||||
public List<JobEvent> Events { get; set; } = new();
|
||||
|
||||
@@ -8,6 +8,8 @@ public sealed class StructuredCvProfile
|
||||
public List<string> Summary { get; set; } = new();
|
||||
public List<StructuredCvJob> Jobs { get; set; } = new();
|
||||
public List<StructuredCvEducation> Education { get; set; } = new();
|
||||
public List<StructuredCvCertification> Certifications { get; set; } = new();
|
||||
public List<StructuredCvProject> Projects { get; set; } = new();
|
||||
public List<string> Skills { get; set; } = new();
|
||||
public List<StructuredCvLanguage> Languages { get; set; } = new();
|
||||
public List<string> Interests { get; set; } = new();
|
||||
@@ -60,6 +62,7 @@ public sealed class StructuredCvJob
|
||||
public sealed class StructuredCvEducation
|
||||
{
|
||||
public string? Qualification { get; set; }
|
||||
public string? QualificationLevel { get; set; }
|
||||
public string? Institution { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
@@ -67,6 +70,26 @@ public sealed class StructuredCvEducation
|
||||
public List<string> Details { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvCertification
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Issuer { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Date { get; set; }
|
||||
public List<string> Details { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvProject
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
public string? End { get; set; }
|
||||
public List<string> Bullets { get; set; } = new();
|
||||
public List<string> Skills { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvLanguage
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
@@ -12,6 +12,13 @@ public static class StructuredCvProfileJson
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> NonLocationTokens = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"python", "ruby", "sql", "mysql", "postgresql", "postgres", "sqlite", "javascript", "typescript",
|
||||
"react", "node", "node.js", "c#", ".net", "asp.net", "java", "azure", "aws", "gcp", "docker",
|
||||
"kubernetes", "terraform", "git", "github", "gitlab", "ci/cd", "rest", "graphql", "php", "golang", "go"
|
||||
};
|
||||
|
||||
public static StructuredCvProfile Empty() => Normalize(new StructuredCvProfile());
|
||||
|
||||
public static StructuredCvProfile Deserialize(string? json)
|
||||
@@ -60,6 +67,8 @@ public static class StructuredCvProfileJson
|
||||
: primary.Summary.Concat(secondary.Summary).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (primary.Jobs.Count == 0) primary.Jobs = secondary.Jobs;
|
||||
if (primary.Education.Count == 0) primary.Education = secondary.Education;
|
||||
if (primary.Certifications.Count == 0) primary.Certifications = secondary.Certifications;
|
||||
if (primary.Projects.Count == 0) primary.Projects = secondary.Projects;
|
||||
primary.Skills = primary.Skills.Count == 0
|
||||
? secondary.Skills
|
||||
: primary.Skills.Concat(secondary.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
@@ -125,6 +134,14 @@ public static class StructuredCvProfileJson
|
||||
case "education":
|
||||
profile.Education = ParseEducation(section.Content);
|
||||
break;
|
||||
case "certifications":
|
||||
case "certificates":
|
||||
profile.Certifications = ParseCertifications(section.Content);
|
||||
break;
|
||||
case "projects":
|
||||
case "selected projects":
|
||||
profile.Projects = ParseProjects(section.Content);
|
||||
break;
|
||||
default:
|
||||
profile.OtherSections.Add(new StructuredCvOtherSection
|
||||
{
|
||||
@@ -144,7 +161,7 @@ public static class StructuredCvProfileJson
|
||||
profile.Version = string.IsNullOrWhiteSpace(profile.Version) ? "1" : profile.Version.Trim();
|
||||
profile.Metadata ??= new StructuredCvMetadata();
|
||||
profile.Metadata.Fields ??= new Dictionary<string, StructuredCvFieldMetadata>();
|
||||
profile.Contact ??= new StructuredCvContact();
|
||||
profile.Contact = NormalizeContact(profile.Contact);
|
||||
profile.Summary = CleanList(profile.Summary);
|
||||
profile.Jobs = (profile.Jobs ?? new List<StructuredCvJob>())
|
||||
.Select(NormalizeJob)
|
||||
@@ -158,6 +175,18 @@ public static class StructuredCvProfileJson
|
||||
|| !string.IsNullOrWhiteSpace(education.Institution)
|
||||
|| education.Details.Count > 0)
|
||||
.ToList();
|
||||
profile.Certifications = (profile.Certifications ?? new List<StructuredCvCertification>())
|
||||
.Select(NormalizeCertification)
|
||||
.Where(certification => !string.IsNullOrWhiteSpace(certification.Name)
|
||||
|| !string.IsNullOrWhiteSpace(certification.Issuer)
|
||||
|| certification.Details.Count > 0)
|
||||
.ToList();
|
||||
profile.Projects = (profile.Projects ?? new List<StructuredCvProject>())
|
||||
.Select(NormalizeProject)
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project.Name)
|
||||
|| !string.IsNullOrWhiteSpace(project.Role)
|
||||
|| project.Bullets.Count > 0)
|
||||
.ToList();
|
||||
profile.Skills = CleanList(profile.Skills);
|
||||
profile.Languages = (profile.Languages ?? new List<StructuredCvLanguage>())
|
||||
.Select(NormalizeLanguage)
|
||||
@@ -178,37 +207,320 @@ public static class StructuredCvProfileJson
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static StructuredCvContact NormalizeContact(StructuredCvContact? contact)
|
||||
{
|
||||
contact ??= new StructuredCvContact();
|
||||
contact.FullName = TrimOrNull(contact.FullName);
|
||||
contact.Headline = TrimOrNull(contact.Headline);
|
||||
contact.Email = TrimOrNull(contact.Email);
|
||||
contact.Phone = TrimOrNull(contact.Phone);
|
||||
contact.Location = NormalizeLocationValue(contact.Location);
|
||||
contact.Website = NormalizeWebsite(contact.Website);
|
||||
contact.LinkedIn = NormalizeLinkedIn(contact.LinkedIn);
|
||||
return contact;
|
||||
}
|
||||
|
||||
private static StructuredCvJob NormalizeJob(StructuredCvJob? job)
|
||||
{
|
||||
job ??= new StructuredCvJob();
|
||||
job.Title = TrimOrNull(job.Title);
|
||||
job.Company = TrimOrNull(job.Company);
|
||||
job.Location = TrimOrNull(job.Location);
|
||||
job.Start = TrimOrNull(job.Start);
|
||||
job.End = TrimOrNull(job.End);
|
||||
job.Bullets = CleanList(job.Bullets);
|
||||
|
||||
var title = NormalizeJobTitle(job.Title);
|
||||
var company = NormalizeCompanyName(job.Company);
|
||||
var location = NormalizeLocationValue(job.Location);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title) && company is null)
|
||||
{
|
||||
var atSplit = Regex.Match(title, @"^(?<title>.+?)\s+at\s+(?<company>.+)$", RegexOptions.IgnoreCase);
|
||||
if (atSplit.Success)
|
||||
{
|
||||
title = NormalizeJobTitle(atSplit.Groups["title"].Value);
|
||||
company = NormalizeCompanyName(atSplit.Groups["company"].Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(company))
|
||||
{
|
||||
var titleLooksLikeCompany = LooksLikeCompanyName(title) && !LooksLikeJobTitle(title);
|
||||
var companyLooksLikeTitle = LooksLikeJobTitle(company) && !LooksLikeCompanyName(company);
|
||||
if (titleLooksLikeCompany && companyLooksLikeTitle)
|
||||
{
|
||||
(title, company) = (company, title);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title) && !LooksLikeJobTitle(title) && LooksLikeCompanyName(title))
|
||||
{
|
||||
if (company is null) company = title;
|
||||
title = null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(company) && !LooksLikeCompanyName(company) && LooksLikeJobTitle(company) && title is null)
|
||||
{
|
||||
title = company;
|
||||
company = null;
|
||||
}
|
||||
|
||||
job.Title = title;
|
||||
job.Company = company;
|
||||
job.Location = location;
|
||||
job.Start = NormalizeDateValue(job.Start);
|
||||
job.End = NormalizeDateValue(job.End);
|
||||
job.Bullets = CleanList(job.Bullets)
|
||||
.Select(NormalizeBullet)
|
||||
.Where(bullet => bullet is not null)
|
||||
.Select(bullet => bullet!)
|
||||
.Where(bullet => IsUsefulJobBullet(bullet, job.Title, job.Company))
|
||||
.ToList();
|
||||
job.Skills = CleanList(job.Skills);
|
||||
job.IsCurrent = job.IsCurrent || string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase);
|
||||
return job;
|
||||
}
|
||||
|
||||
private static string? NormalizeBullet(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return value.Trim().TrimStart('-', '•', '*', ' ');
|
||||
}
|
||||
|
||||
private static bool IsUsefulJobBullet(string? value, string? title, string? company)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return false;
|
||||
if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || trimmed.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (title is not null && trimmed.Equals(title, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (company is not null && trimmed.Equals(company, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (trimmed.Length < 12 && !trimmed.Contains(' ')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NormalizeJobTitle(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || LooksLikeUrlOrEmail(trimmed)) return null;
|
||||
trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ',', '-', ':');
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeCompanyName(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || LooksLikeUrlOrEmail(trimmed)) return null;
|
||||
if (trimmed.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
if (trimmed.Contains('.') && trimmed.Contains(' ')) return null;
|
||||
trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ',', '-', ':');
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeLocationValue(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
if (LooksLikeDateRange(trimmed) || LooksLikeSectionHeading(trimmed) || LooksLikeUrlOrEmail(trimmed)) return null;
|
||||
if (trimmed.Any(char.IsDigit) || trimmed.Length > 80) return null;
|
||||
|
||||
var normalized = Regex.Replace(trimmed, @"\s+[A-Z](?:\s+[A-Z]){2,}(?:\b.*)?$", string.Empty).Trim();
|
||||
normalized = Regex.Replace(normalized, @"\b(?:remote|hybrid)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||
normalized = Regex.Replace(normalized, @"\b(?:sales representative|developer|engineer|manager|consultant|analyst|designer|specialist|technician)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||
normalized = Regex.Replace(normalized, @"\s+", " ").Trim(' ', '|', ';', ':');
|
||||
var parts = normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0 || parts.Length > 4) return null;
|
||||
if (parts.Any(part => !Regex.IsMatch(part, @"^[\p{L}][\p{L}'’\-. ]+$"))) return null;
|
||||
if (parts.Any(LooksLikeSkillToken)) return null;
|
||||
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string? NormalizeWebsite(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
if (trimmed.Contains("linkedin.com", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var candidate = trimmed;
|
||||
if (!candidate.Contains("://", StringComparison.Ordinal)) candidate = $"https://{candidate}";
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null;
|
||||
var host = uri.Host.Trim().Trim('.').ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(host) || !Regex.IsMatch(host, @"^(?:[a-z0-9-]+\.)+[a-z]{2,}$", RegexOptions.IgnoreCase)) return null;
|
||||
return host;
|
||||
}
|
||||
|
||||
private static string? NormalizeLinkedIn(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
|
||||
var candidate = trimmed;
|
||||
if (!candidate.Contains("://", StringComparison.Ordinal)) candidate = $"https://{candidate}";
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null;
|
||||
if (!uri.Host.Contains("linkedin.com", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var path = uri.AbsolutePath.TrimEnd('/');
|
||||
if (!Regex.IsMatch(path, @"^/(in|pub)/[^/]+(?:/[^/]+){0,2}$", RegexOptions.IgnoreCase)) return null;
|
||||
return $"https://www.linkedin.com{path}";
|
||||
}
|
||||
|
||||
private static string? NormalizeDateValue(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
return trimmed is not null && LooksLikeDateRange(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
private static bool LooksLikeDateRange(string value)
|
||||
{
|
||||
return Regex.IsMatch(value, @"^(?:\d{1,2}/\d{1,2}/\d{4}|(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}|\d{4}|Present|Current)(?:\s*[-–]\s*(?:\d{1,2}/\d{1,2}/\d{4}|(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{4}|\d{4}|Present|Current))?$", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static bool LooksLikeUrlOrEmail(string value)
|
||||
{
|
||||
return value.Contains('@')
|
||||
|| value.Contains("www.", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Contains("https://", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool LooksLikeSectionHeading(string value)
|
||||
{
|
||||
return value.Equals("Work Experience", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Experience", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Employment History", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Education", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Skills", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Languages", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Interests", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Contact", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Professional Summary", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("Summary", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool LooksLikeJobTitle(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || LooksLikeDateRange(value) || LooksLikeUrlOrEmail(value)) return false;
|
||||
|
||||
return Regex.IsMatch(value, @"\b(developer|engineer|manager|lead|architect|consultant|specialist|analyst|administrator|coordinator|director|designer|intern|officer|owner|founder|teacher|researcher|writer|editor|producer|assistant|technician|supervisor|head)\b", RegexOptions.IgnoreCase)
|
||||
|| (value.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 6 && !LooksLikeCompanyName(value));
|
||||
}
|
||||
|
||||
private static bool LooksLikeCompanyName(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || LooksLikeDateRange(value) || LooksLikeUrlOrEmail(value)) return false;
|
||||
|
||||
return Regex.IsMatch(value, @"\b(inc|llc|ltd|limited|plc|corp|corporation|company|group|university|college|council|municipality|kommune|bank|studio|agency|institute|hospital|school|technologies|technology|systems|solutions|consulting|consultants|partners|foundation|ministry|government)\b", RegexOptions.IgnoreCase)
|
||||
|| value.Contains('&')
|
||||
|| Regex.IsMatch(value, @"\b[A-Z]{2,}\b");
|
||||
}
|
||||
|
||||
private static bool LooksLikeSkillToken(string value)
|
||||
{
|
||||
var normalized = TrimOrNull(value)?.Trim('.', ' ');
|
||||
return normalized is not null && NonLocationTokens.Contains(normalized);
|
||||
}
|
||||
|
||||
private static bool LooksLikeQualification(string value)
|
||||
{
|
||||
return Regex.IsMatch(value, @"\b(level\s*\d+|nvq|btec|gcse|a-?level|diploma|certificate|certification|bachelor(?:'s)?|master(?:'s)?|phd|doctorate|mba|ba|bsc|msc|ma|associate|apprenticeship|degree|ict)\b", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static bool LooksLikeInstitutionName(string value)
|
||||
{
|
||||
return Regex.IsMatch(value, @"\b(university|college|school|academy|institute|faculty|campus|council|polytechnic)\b", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private static string? NormalizeQualification(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
if (LooksLikeDateRange(trimmed) || LooksLikeUrlOrEmail(trimmed) || LooksLikeSectionHeading(trimmed)) return null;
|
||||
trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ';', ':');
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeInstitution(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
if (LooksLikeDateRange(trimmed) || LooksLikeUrlOrEmail(trimmed) || LooksLikeSectionHeading(trimmed)) return null;
|
||||
trimmed = Regex.Replace(trimmed, @"\s+", " ").Trim(' ', '|', ';', ':');
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeQualificationLevel(string? explicitValue, string? qualificationText)
|
||||
{
|
||||
var candidate = TrimOrNull(explicitValue) ?? TrimOrNull(qualificationText);
|
||||
if (candidate is null) return null;
|
||||
|
||||
if (Regex.IsMatch(candidate, @"\b(phd|doctorate|dphil)\b", RegexOptions.IgnoreCase)) return "PhD";
|
||||
if (Regex.IsMatch(candidate, @"\b(master(?:'s)?|msc|m\.sc|ma|m\.a|mba|meng|meng)\b", RegexOptions.IgnoreCase)) return "Master";
|
||||
if (Regex.IsMatch(candidate, @"\b(bachelor(?:'s)?|bsc|b\.sc|ba|b\.a|beng|llb|undergraduate degree)\b", RegexOptions.IgnoreCase)) return "Bachelor";
|
||||
if (Regex.IsMatch(candidate, @"\b(diploma|certificate|certification|nvq|btec|level\s*\d+|apprenticeship|associate degree)\b", RegexOptions.IgnoreCase)) return "Diploma/Certificate";
|
||||
if (Regex.IsMatch(candidate, @"\b(gcse|a-?level|secondary|high school|gymnasium)\b", RegexOptions.IgnoreCase)) return "Secondary";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
private static StructuredCvEducation NormalizeEducation(StructuredCvEducation? education)
|
||||
{
|
||||
education ??= new StructuredCvEducation();
|
||||
education.Qualification = TrimOrNull(education.Qualification);
|
||||
education.Institution = TrimOrNull(education.Institution);
|
||||
education.Location = TrimOrNull(education.Location);
|
||||
education.Start = TrimOrNull(education.Start);
|
||||
education.End = TrimOrNull(education.End);
|
||||
education.Qualification = NormalizeQualification(education.Qualification);
|
||||
education.QualificationLevel = NormalizeQualificationLevel(education.QualificationLevel, education.Qualification);
|
||||
education.Institution = NormalizeInstitution(education.Institution);
|
||||
education.Location = NormalizeLocationValue(education.Location);
|
||||
education.Start = NormalizeDateValue(education.Start);
|
||||
education.End = NormalizeDateValue(education.End);
|
||||
education.Details = CleanList(education.Details);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(education.Qualification) && !string.IsNullOrWhiteSpace(education.Institution))
|
||||
{
|
||||
var qualificationLooksInstitutional = LooksLikeInstitutionName(education.Qualification) && !LooksLikeQualification(education.Qualification);
|
||||
var institutionLooksQualification = LooksLikeQualification(education.Institution) && !LooksLikeInstitutionName(education.Institution);
|
||||
if (qualificationLooksInstitutional && institutionLooksQualification)
|
||||
{
|
||||
(education.Qualification, education.Institution) = (education.Institution, education.Qualification);
|
||||
education.QualificationLevel = NormalizeQualificationLevel(education.QualificationLevel, education.Qualification);
|
||||
}
|
||||
}
|
||||
|
||||
return education;
|
||||
}
|
||||
|
||||
private static StructuredCvCertification NormalizeCertification(StructuredCvCertification? certification)
|
||||
{
|
||||
certification ??= new StructuredCvCertification();
|
||||
certification.Name = NormalizeQualification(certification.Name);
|
||||
certification.Issuer = NormalizeInstitution(certification.Issuer);
|
||||
certification.Location = NormalizeLocationValue(certification.Location);
|
||||
certification.Date = NormalizeDateValue(certification.Date);
|
||||
certification.Details = CleanList(certification.Details);
|
||||
return certification;
|
||||
}
|
||||
|
||||
private static StructuredCvProject NormalizeProject(StructuredCvProject? project)
|
||||
{
|
||||
project ??= new StructuredCvProject();
|
||||
project.Name = NormalizeQualification(project.Name);
|
||||
project.Role = NormalizeJobTitle(project.Role);
|
||||
project.Location = NormalizeLocationValue(project.Location);
|
||||
project.Start = NormalizeDateValue(project.Start);
|
||||
project.End = NormalizeDateValue(project.End);
|
||||
project.Bullets = CleanList(project.Bullets)
|
||||
.Select(NormalizeBullet)
|
||||
.Where(bullet => bullet is not null)
|
||||
.Select(bullet => bullet!)
|
||||
.ToList();
|
||||
project.Skills = CleanList(project.Skills);
|
||||
return project;
|
||||
}
|
||||
|
||||
private static StructuredCvLanguage NormalizeLanguage(StructuredCvLanguage? language)
|
||||
{
|
||||
language ??= new StructuredCvLanguage();
|
||||
language.Name = TrimOrNull(language.Name);
|
||||
language.Level = TrimOrNull(language.Level);
|
||||
|
||||
var originalName = TrimOrNull(language.Name);
|
||||
var normalizedName = HumanLanguageCatalog.NormalizeLanguageName(originalName);
|
||||
var normalizedLevel = HumanLanguageCatalog.ExtractLevel(language.Level) ?? HumanLanguageCatalog.ExtractLevel(originalName);
|
||||
|
||||
language.Name = normalizedName is not null && normalizedLevel is not null ? normalizedName : null;
|
||||
language.Level = normalizedLevel;
|
||||
language.Notes = TrimOrNull(language.Notes);
|
||||
return language;
|
||||
}
|
||||
@@ -267,12 +579,42 @@ public static class StructuredCvProfileJson
|
||||
AddIf(lines, $"### {education.Qualification}".Trim());
|
||||
var meta = string.Join(" | ", new[] { education.Institution, education.Location, FormatDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddIf(lines, meta);
|
||||
if (!string.IsNullOrWhiteSpace(education.QualificationLevel)) AddIf(lines, $"Level: {education.QualificationLevel}");
|
||||
lines.AddRange(education.Details.Select(detail => $"- {detail}"));
|
||||
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
|
||||
}
|
||||
AddSectionIfAny(sections, "Education", lines);
|
||||
}
|
||||
|
||||
if (profile.Certifications.Count > 0)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var certification in profile.Certifications)
|
||||
{
|
||||
AddIf(lines, $"### {certification.Name}".Trim());
|
||||
var meta = string.Join(" | ", new[] { certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddIf(lines, meta);
|
||||
lines.AddRange(certification.Details.Select(detail => $"- {detail}"));
|
||||
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
|
||||
}
|
||||
AddSectionIfAny(sections, "Certifications", lines);
|
||||
}
|
||||
|
||||
if (profile.Projects.Count > 0)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var project in profile.Projects)
|
||||
{
|
||||
AddIf(lines, $"### {project.Name}".Trim());
|
||||
var meta = string.Join(" | ", new[] { project.Role, project.Location, FormatDateRange(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddIf(lines, meta);
|
||||
lines.AddRange(project.Bullets.Select(bullet => $"- {bullet}"));
|
||||
if (project.Skills.Count > 0) AddIf(lines, $"Skills: {string.Join(", ", project.Skills)}");
|
||||
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
|
||||
}
|
||||
AddSectionIfAny(sections, "Projects", lines);
|
||||
}
|
||||
|
||||
AddSectionIfAny(sections, "Skills", profile.Skills);
|
||||
|
||||
if (profile.Languages.Count > 0)
|
||||
@@ -360,7 +702,13 @@ public static class StructuredCvProfileJson
|
||||
}
|
||||
}
|
||||
|
||||
return new StructuredCvLanguage { Name = name.NullIfWhitespace(), Level = level, Notes = notes };
|
||||
var normalizedLevel = HumanLanguageCatalog.ExtractLevel(level) ?? HumanLanguageCatalog.ExtractLevel(item);
|
||||
return new StructuredCvLanguage
|
||||
{
|
||||
Name = normalizedLevel is not null ? HumanLanguageCatalog.NormalizeLanguageName(name) : null,
|
||||
Level = normalizedLevel,
|
||||
Notes = notes,
|
||||
};
|
||||
})
|
||||
.Where(language => !string.IsNullOrWhiteSpace(language.Name))
|
||||
.ToList();
|
||||
@@ -391,7 +739,11 @@ public static class StructuredCvProfileJson
|
||||
job.IsCurrent = string.Equals(job.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(job.End, "current", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var metadataWithoutDates = metadata.Select(line => line.Replace(dateValue ?? string.Empty, string.Empty).Trim(' ', '|', ',', '-')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
var metadataWithoutDates = metadata
|
||||
.Select(line => string.IsNullOrWhiteSpace(dateValue) ? line : line.Replace(dateValue, string.Empty))
|
||||
.Select(line => line.Trim(' ', '|', ',', '-'))
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
if (metadataWithoutDates.Count > 0) job.Company = metadataWithoutDates[0].NullIfWhitespace();
|
||||
if (metadataWithoutDates.Count > 1) job.Location = metadataWithoutDates[1].NullIfWhitespace();
|
||||
|
||||
@@ -428,14 +780,85 @@ public static class StructuredCvProfileJson
|
||||
education.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace();
|
||||
}
|
||||
|
||||
var metadataWithoutDates = metadata.Select(line => line.Replace(dateValue ?? string.Empty, string.Empty).Trim(' ', '|', ',', '-')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
var metadataWithoutDates = metadata
|
||||
.Select(line => string.IsNullOrWhiteSpace(dateValue) ? line : line.Replace(dateValue, string.Empty))
|
||||
.Select(line => line.Trim(' ', '|', ',', '-'))
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
if (metadataWithoutDates.Count > 0) education.Institution = metadataWithoutDates[0].NullIfWhitespace();
|
||||
if (metadataWithoutDates.Count > 1) education.Location = metadataWithoutDates[1].NullIfWhitespace();
|
||||
|
||||
education.Details = lines.Skip(1).Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
education.QualificationLevel = NormalizeQualificationLevel(null, education.Qualification);
|
||||
return string.IsNullOrWhiteSpace(education.Qualification) && string.IsNullOrWhiteSpace(education.Institution) && education.Details.Count == 0 ? null : education;
|
||||
}
|
||||
|
||||
private static List<StructuredCvCertification> ParseCertifications(string content)
|
||||
{
|
||||
var blocks = SplitBlocks(content);
|
||||
return blocks.Select(ParseCertificationBlock).Where(certification => certification is not null).Select(certification => certification!).ToList();
|
||||
}
|
||||
|
||||
private static StructuredCvCertification? ParseCertificationBlock(string block)
|
||||
{
|
||||
var lines = block.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
if (lines.Count == 0) return null;
|
||||
|
||||
var certification = new StructuredCvCertification();
|
||||
if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' ');
|
||||
certification.Name = lines[0].NullIfWhitespace();
|
||||
|
||||
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList();
|
||||
certification.Date = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
|
||||
var metadataWithoutDates = metadata
|
||||
.Select(line => string.IsNullOrWhiteSpace(certification.Date) ? line : line.Replace(certification.Date, string.Empty))
|
||||
.Select(line => line.Trim(' ', '|', ',', '-'))
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
if (metadataWithoutDates.Count > 0) certification.Issuer = metadataWithoutDates[0].NullIfWhitespace();
|
||||
if (metadataWithoutDates.Count > 1) certification.Location = metadataWithoutDates[1].NullIfWhitespace();
|
||||
certification.Details = lines.Skip(1).Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
return string.IsNullOrWhiteSpace(certification.Name) && string.IsNullOrWhiteSpace(certification.Issuer) ? null : certification;
|
||||
}
|
||||
|
||||
private static List<StructuredCvProject> ParseProjects(string content)
|
||||
{
|
||||
var blocks = SplitBlocks(content);
|
||||
return blocks.Select(ParseProjectBlock).Where(project => project is not null).Select(project => project!).ToList();
|
||||
}
|
||||
|
||||
private static StructuredCvProject? ParseProjectBlock(string block)
|
||||
{
|
||||
var lines = block.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
if (lines.Count == 0) return null;
|
||||
|
||||
var project = new StructuredCvProject();
|
||||
if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' ');
|
||||
project.Name = lines[0].NullIfWhitespace();
|
||||
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line) && !line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)(?:\s*[-–]\s*(?:(?:\w+\s+)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
|
||||
if (!string.IsNullOrWhiteSpace(dateValue))
|
||||
{
|
||||
var parts = Regex.Split(dateValue, "\\s*[-–]\\s*");
|
||||
project.Start = parts.FirstOrDefault().NullIfWhitespace();
|
||||
project.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace();
|
||||
}
|
||||
|
||||
var metadataWithoutDates = metadata
|
||||
.Select(line => string.IsNullOrWhiteSpace(dateValue) ? line : line.Replace(dateValue, string.Empty))
|
||||
.Select(line => line.Trim(' ', '|', ',', '-'))
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
if (metadataWithoutDates.Count > 0) project.Role = metadataWithoutDates[0].NullIfWhitespace();
|
||||
if (metadataWithoutDates.Count > 1) project.Location = metadataWithoutDates[1].NullIfWhitespace();
|
||||
project.Bullets = lines.Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
project.Skills = lines
|
||||
.Where(line => line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(line => SplitList(line[(line.IndexOf(':') + 1)..]))
|
||||
.ToList();
|
||||
return string.IsNullOrWhiteSpace(project.Name) && string.IsNullOrWhiteSpace(project.Role) && project.Bullets.Count == 0 ? null : project;
|
||||
}
|
||||
|
||||
private static List<string> SplitBlocks(string content)
|
||||
{
|
||||
var normalized = content.Replace("\r\n", "\n").Trim();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed class TailoredCvDraft
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = string.Empty;
|
||||
public int JobApplicationId { get; set; }
|
||||
public JobApplication? JobApplication { get; set; }
|
||||
public int? CanonicalProfileVersion { get; set; }
|
||||
public string TemplateId { get; set; } = "ats-minimal";
|
||||
public string? Headline { get; set; }
|
||||
public string? SummaryJson { get; set; }
|
||||
public string? SelectedSkillsJson { get; set; }
|
||||
public string? ExperienceJson { get; set; }
|
||||
public string? EducationJson { get; set; }
|
||||
public string? CustomSectionsJson { get; set; }
|
||||
public string? RenderOptionsJson { get; set; }
|
||||
public string? GenerationContextHash { get; set; }
|
||||
public DateTimeOffset? LastGeneratedAtUtc { get; set; }
|
||||
public DateTimeOffset? LastEditedAtUtc { get; set; }
|
||||
public string Status { get; set; } = "generated";
|
||||
}
|
||||
|
||||
public sealed class TailoredCvDocument
|
||||
{
|
||||
public string TemplateId { get; set; } = "ats-minimal";
|
||||
public string? Headline { get; set; }
|
||||
public List<string> Summary { get; set; } = new();
|
||||
public List<string> SelectedSkills { get; set; } = new();
|
||||
public List<TailoredCvExperienceItem> Experience { get; set; } = new();
|
||||
public List<TailoredCvEducationItem> Education { get; set; } = new();
|
||||
public List<TailoredCvCustomSection> CustomSections { get; set; } = new();
|
||||
public TailoredCvRenderOptions RenderOptions { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TailoredCvExperienceItem
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Company { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
public string? End { get; set; }
|
||||
public bool IsCurrent { get; set; }
|
||||
public List<string> Bullets { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TailoredCvEducationItem
|
||||
{
|
||||
public string? Qualification { get; set; }
|
||||
public string? QualificationLevel { get; set; }
|
||||
public string? Institution { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
public string? End { get; set; }
|
||||
public List<string> Details { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TailoredCvCustomSection
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public List<string> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class TailoredCvRenderOptions
|
||||
{
|
||||
public bool ShowPhoto { get; set; }
|
||||
public string PageMode { get; set; } = "one-page";
|
||||
public string AccentColor { get; set; } = "slate";
|
||||
public List<string> SectionOrder { get; set; } = new() { "summary", "skills", "experience", "education", "custom" };
|
||||
public string BulletDensity { get; set; } = "balanced";
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public static class TailoredCvDraftJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static TailoredCvDocument Empty() => Normalize(new TailoredCvDocument());
|
||||
|
||||
public static TailoredCvDocument FromDraft(TailoredCvDraft? draft)
|
||||
{
|
||||
if (draft is null) return Empty();
|
||||
|
||||
var document = new TailoredCvDocument
|
||||
{
|
||||
TemplateId = string.IsNullOrWhiteSpace(draft.TemplateId) ? "base" : draft.TemplateId.Trim(),
|
||||
Headline = TrimOrNull(draft.Headline),
|
||||
Summary = DeserializeList(draft.SummaryJson),
|
||||
SelectedSkills = DeserializeList(draft.SelectedSkillsJson),
|
||||
Experience = DeserializeList<TailoredCvExperienceItem>(draft.ExperienceJson)
|
||||
.Select(NormalizeExperience)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || !string.IsNullOrWhiteSpace(item.Company) || item.Bullets.Count > 0)
|
||||
.ToList(),
|
||||
Education = DeserializeList<TailoredCvEducationItem>(draft.EducationJson)
|
||||
.Select(NormalizeEducation)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Qualification) || !string.IsNullOrWhiteSpace(item.Institution) || item.Details.Count > 0)
|
||||
.ToList(),
|
||||
CustomSections = DeserializeList<TailoredCvCustomSection>(draft.CustomSectionsJson)
|
||||
.Select(NormalizeCustomSection)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || item.Items.Count > 0)
|
||||
.ToList(),
|
||||
RenderOptions = DeserializeObject<TailoredCvRenderOptions>(draft.RenderOptionsJson) ?? new TailoredCvRenderOptions(),
|
||||
};
|
||||
|
||||
return Normalize(document);
|
||||
}
|
||||
|
||||
public static void ApplyToDraft(TailoredCvDraft draft, TailoredCvDocument? document)
|
||||
{
|
||||
var normalized = Normalize(document);
|
||||
draft.TemplateId = normalized.TemplateId;
|
||||
draft.Headline = TrimOrNull(normalized.Headline);
|
||||
draft.SummaryJson = JsonSerializer.Serialize(normalized.Summary, SerializerOptions);
|
||||
draft.SelectedSkillsJson = JsonSerializer.Serialize(normalized.SelectedSkills, SerializerOptions);
|
||||
draft.ExperienceJson = JsonSerializer.Serialize(normalized.Experience, SerializerOptions);
|
||||
draft.EducationJson = JsonSerializer.Serialize(normalized.Education, SerializerOptions);
|
||||
draft.CustomSectionsJson = JsonSerializer.Serialize(normalized.CustomSections, SerializerOptions);
|
||||
draft.RenderOptionsJson = JsonSerializer.Serialize(normalized.RenderOptions, SerializerOptions);
|
||||
}
|
||||
|
||||
public static TailoredCvDocument Normalize(TailoredCvDocument? document)
|
||||
{
|
||||
document ??= new TailoredCvDocument();
|
||||
document.TemplateId = string.IsNullOrWhiteSpace(document.TemplateId) ? "ats-minimal" : document.TemplateId.Trim();
|
||||
document.Headline = TrimOrNull(document.Headline);
|
||||
document.Summary = CleanList(document.Summary);
|
||||
document.SelectedSkills = CleanList(document.SelectedSkills);
|
||||
document.Experience = (document.Experience ?? new List<TailoredCvExperienceItem>())
|
||||
.Select(NormalizeExperience)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || !string.IsNullOrWhiteSpace(item.Company) || item.Bullets.Count > 0)
|
||||
.ToList();
|
||||
document.Education = (document.Education ?? new List<TailoredCvEducationItem>())
|
||||
.Select(NormalizeEducation)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Qualification) || !string.IsNullOrWhiteSpace(item.Institution) || item.Details.Count > 0)
|
||||
.ToList();
|
||||
document.CustomSections = (document.CustomSections ?? new List<TailoredCvCustomSection>())
|
||||
.Select(NormalizeCustomSection)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || item.Items.Count > 0)
|
||||
.ToList();
|
||||
document.RenderOptions ??= new TailoredCvRenderOptions();
|
||||
document.RenderOptions.PageMode = string.IsNullOrWhiteSpace(document.RenderOptions.PageMode) ? "one-page" : document.RenderOptions.PageMode.Trim();
|
||||
document.RenderOptions.AccentColor = string.IsNullOrWhiteSpace(document.RenderOptions.AccentColor) ? "slate" : document.RenderOptions.AccentColor.Trim();
|
||||
document.RenderOptions.BulletDensity = string.IsNullOrWhiteSpace(document.RenderOptions.BulletDensity) ? "balanced" : document.RenderOptions.BulletDensity.Trim();
|
||||
document.RenderOptions.SectionOrder = CleanList(document.RenderOptions.SectionOrder);
|
||||
if (document.RenderOptions.SectionOrder.Count == 0)
|
||||
{
|
||||
document.RenderOptions.SectionOrder = new List<string> { "summary", "skills", "experience", "education", "custom" };
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
public static string RenderPlainText(TailoredCvDocument? document)
|
||||
{
|
||||
var normalized = Normalize(document);
|
||||
var lines = new List<string>();
|
||||
|
||||
AddLine(lines, normalized.Headline);
|
||||
if (normalized.Summary.Count > 0)
|
||||
{
|
||||
AddBlock(lines, "Professional Summary", normalized.Summary.Select(item => $"- {item}"));
|
||||
}
|
||||
|
||||
if (normalized.SelectedSkills.Count > 0)
|
||||
{
|
||||
AddBlock(lines, "Core Skills", normalized.SelectedSkills);
|
||||
}
|
||||
|
||||
if (normalized.Experience.Count > 0)
|
||||
{
|
||||
var block = new List<string>();
|
||||
foreach (var item in normalized.Experience)
|
||||
{
|
||||
AddLine(block, item.Title);
|
||||
var meta = string.Join(" | ", new[]
|
||||
{
|
||||
item.Company,
|
||||
item.Location,
|
||||
FormatDateRange(item.Start, item.End, item.IsCurrent)
|
||||
}.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddLine(block, meta);
|
||||
foreach (var bullet in item.Bullets)
|
||||
{
|
||||
AddLine(block, $"- {bullet}");
|
||||
}
|
||||
AddLine(block, string.Empty);
|
||||
}
|
||||
AddBlock(lines, "Experience", block);
|
||||
}
|
||||
|
||||
if (normalized.Education.Count > 0)
|
||||
{
|
||||
var block = new List<string>();
|
||||
foreach (var item in normalized.Education)
|
||||
{
|
||||
AddLine(block, string.IsNullOrWhiteSpace(item.QualificationLevel) ? item.Qualification : $"{item.Qualification} ({item.QualificationLevel})");
|
||||
var meta = string.Join(" | ", new[]
|
||||
{
|
||||
item.Institution,
|
||||
item.Location,
|
||||
FormatDateRange(item.Start, item.End, false)
|
||||
}.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddLine(block, meta);
|
||||
foreach (var detail in item.Details)
|
||||
{
|
||||
AddLine(block, $"- {detail}");
|
||||
}
|
||||
AddLine(block, string.Empty);
|
||||
}
|
||||
AddBlock(lines, "Education", block);
|
||||
}
|
||||
|
||||
foreach (var section in normalized.CustomSections)
|
||||
{
|
||||
AddBlock(lines, section.Title ?? "Additional Information", section.Items);
|
||||
}
|
||||
|
||||
return string.Join("\n\n", lines.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim();
|
||||
}
|
||||
|
||||
private static TailoredCvExperienceItem NormalizeExperience(TailoredCvExperienceItem? item)
|
||||
{
|
||||
item ??= new TailoredCvExperienceItem();
|
||||
item.Title = TrimOrNull(item.Title);
|
||||
item.Company = TrimOrNull(item.Company);
|
||||
item.Location = TrimOrNull(item.Location);
|
||||
item.Start = TrimOrNull(item.Start);
|
||||
item.End = TrimOrNull(item.End);
|
||||
item.Bullets = CleanList(item.Bullets);
|
||||
item.IsCurrent = item.IsCurrent || string.Equals(item.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(item.End, "current", StringComparison.OrdinalIgnoreCase);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static TailoredCvEducationItem NormalizeEducation(TailoredCvEducationItem? item)
|
||||
{
|
||||
item ??= new TailoredCvEducationItem();
|
||||
item.Qualification = TrimOrNull(item.Qualification);
|
||||
item.QualificationLevel = TrimOrNull(item.QualificationLevel);
|
||||
item.Institution = TrimOrNull(item.Institution);
|
||||
item.Location = TrimOrNull(item.Location);
|
||||
item.Start = TrimOrNull(item.Start);
|
||||
item.End = TrimOrNull(item.End);
|
||||
item.Details = CleanList(item.Details);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static TailoredCvCustomSection NormalizeCustomSection(TailoredCvCustomSection? item)
|
||||
{
|
||||
item ??= new TailoredCvCustomSection();
|
||||
item.Title = TrimOrNull(item.Title);
|
||||
item.Items = CleanList(item.Items);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static List<string> DeserializeList(string? json)
|
||||
{
|
||||
return DeserializeList<string>(json)
|
||||
.Select(value => value?.Trim() ?? string.Empty)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<T> DeserializeList<T>(string? json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? new List<T>()
|
||||
: JsonSerializer.Deserialize<List<T>>(json, SerializerOptions) ?? new List<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<T>();
|
||||
}
|
||||
}
|
||||
|
||||
private static T? DeserializeObject<T>(string? json) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<T>(json, SerializerOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> CleanList(IEnumerable<string>? values)
|
||||
{
|
||||
return (values ?? Array.Empty<string>())
|
||||
.Select(value => value?.Trim() ?? string.Empty)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? FormatDateRange(string? start, string? end, bool isCurrent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null;
|
||||
if (string.IsNullOrWhiteSpace(start)) return end;
|
||||
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
||||
}
|
||||
|
||||
private static void AddBlock(List<string> lines, string title, IEnumerable<string> body)
|
||||
{
|
||||
var content = string.Join("\n", body.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim();
|
||||
if (string.IsNullOrWhiteSpace(content)) return;
|
||||
lines.Add($"{title}\n{content}");
|
||||
}
|
||||
|
||||
private static void AddLine(List<string> lines, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)) lines.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
|
||||
|
||||
## Quickstart (Docker)
|
||||
|
||||
This runs: frontend (nginx), backend API, and the AI service.
|
||||
This runs: frontend (nginx), backend API, the local AI service, and an Ollama container for hybrid CV block classification.
|
||||
|
||||
1) Create a `.env` file next to `docker-compose.yml` (you can start from `.env.example`).
|
||||
|
||||
@@ -108,9 +108,15 @@ The API calls a local FastAPI service to generate summaries. If it’s not runni
|
||||
With Docker (recommended):
|
||||
|
||||
```bash
|
||||
docker compose up --build ai-service
|
||||
# One command for local Ollama startup + pull + AI-service restart
|
||||
OLLAMA_MODEL=qwen2.5:7b ./scripts/start-ollama-cv.sh
|
||||
|
||||
# Then start the rest of the app if needed
|
||||
docker compose up --build -d backend frontend
|
||||
```
|
||||
|
||||
The first Ollama startup is usually quick, but the first model pull and first generation can take a while. After the model is cached in the `ollama_data` volume, later restarts are much faster.
|
||||
|
||||
Or run directly from `tools/summarizer/` (see `tools/summarizer/README.md`).
|
||||
|
||||
## Configuration
|
||||
|
||||
+5
-1
@@ -52,6 +52,8 @@ AUTH_ADMIN_EMAIL=you@example.com
|
||||
AUTH_ADMIN_PASSWORD=replace_with_strong_password
|
||||
APP_PUBLIC_BASE_URL=https://your-domain.example
|
||||
AI_SERVICE_BASE_URL=http://ai-service:8001
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b
|
||||
EMAIL_FOLLOWUPREMINDERS_ENABLED=true
|
||||
EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS=2
|
||||
# Optional backward-compatible alias if older config still references the previous name:
|
||||
@@ -87,7 +89,8 @@ If this app is going to be a real production service on Ubuntu:
|
||||
2. Gitea Actions runs tests
|
||||
3. if green, workflow uploads repo to server
|
||||
4. `deploy/deploy.sh` links `/opt/job-tracker/shared/.env` into the repo checkout, then runs `docker compose build && docker compose up -d`
|
||||
5. workflow checks service status after deployment
|
||||
5. if `OLLAMA_MODEL` is set, the deploy script waits for Ollama, pulls the configured model if missing, then restarts `ai-service` so hybrid CV classification can use it
|
||||
6. workflow checks service status after deployment
|
||||
|
||||
## Post-deploy verification you should also do manually the first time
|
||||
- confirm reverse proxy routes to the frontend correctly
|
||||
@@ -96,3 +99,4 @@ If this app is going to be a real production service on Ubuntu:
|
||||
- confirm AI service container is reachable from backend
|
||||
- confirm reminder and admin/system pages load
|
||||
- verify follow-up reminder emails are enabled only when intended and that links open the correct job/tab
|
||||
hat links open the correct job/tab
|
||||
|
||||
@@ -45,6 +45,11 @@ build_with_recovery
|
||||
# Force recreation so updated port mappings, env vars, and container config always apply on deploy.
|
||||
compose up -d --force-recreate --remove-orphans
|
||||
|
||||
if [ -n "${OLLAMA_MODEL:-}" ]; then
|
||||
echo "Post-deploy Ollama warmup enabled for model: ${OLLAMA_MODEL}"
|
||||
./scripts/start-ollama-cv.sh
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
compose ps
|
||||
|
||||
|
||||
@@ -71,8 +71,13 @@ services:
|
||||
build:
|
||||
context: ./tools/summarizer
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b}
|
||||
ports:
|
||||
- "8001:8001"
|
||||
depends_on:
|
||||
- ollama
|
||||
networks:
|
||||
- default
|
||||
- shared_services
|
||||
@@ -83,8 +88,29 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- "11434:11434"
|
||||
environment:
|
||||
- OLLAMA_HOST=0.0.0.0:11434
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
networks:
|
||||
- default
|
||||
- shared_services
|
||||
restart: unless-stopped
|
||||
gpus: all
|
||||
healthcheck:
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 20s
|
||||
timeout: 15s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
jobtracker_data:
|
||||
ollama_data:
|
||||
|
||||
networks:
|
||||
shared_services:
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# CV builder, parser, benchmark, and Ollama admin integration
|
||||
|
||||
## What changed
|
||||
|
||||
This branch upgrades the Profile CV flow from a text-only rewrite helper into a template-driven CV builder backed by the server-side renderer/PDF pipeline, strengthens CV normalization around location and qualification handling, adds a repeatable local corpus benchmark workflow, and expands the admin system page with richer Ollama visibility.
|
||||
|
||||
## Profile CV builder
|
||||
|
||||
### New backend capabilities
|
||||
|
||||
`JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
|
||||
- Hardened `POST /api/profile-cv/rewrite-section`
|
||||
- accepts flexible `jobApplicationId` payloads (number or blank string)
|
||||
- uses richer saved-job context for tailoring
|
||||
- logs empty AI responses with useful context
|
||||
- Added `GET /api/profile-cv/templates`
|
||||
- Added `POST /api/profile-cv/rewrite-preview`
|
||||
- rewrites either the whole CV or one selected section
|
||||
- rebuilds structured CV from the rewritten full text
|
||||
- maps the result into the shared template renderer
|
||||
- returns rendered HTML, file name, rewritten text, and full replacement text
|
||||
- Added `POST /api/profile-cv/export-pdf`
|
||||
- uses the same rendered HTML and the shared Playwright exporter
|
||||
|
||||
### Frontend flow
|
||||
|
||||
`job-tracker-ui/src/pages/ProfilePage.tsx`
|
||||
|
||||
- Replaced the old rewrite draft box with a template-driven builder section.
|
||||
- Users can:
|
||||
- choose from 6 templates
|
||||
- optionally target one section
|
||||
- target by free-text role or saved job
|
||||
- inspect the rewritten content
|
||||
- inspect the actual rendered preview
|
||||
- download a PDF
|
||||
- replace the master CV with the rebuilt full-text result
|
||||
|
||||
## Templates
|
||||
|
||||
Shared renderer: `JobTrackerApi/Services/CvTemplateRenderer.cs`
|
||||
|
||||
Available templates:
|
||||
- `ats-minimal`
|
||||
- `harvard`
|
||||
- `auckland`
|
||||
- `edinburgh`
|
||||
- `monarch`
|
||||
- `fjord`
|
||||
|
||||
### Adding a new template
|
||||
|
||||
1. Add the new template id to `NormalizeTemplateId()` in:
|
||||
- `JobTrackerApi/Services/CvTemplateRenderer.cs`
|
||||
- `JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
2. Add a render branch in `CvTemplateRenderer.Render()`.
|
||||
3. Add a descriptor to `GetCvTemplateDescriptors()`.
|
||||
4. Add the matching card entry in `job-tracker-ui/src/pages/ProfilePage.tsx` if you want a custom preview card.
|
||||
|
||||
## PDF generation
|
||||
|
||||
The master CV builder now reuses the existing server-side pipeline:
|
||||
|
||||
1. rewrite full text / section
|
||||
2. rebuild structured CV
|
||||
3. map to `TailoredCvDocument`
|
||||
4. render HTML via `ICvTemplateRenderer`
|
||||
5. export PDF via `ICvPdfExporter` / Playwright
|
||||
|
||||
This keeps PDF output visually aligned with the selected template and avoids a separate client-only print implementation.
|
||||
|
||||
## Parser and structured CV changes
|
||||
|
||||
### Shared schema
|
||||
|
||||
`Models/StructuredCvProfile.cs`
|
||||
|
||||
Added:
|
||||
- `education[].qualificationLevel`
|
||||
- top-level `certifications[]`
|
||||
- top-level `projects[]`
|
||||
|
||||
`qualification` remains the original preserved text.
|
||||
|
||||
### Normalization improvements
|
||||
|
||||
`Models/StructuredCvProfileJson.cs`
|
||||
|
||||
- tighter location sanitization to avoid skill or role spillover into location fields
|
||||
- qualification level normalization to one of:
|
||||
- `Secondary`
|
||||
- `Diploma/Certificate`
|
||||
- `Bachelor`
|
||||
- `Master`
|
||||
- `PhD`
|
||||
- `Other`
|
||||
- first-class normalization for certifications and projects
|
||||
- section reconstruction now includes certifications and projects
|
||||
|
||||
### Extraction prompt improvements
|
||||
|
||||
`JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
|
||||
The LLM extraction prompt now explicitly asks for:
|
||||
- qualification level enum
|
||||
- certifications
|
||||
- projects
|
||||
- strict location separation rules
|
||||
- preservation of original qualification text
|
||||
|
||||
## Benchmark workflow
|
||||
|
||||
### Runner
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
```bash
|
||||
CV_BENCHMARK_OUTPUT_DIR=/absolute/output/path \
|
||||
CV_BENCHMARK_APPROVED_DIR=/absolute/approved/fixtures/path \
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
The runner scans:
|
||||
|
||||
- `/home/pi/cvs`
|
||||
|
||||
Supported corpus file types:
|
||||
- PDF
|
||||
- DOCX
|
||||
- TXT
|
||||
- MD
|
||||
|
||||
### Outputs
|
||||
|
||||
The runner writes:
|
||||
|
||||
- `index.json` — machine-readable summary
|
||||
- `report.md` — markdown overview
|
||||
- `outputs/*.json` — latest normalized structured output per CV
|
||||
- `candidate-fixtures/*.json` — created when no approved fixture exists yet
|
||||
|
||||
Approved fixtures are local by design and should be reviewed manually before being promoted into the approved fixture path you use for regression comparisons.
|
||||
|
||||
### Admin review
|
||||
|
||||
`GET /api/admin/system/cv-benchmark`
|
||||
|
||||
The admin system page surfaces:
|
||||
- benchmark root path
|
||||
- last benchmark update time
|
||||
- latest markdown summary
|
||||
|
||||
## Ollama admin visibility
|
||||
|
||||
### Python health endpoint
|
||||
|
||||
`tools/summarizer/app.py`
|
||||
|
||||
`GET /health` now returns additional Ollama metadata when configured/reachable:
|
||||
- `ollama_version`
|
||||
- `ollama_installed_models`
|
||||
- `ollama_loaded_models`
|
||||
- `ollama_loaded_count`
|
||||
|
||||
### Backend propagation
|
||||
|
||||
`JobTrackerApi/Services/SummarizerService.cs`
|
||||
|
||||
The backend metrics shape now carries those fields through to admin consumers.
|
||||
|
||||
### Admin UI
|
||||
|
||||
`job-tracker-ui/src/pages/AdminSystemPage.tsx`
|
||||
|
||||
The system page now shows:
|
||||
- Ollama version
|
||||
- loaded model count
|
||||
- installed model chips
|
||||
- loaded model chips
|
||||
- benchmark summary panel
|
||||
|
||||
## Verification used on this branch
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter ProfileCvControllerTests
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter "ProfileCvControllerTests|AuthAndSystemControllerTests|JobApplicationsApplicationPackageTests"
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter CvCorpusHarnessTests
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/profile-page.test.tsx
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/admin-system-page.test.tsx
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/profile-page.test.tsx src/admin-system-page.test.tsx src/job-details-generated-drafts.test.tsx
|
||||
```
|
||||
|
||||
### Benchmark runner
|
||||
|
||||
```bash
|
||||
CV_BENCHMARK_OUTPUT_DIR="$(pwd)/tmp/cv-benchmarks/latest" \
|
||||
CV_BENCHMARK_APPROVED_DIR="$(pwd)/tmp/cv-benchmarks/approved" \
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
### Python service tests
|
||||
|
||||
The summarizer Python unit tests were updated for the new health payload, but this machine currently lacks `pip` / `venv` support (`python3 -m venv` fails because `python3.12-venv` is not installed), so test execution is environment-blocked here. Once Python packaging is available, run:
|
||||
|
||||
```bash
|
||||
cd tools/summarizer
|
||||
python3 -m pytest -q tests/test_app.py
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { Suspense, lazy, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Box, Button, CssBaseline, Typography } from "@mui/material";
|
||||
import { Box, Button, CssBaseline, IconButton, Typography } from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
@@ -99,6 +99,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [quickOpen, setQuickOpen] = useState(false);
|
||||
@@ -165,9 +166,35 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
];
|
||||
|
||||
const rightActions = (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
|
||||
{isJobs ? <Button variant="contained" onClick={() => setAddOpen(true)}>{t("addJob")}</Button> : null}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexWrap: "nowrap",
|
||||
justifyContent: "flex-end",
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
flex: { xs: 1, sm: "0 0 auto" },
|
||||
}}
|
||||
>
|
||||
{compactHeaderActions ? (
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("quickSearch")}
|
||||
onClick={() => setQuickOpen(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
|
||||
>
|
||||
<SearchIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)}>{t("quickSearch")}</Button>
|
||||
)}
|
||||
{isJobs ? (
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)} sx={{ flex: { xs: 1, sm: "0 0 auto" }, minHeight: 42 }}>
|
||||
{t("addJob")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ describe('AdminSystemPage', () => {
|
||||
gpuName: null,
|
||||
ocrAvailable: true,
|
||||
ocrLanguages: 'eng',
|
||||
ollamaConfigured: true,
|
||||
ollamaReachable: true,
|
||||
ollamaModel: 'qwen2.5:7b',
|
||||
ollamaModelAvailable: true,
|
||||
ollamaVersion: '0.7.0',
|
||||
ollamaInstalledModels: ['qwen2.5:7b', 'nomic-embed-text'],
|
||||
ollamaLoadedModels: ['qwen2.5:7b'],
|
||||
ollamaLoadedCount: 1,
|
||||
healthLatencyMs: 12.4,
|
||||
probeLatencyMs: 25.8,
|
||||
lastProbeAt: '2026-03-23T10:00:00Z',
|
||||
@@ -82,6 +90,48 @@ describe('AdminSystemPage', () => {
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === '/admin/system/cv-benchmark') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
rootPath: '/data/CvBenchmarks/latest',
|
||||
lastUpdatedAtUtc: '2026-03-23T10:10:00Z',
|
||||
reportMarkdown: '# CV benchmark report\n\n- Files: 4',
|
||||
indexJson: JSON.stringify({
|
||||
CorpusRoot: '/home/pi/cvs',
|
||||
OutputRoot: '/data/CvBenchmarks/latest',
|
||||
GeneratedAtUtc: '2026-03-23T10:10:00Z',
|
||||
TotalFiles: 4,
|
||||
AverageCoverage: 0.72,
|
||||
AverageConfidence: 0.66,
|
||||
AverageConsistency: 0.94,
|
||||
FilesWithSuspiciousLocations: 1,
|
||||
MissingApprovedFixtures: 4,
|
||||
Entries: [
|
||||
{
|
||||
FileName: 'cv.txt',
|
||||
Slug: 'cv-txt',
|
||||
Extension: '.txt',
|
||||
Characters: 2000,
|
||||
OutputPath: '/data/CvBenchmarks/latest/outputs/cv-txt.json',
|
||||
ApprovedFixturePath: null,
|
||||
CandidateFixturePath: '/data/CvBenchmarks/latest/candidate-fixtures/cv-txt.json',
|
||||
ContactLocation: 'San Francisco, Hobbies',
|
||||
FirstJob: '* July',
|
||||
FirstJobLocation: null,
|
||||
FirstEducation: '* September',
|
||||
FirstEducationLocation: null,
|
||||
QualificationLevels: ['Other'],
|
||||
SuspiciousLocations: [],
|
||||
CoverageScore: 0.5,
|
||||
ConfidenceScore: 0.65,
|
||||
ConsistencyScore: 0.8,
|
||||
DiffSummary: 'No approved fixture yet — candidate fixture written.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.put.mockResolvedValue({
|
||||
@@ -117,6 +167,12 @@ describe('AdminSystemPage', () => {
|
||||
|
||||
expect(screen.getByText(/25.8 ms probe/i)).toBeTruthy();
|
||||
expect(screen.getByText('OCR eng')).toBeTruthy();
|
||||
expect(screen.getAllByText(/ollama configured/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/ollama version/i)).toBeTruthy();
|
||||
expect(screen.getByText(/model · qwen2.5:7b/i)).toBeTruthy();
|
||||
expect(screen.getByText(/cv benchmark review/i)).toBeTruthy();
|
||||
expect(screen.getByText(/top parser findings/i)).toBeTruthy();
|
||||
expect(screen.getByText(/suspicious contact location: san francisco, hobbies/i)).toBeTruthy();
|
||||
expect(screen.getByText('OCR avg latency')).toBeTruthy();
|
||||
expect(screen.getByText('88.4 ms')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { Company } from "../types";
|
||||
@@ -26,6 +29,7 @@ import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
export default function CompaniesTable() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const location = useLocation();
|
||||
@@ -93,60 +97,101 @@ export default function CompaniesTable() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0 }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
<TableCell>{t("companiesLocation")}</TableCell>
|
||||
<TableCell>{t("companiesSource")}</TableCell>
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell>{c.location ?? ""}</TableCell>
|
||||
<TableCell>{c.source ?? ""}</TableCell>
|
||||
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
{c.recruiterName ?? ""}
|
||||
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
const renderCompanyMeta = (label: string, value?: string | null) => (
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{label}</Typography>
|
||||
<Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{value || "—"}</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth maxWidth="sm">
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: { xs: 1.5, sm: 0 } }}>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.5}>
|
||||
{companies.map((c) => (
|
||||
<Paper key={c.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{c.name}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{c.location || t("companiesLocation")}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
||||
{renderCompanyMeta(t("companiesSource"), c.source)}
|
||||
{renderCompanyMeta(t("companiesPipeline"), c.pipelineStage)}
|
||||
{renderCompanyMeta(t("companiesRecruiter"), [c.recruiterName, c.recruiterEmail].filter(Boolean).join(" · "))}
|
||||
{renderCompanyMeta(t("companiesNextContact"), c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : null)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
{companies.length === 0 ? (
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<TableContainer sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("companiesName")}</TableCell>
|
||||
<TableCell>{t("companiesLocation")}</TableCell>
|
||||
<TableCell>{t("companiesSource")}</TableCell>
|
||||
<TableCell>{t("companiesPipeline")}</TableCell>
|
||||
<TableCell>{t("companiesRecruiter")}</TableCell>
|
||||
<TableCell>{t("companiesNextContact")}</TableCell>
|
||||
<TableCell width={1} align="right" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell>{c.location ?? ""}</TableCell>
|
||||
<TableCell>{c.source ?? ""}</TableCell>
|
||||
<TableCell>{c.pipelineStage ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
{c.recruiterName ?? ""}
|
||||
{c.recruiterEmail ? ` (${c.recruiterEmail})` : ""}
|
||||
</TableCell>
|
||||
<TableCell>{c.nextContactAt ? new Date(c.nextContactAt).toLocaleDateString() : ""}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => openEdit(c)}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Typography sx={{ py: 2, textAlign: "center" }}>
|
||||
{t("companiesEmpty")}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Dialog open={editOpen} onClose={() => setEditOpen(false)} fullWidth fullScreen={isMobile} maxWidth="sm">
|
||||
<DialogTitle>{t("companiesEdit")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<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={t("companiesName")}
|
||||
value={editing?.name ?? ""}
|
||||
onChange={(e) => setEditing((p) => (p ? { ...p, name: e.target.value } : p))}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||
/>
|
||||
<TextField
|
||||
label={t("companiesLocation")}
|
||||
@@ -178,7 +223,7 @@ export default function CompaniesTable() {
|
||||
label={t("companiesRecruiterLinkedIn")}
|
||||
value={recruiterLinkedIn}
|
||||
onChange={(e) => setRecruiterLinkedIn(e.target.value)}
|
||||
sx={{ gridColumn: "1 / -1" }}
|
||||
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
@@ -197,9 +242,9 @@ export default function CompaniesTable() {
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditOpen(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave}>
|
||||
<DialogActions sx={{ px: 3, pb: 3, flexDirection: { xs: "column-reverse", sm: "row" }, gap: 1 }}>
|
||||
<Button onClick={() => setEditOpen(false)} fullWidth={isMobile}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!canSave} fullWidth={isMobile}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import TrendingUpIcon from "@mui/icons-material/TrendingUp";
|
||||
@@ -110,7 +111,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.25,
|
||||
p: { xs: 1.5, sm: 2.25 },
|
||||
borderRadius: 4,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
@@ -126,6 +127,7 @@ function SectionCard({ children, sx = {} }: { children: React.ReactNode; sx?: an
|
||||
|
||||
export default function DashboardView() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [stats, setStats] = useState<JobStats | null>(null);
|
||||
@@ -153,8 +155,8 @@ export default function DashboardView() {
|
||||
|
||||
const appliedValues = analytics.map((x) => x.applied);
|
||||
const responseValues = analytics.map((x) => x.responses);
|
||||
const chartWidth = 860;
|
||||
const chartHeight = 250;
|
||||
const chartWidth = isMobile ? Math.max(420, analytics.length * 70) : 860;
|
||||
const chartHeight = isMobile ? 210 : 250;
|
||||
const appliedPath = buildLinePath(appliedValues, chartWidth, chartHeight);
|
||||
const responsePath = buildLinePath(responseValues, chartWidth, chartHeight);
|
||||
const tagColors = [theme.palette.primary.main, theme.palette.success.main, theme.palette.warning.main, theme.palette.info.main, theme.palette.error.main];
|
||||
@@ -245,7 +247,7 @@ export default function DashboardView() {
|
||||
<Typography variant="overline" sx={{ color: theme.palette.primary.main, fontWeight: 800 }}>
|
||||
{t("dashboardHeroLabel")}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5, letterSpacing: -0.6, color: "text.primary" }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 950, mt: 0.5, letterSpacing: -0.6, color: "text.primary", overflowWrap: "anywhere" }}>
|
||||
{t("dashboardOverviewTitle")}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: "text.secondary", mt: 1.25, maxWidth: 680 }}>
|
||||
@@ -259,7 +261,18 @@ export default function DashboardView() {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
width: { xs: "100%", sm: "auto" },
|
||||
'& .MuiButton-root': {
|
||||
flex: { xs: '1 1 calc(50% - 8px)', sm: '0 0 auto' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{([6, 12, 24] as const).map((m) => (
|
||||
<Button key={m} size="small" variant={months === m ? "contained" : "outlined"} onClick={() => setMonths(m)}>
|
||||
{t("dashboardMonthsShort", { count: m })}
|
||||
@@ -300,7 +313,7 @@ export default function DashboardView() {
|
||||
{card.icon}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Box sx={{ mt: 1.5, overflowX: "auto" }}>
|
||||
<MiniSpark values={card.spark.length ? card.spark : [0, 0, 0]} color={alpha(card.tone, 0.95)} />
|
||||
</Box>
|
||||
</SectionCard>
|
||||
@@ -322,7 +335,7 @@ export default function DashboardView() {
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, overflowX: "auto" }}>
|
||||
<Box sx={{ mt: 2, overflowX: "auto", mx: isMobile ? -0.5 : 0, px: isMobile ? 0.5 : 0 }}>
|
||||
<Box sx={{ minWidth: chartWidth }}>
|
||||
<svg width={chartWidth} height={chartHeight} viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
|
||||
{[0.2, 0.4, 0.6, 0.8].map((tick) => (
|
||||
@@ -359,7 +372,7 @@ export default function DashboardView() {
|
||||
const width = funnelMax ? clamp((item.count / funnelMax) * 100, 0, 100) : 0;
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||
</Box>
|
||||
@@ -402,16 +415,17 @@ export default function DashboardView() {
|
||||
{priorityJobs.map((job) => {
|
||||
const action = getReminderAction(job);
|
||||
return (
|
||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900 }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
<Box key={job.id} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, 0.03), display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{job.company?.name ?? t("jobTableCompany")} • {job.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{action?.detail ?? job.workflowSignal?.reason ?? job.followUpReason ?? t("remindersFollowUpLabel")}</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={() => openReminderJob(job)} sx={{ width: { xs: "100%", sm: "auto" } }}>
|
||||
{action?.label ?? t("remindersOpen")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={() => openReminderJob(job)}>
|
||||
{action?.label ?? t("remindersOpen")}
|
||||
</Button>
|
||||
</Box>
|
||||
)})}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
@@ -426,9 +440,9 @@ export default function DashboardView() {
|
||||
{(overview?.topCompanies ?? []).map((item, index) => (
|
||||
<Box key={item.companyId} sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: alpha(theme.palette.primary.main, index === 0 ? 0.05 : 0.02) }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900 }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>{item.company}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{t("dashboardCompanyJobsResponses", { jobs: item.count, responses: item.responses })}</Typography>
|
||||
</Box>
|
||||
<Chip label={`${item.responseRate}%`} color={item.responseRate >= 50 ? "success" : item.responseRate >= 25 ? "warning" : "default"} variant="outlined" />
|
||||
</Box>
|
||||
@@ -451,7 +465,7 @@ export default function DashboardView() {
|
||||
return (
|
||||
<Box key={tag.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{tag.tag}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{tag.tag}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{tag.count}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ height: 10, borderRadius: 999, bgcolor: alpha(theme.palette.text.primary, 0.08), overflow: "hidden" }}>
|
||||
@@ -470,8 +484,8 @@ export default function DashboardView() {
|
||||
<Stack spacing={1.1}>
|
||||
{tagTrends.series.map((series, index) => (
|
||||
<Box key={series.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{series.tag}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{series.tag}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{series.counts.reduce((sum, value) => sum + value, 0)} total</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${series.counts.length}, 1fr)`, gap: 0.5 }}>
|
||||
|
||||
@@ -19,9 +19,10 @@ import {
|
||||
} from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse, TailoredCvDraft } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||
|
||||
import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
@@ -47,6 +48,12 @@ type PackageWorkspaceState = {
|
||||
recruiterMessage: string;
|
||||
};
|
||||
|
||||
type TailoredCvPreviewResponse = {
|
||||
templateId: string;
|
||||
html: string;
|
||||
suggestedFileName: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
jobId: number | null;
|
||||
@@ -129,6 +136,22 @@ function getWorkspaceStatus(currentValue: string, savedValue: string) {
|
||||
return { label: "Empty", color: "default" as const };
|
||||
}
|
||||
|
||||
function serializeTailoredDraft(draft: TailoredCvDraft) {
|
||||
const normalized = normalizeTailoredCvDraft(draft);
|
||||
return JSON.stringify({
|
||||
templateId: normalized.templateId,
|
||||
headline: normalized.headline ?? "",
|
||||
summary: normalized.summary,
|
||||
selectedSkills: normalized.selectedSkills,
|
||||
experience: normalized.experience,
|
||||
education: normalized.education,
|
||||
customSections: normalized.customSections,
|
||||
renderOptions: normalized.renderOptions,
|
||||
status: normalized.status,
|
||||
isLegacyFallback: normalized.isLegacyFallback,
|
||||
});
|
||||
}
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, initialFollowUpMode }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
@@ -153,13 +176,22 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
||||
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
|
||||
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
|
||||
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
|
||||
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
|
||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
||||
const [coverLetterStyle, setCoverLetterStyle] = useState<CoverLetterStyle>("balanced");
|
||||
const [tailoredCvText, setTailoredCvText] = useState("");
|
||||
const [tailoredCvDraft, setTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
|
||||
const [savedTailoredCvDraft, setSavedTailoredCvDraft] = useState<TailoredCvDraft>(emptyTailoredCvDraft());
|
||||
const [loadingTailoredCvDraft, setLoadingTailoredCvDraft] = useState(false);
|
||||
const [generatingTailoredCvDraft, setGeneratingTailoredCvDraft] = useState(false);
|
||||
const [savingTailoredCvDraft, setSavingTailoredCvDraft] = useState(false);
|
||||
const [tailoredCvPreview, setTailoredCvPreview] = useState<TailoredCvPreviewResponse | null>(null);
|
||||
const [loadingTailoredCvPreview, setLoadingTailoredCvPreview] = useState(false);
|
||||
const [exportingTailoredCvPdf, setExportingTailoredCvPdf] = useState(false);
|
||||
const [profileAvatarImageDataUrl, setProfileAvatarImageDataUrl] = useState<string | null>(null);
|
||||
const [customPhotoDataUrl, setCustomPhotoDataUrl] = useState<string | null>(null);
|
||||
const [useProfilePhoto, setUseProfilePhoto] = useState(true);
|
||||
const [packageWorkspace, setPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [savedPackageWorkspace, setSavedPackageWorkspace] = useState<PackageWorkspaceState>({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
const [packageGeneratedAt, setPackageGeneratedAt] = useState<string | null>(null);
|
||||
@@ -182,11 +214,16 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
setPackageGeneratedAt(null);
|
||||
setTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setSavedTailoredCvDraft(emptyTailoredCvDraft());
|
||||
setTailoredCvPreview(null);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
setCustomPhotoDataUrl(null);
|
||||
setUseProfilePhoto(true);
|
||||
setPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
setSavedPackageWorkspace({ coverLetter: "", applicationAnswer: "", recruiterMessage: "" });
|
||||
api.get<JobApplication>(`/jobapplications/${jobId}`).then((r) => {
|
||||
setJob(r.data);
|
||||
setTailoredCvText(r.data.tailoredCvText ?? "");
|
||||
const savedWorkspace = {
|
||||
coverLetter: r.data.coverLetterText ?? "",
|
||||
applicationAnswer: extractApplicationAnswerDraft(r.data.notes),
|
||||
@@ -206,10 +243,30 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
setJobAttachments([]);
|
||||
setSelectedAttachmentIds([]);
|
||||
});
|
||||
api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false));
|
||||
api.get(`/auth/me`).then((r) => {
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
setProfileAvatarImageDataUrl(r.data?.avatarImageDataUrl ?? null);
|
||||
}).catch(() => {
|
||||
setIsAdmin(false);
|
||||
setProfileAvatarImageDataUrl(null);
|
||||
});
|
||||
api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([]));
|
||||
}, [open, jobId, initialTab, initialFollowUpMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 3) return;
|
||||
setLoadingTailoredCvDraft(true);
|
||||
api.get<TailoredCvDraft>(`/jobapplications/${jobId}/tailored-cv-draft`).then((r) => {
|
||||
const normalized = normalizeTailoredCvDraft(r.data);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
}).catch(() => {
|
||||
const empty = emptyTailoredCvDraft();
|
||||
setTailoredCvDraft(empty);
|
||||
setSavedTailoredCvDraft(empty);
|
||||
}).finally(() => setLoadingTailoredCvDraft(false));
|
||||
}, [open, jobId, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4) return;
|
||||
setLoadingDraft(true);
|
||||
@@ -303,51 +360,160 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
const tailoredCvStatus = getWorkspaceStatus(tailoredCvText, job?.tailoredCvText ?? "");
|
||||
const tailoredCvDraftStatus = getWorkspaceStatus(tailoredCvDraft.renderedText, savedTailoredCvDraft.renderedText);
|
||||
const coverLetterStatus = getWorkspaceStatus(packageWorkspace.coverLetter, savedPackageWorkspace.coverLetter);
|
||||
const applicationAnswerStatus = getWorkspaceStatus(packageWorkspace.applicationAnswer, savedPackageWorkspace.applicationAnswer);
|
||||
const recruiterMessageStatus = getWorkspaceStatus(packageWorkspace.recruiterMessage, savedPackageWorkspace.recruiterMessage);
|
||||
const hasUnsavedTailoredCvDraftChanges = serializeTailoredDraft(tailoredCvDraft) !== serializeTailoredDraft(savedTailoredCvDraft);
|
||||
const hasUnsavedPackageChanges = [
|
||||
tailoredCvText.trim() !== (job?.tailoredCvText ?? "").trim(),
|
||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim(),
|
||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim(),
|
||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim(),
|
||||
].some(Boolean);
|
||||
|
||||
const saveTailoredCvDraft = async () => {
|
||||
if (!jobId) return;
|
||||
|
||||
const normalized = normalizeTailoredCvDraft({
|
||||
...tailoredCvDraft,
|
||||
status: tailoredCvDraft.status === "empty" ? "edited" : tailoredCvDraft.status,
|
||||
});
|
||||
|
||||
try {
|
||||
setSavingTailoredCvDraft(true);
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv-draft`, {
|
||||
templateId: normalized.templateId,
|
||||
headline: normalized.headline,
|
||||
summary: normalized.summary,
|
||||
selectedSkills: normalized.selectedSkills,
|
||||
experience: normalized.experience,
|
||||
education: normalized.education,
|
||||
customSections: normalized.customSections,
|
||||
renderOptions: normalized.renderOptions,
|
||||
status: normalized.status,
|
||||
});
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText: normalized.renderedText,
|
||||
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||
} : prev);
|
||||
setReadiness(null);
|
||||
toast("Tailored CV draft saved.", "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to save the tailored CV draft."), "error");
|
||||
} finally {
|
||||
setSavingTailoredCvDraft(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateTailoredCvDraft = async () => {
|
||||
if (!jobId) return;
|
||||
if (hasUnsavedTailoredCvDraftChanges) {
|
||||
const confirmed = await confirmAction("Regenerating the tailored CV draft will replace your unsaved edits.", {
|
||||
title: "Replace unsaved tailored CV edits?",
|
||||
confirmLabel: "Regenerate draft",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGeneratingTailoredCvDraft(true);
|
||||
const res = await api.post<TailoredCvDraft>(`/jobapplications/${jobId}/generate-tailored-cv-draft`, null, { params: { mode: generationMode } });
|
||||
const normalized = normalizeTailoredCvDraft(res.data);
|
||||
setTailoredCvDraft(normalized);
|
||||
setSavedTailoredCvDraft(normalized);
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText: normalized.renderedText,
|
||||
tailoredCvUpdatedAt: new Date().toISOString(),
|
||||
} : prev);
|
||||
toast("Tailored CV draft generated.", "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to generate a tailored CV draft."), "error");
|
||||
} finally {
|
||||
setGeneratingTailoredCvDraft(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetTailoredCvDraftToSaved = () => {
|
||||
setTailoredCvDraft(savedTailoredCvDraft);
|
||||
toast("Restored the last saved tailored CV draft.", "info");
|
||||
};
|
||||
|
||||
const buildTailoredCvRenderPayload = () => ({
|
||||
templateId: tailoredCvDraft.templateId,
|
||||
headline: tailoredCvDraft.headline,
|
||||
summary: tailoredCvDraft.summary,
|
||||
selectedSkills: tailoredCvDraft.selectedSkills,
|
||||
experience: tailoredCvDraft.experience,
|
||||
education: tailoredCvDraft.education,
|
||||
customSections: tailoredCvDraft.customSections,
|
||||
renderOptions: tailoredCvDraft.renderOptions,
|
||||
photoDataUrl: customPhotoDataUrl,
|
||||
useProfileAvatar: useProfilePhoto,
|
||||
});
|
||||
|
||||
const refreshTailoredCvPreview = async () => {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
setLoadingTailoredCvPreview(true);
|
||||
const res = await api.post<TailoredCvPreviewResponse>(`/jobapplications/${jobId}/tailored-cv-preview`, buildTailoredCvRenderPayload());
|
||||
setTailoredCvPreview(res.data);
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to build the CV preview."), "error");
|
||||
} finally {
|
||||
setLoadingTailoredCvPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportTailoredCvPdf = async () => {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
setExportingTailoredCvPdf(true);
|
||||
const response = await api.post(`/jobapplications/${jobId}/export-tailored-cv-pdf`, buildTailoredCvRenderPayload(), { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = tailoredCvPreview?.suggestedFileName || `${(job?.jobTitle ?? "tailored-cv").replace(/\s+/g, "-").toLowerCase()}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast("Tailored CV PDF downloaded.", "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to export the CV PDF."), "error");
|
||||
} finally {
|
||||
setExportingTailoredCvPdf(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePackageWorkspace = async () => {
|
||||
if (!jobId || !job) return;
|
||||
|
||||
const nextNotes = upsertApplicationAnswerDraft(job.notes, packageWorkspace.applicationAnswer);
|
||||
const tailoredCvChanged = tailoredCvText.trim() !== (job.tailoredCvText ?? "").trim();
|
||||
const draftsChanged =
|
||||
packageWorkspace.coverLetter.trim() !== savedPackageWorkspace.coverLetter.trim() ||
|
||||
packageWorkspace.applicationAnswer.trim() !== savedPackageWorkspace.applicationAnswer.trim() ||
|
||||
packageWorkspace.recruiterMessage.trim() !== savedPackageWorkspace.recruiterMessage.trim();
|
||||
|
||||
if (!tailoredCvChanged && !draftsChanged) {
|
||||
if (!draftsChanged) {
|
||||
toast("No unsaved package changes.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (tailoredCvChanged) {
|
||||
setSavingTailoredCv(true);
|
||||
await api.put(`/jobapplications/${jobId}/tailored-cv`, { tailoredCvText });
|
||||
}
|
||||
|
||||
if (draftsChanged) {
|
||||
setSavingApplicationDrafts(true);
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
notes: nextNotes,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
});
|
||||
}
|
||||
setSavingApplicationDrafts(true);
|
||||
await api.put(`/jobapplications/${jobId}/application-drafts`, {
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
notes: nextNotes,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
});
|
||||
|
||||
setJob((prev) => prev ? {
|
||||
...prev,
|
||||
tailoredCvText,
|
||||
tailoredCvUpdatedAt: tailoredCvChanged ? new Date().toISOString() : prev.tailoredCvUpdatedAt,
|
||||
coverLetterText: packageWorkspace.coverLetter,
|
||||
recruiterMessageDraft: packageWorkspace.recruiterMessage,
|
||||
notes: nextNotes,
|
||||
@@ -359,13 +525,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to save the application package."), "error");
|
||||
} finally {
|
||||
setSavingTailoredCv(false);
|
||||
setSavingApplicationDrafts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetPackageWorkspaceToSaved = () => {
|
||||
setTailoredCvText(job?.tailoredCvText ?? "");
|
||||
setPackageWorkspace(savedPackageWorkspace);
|
||||
toast("Restored the last saved package.", "info");
|
||||
};
|
||||
@@ -489,11 +653,11 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
|
||||
{tab === 3 && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedTailoredCvDraftChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">{t("jobDetailsTabTailoredCv")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Build the package here, then save the working copy back onto this job.</Typography>
|
||||
<Typography variant="overline">Tailored CV draft</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>This draft is job-scoped. It stays separate from your master CV and from the package drafts below.</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
@@ -506,6 +670,214 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<MenuItem value="interview">{t("jobDetailsGenerationInterview")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Template</InputLabel>
|
||||
<Select value={tailoredCvDraft.templateId} label="Template" onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, templateId: e.target.value, status: "edited" }))}>
|
||||
<MenuItem value="ats-minimal">ATS Minimal</MenuItem>
|
||||
<MenuItem value="harvard">Harvard</MenuItem>
|
||||
<MenuItem value="auckland">Auckland</MenuItem>
|
||||
<MenuItem value="edinburgh">Edinburgh</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Accent"
|
||||
type="color"
|
||||
value={tailoredCvDraft.renderOptions.accentColor?.startsWith("#") ? tailoredCvDraft.renderOptions.accentColor : "#334155"}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
renderOptions: { ...current.renderOptions, accentColor: e.target.value },
|
||||
status: "edited",
|
||||
}))}
|
||||
sx={{ width: 110 }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<Button size="small" variant={tailoredCvDraft.renderOptions.showPhoto ? "contained" : "outlined"} onClick={() => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
renderOptions: { ...current.renderOptions, showPhoto: !current.renderOptions.showPhoto },
|
||||
status: "edited",
|
||||
}))}>{tailoredCvDraft.renderOptions.showPhoto ? "Photo on" : "Photo off"}</Button>
|
||||
<Button size="small" variant={useProfilePhoto ? "contained" : "outlined"} onClick={() => setUseProfilePhoto((current) => !current)}>{useProfilePhoto ? "Using profile photo" : "Profile photo off"}</Button>
|
||||
<Button size="small" variant="outlined" component="label">
|
||||
Pick photo
|
||||
<input hidden type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setCustomPhotoDataUrl(typeof reader.result === "string" ? reader.result : null);
|
||||
reader.readAsDataURL(file);
|
||||
}} />
|
||||
</Button>
|
||||
{customPhotoDataUrl ? <Button size="small" variant="text" onClick={() => setCustomPhotoDataUrl(null)}>Clear custom photo</Button> : null}
|
||||
<Button size="small" variant="outlined" disabled={loadingTailoredCvDraft || generatingTailoredCvDraft} onClick={generateTailoredCvDraft}>{generatingTailoredCvDraft ? "Generating tailored draft..." : "Generate tailored draft"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={loadingTailoredCvPreview} onClick={refreshTailoredCvPreview}>{loadingTailoredCvPreview ? "Building preview..." : "Preview PDF layout"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={exportingTailoredCvPdf} onClick={exportTailoredCvPdf}>{exportingTailoredCvPdf ? "Exporting PDF..." : "Download PDF"}</Button>
|
||||
<Button size="small" variant="outlined" disabled={!hasUnsavedTailoredCvDraftChanges} onClick={resetTailoredCvDraftToSaved}>Reset to saved draft</Button>
|
||||
<Button size="small" variant="contained" disabled={savingTailoredCvDraft || loadingTailoredCvDraft} onClick={saveTailoredCvDraft}>{savingTailoredCvDraft ? t("jobDetailsSaving") : "Save tailored draft"}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Chip size="small" label={`Tailored CV · ${tailoredCvDraftStatus.label}`} color={tailoredCvDraftStatus.color} />
|
||||
<Chip size="small" variant="outlined" label={`Template · ${tailoredCvDraft.templateId}`} />
|
||||
{tailoredCvDraft.isLegacyFallback ? <Chip size="small" color="warning" variant="outlined" label="Legacy text fallback" /> : null}
|
||||
{tailoredCvDraft.lastGeneratedAtUtc ? <Chip size="small" variant="outlined" label={`Generated ${new Date(tailoredCvDraft.lastGeneratedAtUtc).toLocaleString()}`} /> : null}
|
||||
{tailoredCvDraft.canonicalProfileVersion ? <Chip size="small" variant="outlined" label={`Profile v${tailoredCvDraft.canonicalProfileVersion}`} /> : null}
|
||||
</Box>
|
||||
|
||||
{loadingTailoredCvDraft ? (
|
||||
<Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||
) : (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.1fr 0.9fr" }, gap: 2 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<TextField
|
||||
label="Headline"
|
||||
value={tailoredCvDraft.headline ?? ""}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, headline: e.target.value, status: "edited" }))}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Summary bullets"
|
||||
value={joinLines(tailoredCvDraft.summary)}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, summary: splitLines(e.target.value), status: "edited" }))}
|
||||
multiline
|
||||
minRows={5}
|
||||
fullWidth
|
||||
helperText="One bullet per line."
|
||||
/>
|
||||
<TextField
|
||||
label="Selected skills"
|
||||
value={joinLines(tailoredCvDraft.selectedSkills)}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({ ...current, selectedSkills: splitLines(e.target.value), status: "edited" }))}
|
||||
multiline
|
||||
minRows={4}
|
||||
fullWidth
|
||||
helperText="One skill per line."
|
||||
/>
|
||||
<TextField
|
||||
label="Experience"
|
||||
value={tailoredCvDraft.experience.map((item) => [
|
||||
[item.title, item.company].filter(Boolean).join(" — "),
|
||||
[item.location, item.start, item.end].filter(Boolean).join(" | "),
|
||||
...(item.bullets ?? []).map((bullet) => `- ${bullet}`),
|
||||
].filter(Boolean).join("\n")).join("\n\n")}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
experience: e.target.value
|
||||
.split(/\n\s*\n/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => {
|
||||
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const [titleCompany = "", meta = "", ...bulletLines] = lines;
|
||||
const [title = "", company = ""] = titleCompany.split("—").map((part) => part.trim());
|
||||
const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim());
|
||||
return {
|
||||
title,
|
||||
company,
|
||||
location,
|
||||
start,
|
||||
end,
|
||||
bullets: bulletLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean),
|
||||
};
|
||||
}),
|
||||
status: "edited",
|
||||
}))}
|
||||
multiline
|
||||
minRows={10}
|
||||
fullWidth
|
||||
helperText="Separate entries with a blank line. First line: Title — Company. Second line: Location | Start | End."
|
||||
/>
|
||||
<TextField
|
||||
label="Education"
|
||||
value={tailoredCvDraft.education.map((item) => [
|
||||
[item.qualification, item.institution].filter(Boolean).join(" — "),
|
||||
[item.location, item.start, item.end].filter(Boolean).join(" | "),
|
||||
...(item.details ?? []).map((detail) => `- ${detail}`),
|
||||
].filter(Boolean).join("\n")).join("\n\n")}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
education: e.target.value
|
||||
.split(/\n\s*\n/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => {
|
||||
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const [qualificationInstitution = "", meta = "", ...detailLines] = lines;
|
||||
const [qualification = "", institution = ""] = qualificationInstitution.split("—").map((part) => part.trim());
|
||||
const [location = "", start = "", end = ""] = meta.split("|").map((part) => part.trim());
|
||||
return {
|
||||
qualification,
|
||||
institution,
|
||||
location,
|
||||
start,
|
||||
end,
|
||||
details: detailLines.map((line) => line.replace(/^[-•*]\s*/, "").trim()).filter(Boolean),
|
||||
};
|
||||
}),
|
||||
status: "edited",
|
||||
}))}
|
||||
multiline
|
||||
minRows={8}
|
||||
fullWidth
|
||||
helperText="Separate entries with a blank line. First line: Qualification — Institution. Second line: Location | Start | End."
|
||||
/>
|
||||
<TextField
|
||||
label="Custom sections"
|
||||
value={tailoredCvDraft.customSections.map((section) => `${section.title || "Additional Information"}\n${(section.items ?? []).join("\n")}`).join("\n\n")}
|
||||
onChange={(e) => setTailoredCvDraft((current) => normalizeTailoredCvDraft({
|
||||
...current,
|
||||
customSections: e.target.value
|
||||
.split(/\n\s*\n/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => {
|
||||
const [title = "", ...items] = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return { title, items };
|
||||
}),
|
||||
status: "edited",
|
||||
}))}
|
||||
multiline
|
||||
minRows={7}
|
||||
fullWidth
|
||||
helperText="Each block starts with the section title, followed by one item per line."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="overline">Rendered CV snapshot</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>This plain-text snapshot stays deterministic and is what the job stores immediately after saving the draft.</Typography>
|
||||
<TextField value={tailoredCvDraft.renderedText} multiline minRows={12} fullWidth InputProps={{ readOnly: true }} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="overline">PDF-style preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here.</Typography>
|
||||
{tailoredCvPreview ? (
|
||||
<iframe title="Tailored CV preview" srcDoc={tailoredCvPreview.html} style={{ width: "100%", minHeight: 780, border: "1px solid rgba(15,23,42,0.08)", borderRadius: 12, background: "white" }} />
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>Build the PDF layout preview to inspect the ATS template before downloading.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="overline">Saved job material</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Saving the tailored draft updates the job-scoped CV text without touching your master profile.</Typography>
|
||||
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Master CV:</strong> Never overwritten here</Typography>
|
||||
<Typography variant="body2"><strong>Photo source:</strong> {customPhotoDataUrl ? "Custom preview photo" : useProfilePhoto && profileAvatarImageDataUrl ? "Profile picture" : "No photo source selected"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: hasUnsavedPackageChanges ? "warning.main" : "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline">Application package drafts</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>These drafts stay separate from the tailored CV draft. Save them when you want reusable role-specific copy on the job.</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||
<InputLabel>{t("jobDetailsCoverLetterStyle")}</InputLabel>
|
||||
<Select value={coverLetterStyle} label={t("jobDetailsCoverLetterStyle")} onChange={(e) => setCoverLetterStyle(e.target.value as CoverLetterStyle)}>
|
||||
@@ -515,22 +887,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<MenuItem value="bold">{t("jobDetailsCoverLetterStyleBold")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button size="small" variant="outlined" onClick={async () => {
|
||||
try {
|
||||
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
||||
setTailoredCvText(me.data?.profileCvText ?? "");
|
||||
toast(t("jobDetailsLoadedMasterCv"), "success");
|
||||
} catch {
|
||||
toast(t("jobDetailsLoadMasterCvFailed"), "error");
|
||||
}
|
||||
}}>{t("jobDetailsStartFromMasterCv")}</Button>
|
||||
<Button size="small" variant="outlined" disabled={generatingPackage} onClick={async () => {
|
||||
if (!jobId) return;
|
||||
setGeneratingPackage(true);
|
||||
try {
|
||||
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } });
|
||||
setApplicationPackage(res.data);
|
||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||
setPackageWorkspace({
|
||||
coverLetter: res.data.coverLetterDraft ?? "",
|
||||
applicationAnswer: res.data.applicationAnswerDraft ?? "",
|
||||
@@ -545,58 +907,53 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
}
|
||||
}}>{generatingPackage ? t("jobDetailsGeneratingPackage") : t("jobDetailsGeneratePackage")}</Button>
|
||||
<Button size="small" variant="outlined" disabled={!hasUnsavedPackageChanges} onClick={resetPackageWorkspaceToSaved}>Reset to saved</Button>
|
||||
<Button size="small" variant="contained" disabled={savingTailoredCv || savingApplicationDrafts} onClick={savePackageWorkspace}>{savingTailoredCv || savingApplicationDrafts ? t("jobDetailsSaving") : "Save package to job"}</Button>
|
||||
<Button size="small" variant="contained" disabled={savingApplicationDrafts} onClick={savePackageWorkspace}>{savingApplicationDrafts ? t("jobDetailsSaving") : "Save package drafts"}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Chip size="small" label={`Tailored CV · ${tailoredCvStatus.label}`} color={tailoredCvStatus.color} />
|
||||
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
|
||||
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
|
||||
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
|
||||
<Chip size="small" variant="outlined" label="Saved package material feeds follow-up drafting" />
|
||||
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
|
||||
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsCoverLetterDraft")}
|
||||
value={packageWorkspace.coverLetter}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
|
||||
statusLabel={coverLetterStatus.label}
|
||||
statusColor={coverLetterStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsShortApplicationAnswer")}
|
||||
value={packageWorkspace.applicationAnswer}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
|
||||
statusLabel={applicationAnswerStatus.label}
|
||||
statusColor={applicationAnswerStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsRecruiterMessageDraft")}
|
||||
value={packageWorkspace.recruiterMessage}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
|
||||
statusLabel={recruiterMessageStatus.label}
|
||||
statusColor={recruiterMessageStatus.color}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Typography variant="overline">Saved working material</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsCoverLetterDraft")}
|
||||
value={packageWorkspace.coverLetter}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, coverLetter: value }))}
|
||||
statusLabel={coverLetterStatus.label}
|
||||
statusColor={coverLetterStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsShortApplicationAnswer")}
|
||||
value={packageWorkspace.applicationAnswer}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, applicationAnswer: value }))}
|
||||
statusLabel={applicationAnswerStatus.label}
|
||||
statusColor={applicationAnswerStatus.color}
|
||||
/>
|
||||
<WorkspaceDraftCard
|
||||
title={t("jobDetailsRecruiterMessageDraft")}
|
||||
value={packageWorkspace.recruiterMessage}
|
||||
onChange={(value) => setPackageWorkspace((current) => ({ ...current, recruiterMessage: value }))}
|
||||
statusLabel={recruiterMessageStatus.label}
|
||||
statusColor={recruiterMessageStatus.color}
|
||||
/>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Typography variant="overline">Saved working material</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Application answer:</strong> {savedPackageWorkspace.applicationAnswer.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
<Typography variant="body2"><strong>Recruiter message:</strong> {savedPackageWorkspace.recruiterMessage.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
|
||||
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
||||
</Box>
|
||||
<ListCard title={t("jobDetailsKeyPoints")} items={applicationPackage?.keyPoints ?? ["Generate a package to pull in role-specific talking points."]} />
|
||||
<ListCard title={t("jobDetailsCoverLetterVariants")} items={applicationPackage?.coverLetterVariants?.length ? applicationPackage.coverLetterVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsRecruiterMessageVariants")} items={applicationPackage?.recruiterMessageVariants?.length ? applicationPackage.recruiterMessageVariants : [t("jobDetailsNoDraftAvailable")]} />
|
||||
<ListCard title={t("jobDetailsAttachmentSignals")} items={applicationPackage?.attachmentSignals?.length ? applicationPackage.attachmentSignals : [t("jobDetailsNoAttachmentSignals")]} subtitle={applicationPackage?.attachmentFilesUsed?.length ? applicationPackage.attachmentFilesUsed.join(", ") : undefined} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
@@ -105,6 +107,7 @@ function statusTone(status: string): string {
|
||||
|
||||
export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
@@ -217,7 +220,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
try {
|
||||
await api.delete(`/jobapplications/${job.id}`);
|
||||
toast(t("jobTableMovedToTrash"), "success", { label: "Undo", onClick: () => { void restore(job.id); } });
|
||||
setReloadToken((t) => t + 1);
|
||||
setReloadToken((token) => token + 1);
|
||||
} catch {
|
||||
toast(t("jobTableDeleteFailed"), "error");
|
||||
}
|
||||
@@ -227,7 +230,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
try {
|
||||
await api.post(`/jobapplications/${id}/restore`);
|
||||
toast(t("jobTableRestored"), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
setReloadToken((token) => token + 1);
|
||||
} catch {
|
||||
toast(t("jobTableRestoreFailed"), "error");
|
||||
}
|
||||
@@ -237,7 +240,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
try {
|
||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
||||
toast(t("jobTableStatusSet", { status }), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
setReloadToken((token) => token + 1);
|
||||
} catch {
|
||||
toast(t("jobTableStatusUpdateFailed"), "error");
|
||||
}
|
||||
@@ -254,7 +257,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return api.patch(`/jobapplications/${id}/status`, { status: value });
|
||||
}));
|
||||
toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success");
|
||||
setReloadToken((t) => t + 1);
|
||||
setReloadToken((token) => token + 1);
|
||||
setSelectedIds([]);
|
||||
} catch {
|
||||
toast(t("jobTableBulkActionFailed"), "error");
|
||||
@@ -301,52 +304,149 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
};
|
||||
};
|
||||
|
||||
const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const visibleDesktopColumns = 4 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
|
||||
<TextField label={t("jobTableSearch")} value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }} placeholder={t("jobTableSearchPlaceholder")} size="small" InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }} sx={{ minWidth: 320, flex: "1 1 320px" }} />
|
||||
{isMobile ? (
|
||||
<Paper sx={{ mt: 2, p: 1.25, borderRadius: 4 }}>
|
||||
<Stack spacing={1.1}>
|
||||
<TextField
|
||||
label={t("jobTableSearch")}
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
placeholder={t("jobTableSearchPlaceholder")}
|
||||
size="small"
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<FormControl sx={{ minWidth: 160 }} size="small">
|
||||
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
||||
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
||||
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
||||
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
||||
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel>{t("jobTableCompany")}</InputLabel>
|
||||
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
|
||||
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
||||
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("jobTableCompany")}</InputLabel>
|
||||
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
|
||||
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
||||
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<TextField label={t("jobTableLocation")} value={locationFilter} onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} />
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: mode === "jobs" ? "1fr 1fr" : "1fr", gap: 1 }}>
|
||||
<TextField
|
||||
label={t("jobTableLocation")}
|
||||
value={locationFilter}
|
||||
onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} /> : null}
|
||||
{mode === "jobs" ? (
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
||||
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
|
||||
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
||||
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
||||
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : null}
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} /> : null}
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
|
||||
<Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip>
|
||||
{mode === "jobs" ? (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
||||
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
|
||||
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
||||
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
||||
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{mode === "jobs" ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 0.25,
|
||||
px: 0.25,
|
||||
py: 0.5,
|
||||
borderRadius: 3,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.03),
|
||||
border: "1px solid",
|
||||
borderColor: alpha(theme.palette.primary.main, 0.1),
|
||||
}}
|
||||
>
|
||||
<FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0, ml: -0.5 }} />
|
||||
<FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} sx={{ mr: 0, ml: -0.5 }} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr) auto", gap: 0.75, alignItems: "center", pt: 0.25 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
|
||||
</Box>
|
||||
<Button variant="text" size="small" startIcon={<ViewColumnIcon />} onClick={(e) => setColumnsAnchor(e.currentTarget)} sx={{ justifySelf: "end", minHeight: 40, px: 1 }}>
|
||||
{t("jobTableColumns")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", gap: 2, alignItems: "center", justifyContent: "space-between", mt: 2, flexWrap: "wrap" }}>
|
||||
<TextField
|
||||
label={t("jobTableSearch")}
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
placeholder={t("jobTableSearchPlaceholder")}
|
||||
size="small"
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
sx={{ width: { xs: "100%", md: "auto" }, minWidth: { xs: 0, md: 320 }, flex: { xs: "1 1 100%", md: "1 1 320px" } }}
|
||||
/>
|
||||
|
||||
<FormControl sx={{ width: { xs: "100%", sm: 180 } }} size="small">
|
||||
<InputLabel>{t("jobTableStatus")}</InputLabel>
|
||||
<Select value={statusFilter} label={t("jobTableStatus")} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}>
|
||||
{[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => <MenuItem key={s} value={s === t("jobTableAll") ? "All" : s === t("statusApplied") ? "Applied" : s === t("statusWaiting") ? "Waiting" : s === t("statusInterview") ? "Interview" : s === t("statusOffer") ? "Offer" : s === t("statusRejected") ? "Rejected" : "Ghosted"}>{s}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ width: { xs: "100%", sm: 220 } }} size="small">
|
||||
<InputLabel>{t("jobTableCompany")}</InputLabel>
|
||||
<Select value={companyFilterId} label={t("jobTableCompany")} onChange={(e) => { setCompanyFilterId(e.target.value as any); setPage(0); }}>
|
||||
<MenuItem value="All">{t("jobTableAll")}</MenuItem>
|
||||
{companies.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label={t("jobTableLocation")}
|
||||
value={locationFilter}
|
||||
onChange={(e) => { setLocationFilter(e.target.value); setPage(0); }}
|
||||
sx={{ width: { xs: "100%", sm: 220 }, flex: { xs: "1 1 100%", md: "1 1 200px" } }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", width: { xs: "100%", xl: "auto" } }}>
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={needsFollowUpOnly} onChange={(e) => { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} sx={{ mr: 0 }} /> : null}
|
||||
{mode === "jobs" ? (
|
||||
<FormControl size="small" sx={{ width: { xs: "100%", sm: 180 } }}>
|
||||
<InputLabel>{t("jobTableReadiness")}</InputLabel>
|
||||
<Select value={readinessFilter} label={t("jobTableReadiness")} onChange={(e) => setReadinessFilter(e.target.value as any)}>
|
||||
<MenuItem value="all">{t("jobTableAllReadiness")}</MenuItem>
|
||||
<MenuItem value="needs-work">{t("jobTableNeedsWork")}</MenuItem>
|
||||
<MenuItem value="interview">{t("jobTableInterviewStage")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : null}
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null}
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined }} onApply={(p: SavedViewParams) => { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} />
|
||||
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedIds.length > 0 ? (
|
||||
<Paper sx={{ mt: 2, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Typography sx={{ fontWeight: 800 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")}>{t("jobTableRestoreSelected")}</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")}>{t("jobTableDeleteSelected")}</Button>}
|
||||
{mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => <Button key={s} variant="outlined" onClick={() => void runBulkAction("status", s)}>{s}</Button>) : null}
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", width: { xs: "100%", sm: "auto" } }}>
|
||||
{mode === "trash" ? <Button variant="outlined" onClick={() => void runBulkAction("restore")} sx={{ width: { xs: "100%", sm: "auto" } }}>{t("jobTableRestoreSelected")}</Button> : <Button variant="outlined" color="error" onClick={() => void runBulkAction("delete")} sx={{ width: { xs: "100%", sm: "auto" } }}>{t("jobTableDeleteSelected")}</Button>}
|
||||
{mode === "jobs" ? statusOptions.map((status) => <Button key={status} variant="outlined" onClick={() => void runBulkAction("status", status)} sx={{ width: { xs: "100%", sm: "auto" } }}>{status}</Button>) : null}
|
||||
</Box>
|
||||
</Paper>
|
||||
) : null}
|
||||
@@ -360,107 +460,248 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
||||
<TableCell width={1} />
|
||||
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
|
||||
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>{t("jobTableRole")}</TableSortLabel></TableCell>
|
||||
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>{t("jobTableStatus")}</TableSortLabel></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>{t("jobTableDateApplied")}</TableSortLabel></TableCell> : null}
|
||||
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>{t("jobTableDays")}</TableSortLabel></TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{t("settingsColumnJobUrl")}</TableCell> : null}
|
||||
<TableCell align="right">{t("jobTableActions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<Paper sx={{ mt: 2, overflow: "hidden" }}>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.25} sx={{ p: 1.25 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
|
||||
<FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
|
||||
</Box>
|
||||
|
||||
{filteredJobs.map((job) => {
|
||||
const open = expanded.includes(job.id);
|
||||
const toneName = statusTone(job.status);
|
||||
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
|
||||
const primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
const tags = parseTags(job.tags).slice(0, 6);
|
||||
return (
|
||||
<React.Fragment key={job.id}>
|
||||
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
|
||||
<TableCell>{job.company?.name ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{actionSignals.map((signal) => (
|
||||
<Chip
|
||||
key={`${job.id}-${signal.label}-${signal.detail}`}
|
||||
size="small"
|
||||
label={signal.label}
|
||||
color={signal.color}
|
||||
variant={signal.variant === "contained" ? "filled" : "outlined"}
|
||||
title={signal.detail}
|
||||
sx={{ fontWeight: 800, cursor: "pointer" }}
|
||||
clickable
|
||||
onClick={signal.onClick}
|
||||
aria-label={`${job.jobTitle} — ${signal.label} signal`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
|
||||
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.75 }}>
|
||||
{primaryAction ? (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
||||
{t("editJobNextAction")}
|
||||
</Typography>
|
||||
<Button size="small" variant={primaryAction.variant} color={primaryAction.color} onClick={primaryAction.onClick} aria-label={`${t("editJobNextAction")}: ${job.jobTitle} — ${primaryAction.label}`}>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", maxWidth: 220, textAlign: "right" }}>
|
||||
{primaryAction.detail}
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
|
||||
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
<Paper
|
||||
key={job.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3.5,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.03),
|
||||
borderColor: alpha(theme.palette.primary.main, 0.08),
|
||||
boxShadow: `0 10px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.18 : 0.06)}`,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.25}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
|
||||
{job.company?.name ?? t("jobTableCompany")}
|
||||
</Typography>
|
||||
<Typography sx={{ fontWeight: 900, fontSize: 21, lineHeight: 1.1, letterSpacing: -0.4, textWrap: "balance", overflowWrap: "anywhere" }}>
|
||||
{job.jobTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0 }} colSpan={columns.status && columns.dateApplied && columns.daysSince && columns.jobUrl ? 9 : 8}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||
{columns.status ? <Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
{actionSignals.map((signal) => (
|
||||
<Chip
|
||||
key={`${job.id}-${signal.label}-${signal.detail}`}
|
||||
size="small"
|
||||
label={signal.label}
|
||||
color={signal.color}
|
||||
variant={signal.variant === "contained" ? "filled" : "outlined"}
|
||||
title={signal.detail}
|
||||
clickable
|
||||
onClick={signal.onClick}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
||||
{columns.dateApplied ? (
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDateApplied")}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{new Date(job.dateApplied).toLocaleDateString()}</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
{columns.daysSince ? (
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableDays")}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>{job.daysSince}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableLocation")}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>{job.location ?? "-"}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("addJobModalSalary")}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>{job.salary ?? "-"}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{columns.jobUrl && job.jobUrl ? (
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("settingsColumnJobUrl")}</Typography>
|
||||
<Typography variant="body2"><a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a></Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
{tags.map((tag) => <Chip key={tag} size="small" label={tag} sx={{ borderRadius: 999 }} />)}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("jobTableOverview")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, whiteSpace: "pre-wrap", textWrap: "pretty" }}>
|
||||
{generateOverview(job) || t("jobTableNoSummaryYet")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{primaryAction ? (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, display: "block", mb: 0.5 }}>
|
||||
{t("editJobNextAction")}
|
||||
</Typography>
|
||||
<Button variant={primaryAction.variant} color={primaryAction.color} onClick={primaryAction.onClick} fullWidth sx={{ minHeight: 42, fontWeight: 700 }}>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.75, textWrap: "pretty" }}>
|
||||
{primaryAction.detail}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 1 }}>
|
||||
<Button variant="outlined" startIcon={<EditOutlinedIcon />} onClick={() => setEditJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
|
||||
{t("jobTableEdit")}
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<MoreHorizIcon />} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }} sx={{ minHeight: 42, fontWeight: 700 }}>
|
||||
{t("jobTableQuickStatus")}
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<LaunchIcon />} onClick={() => setDetailsJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
|
||||
{t("jobTableOpen")}
|
||||
</Button>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
|
||||
<Button variant="outlined" startIcon={<RestoreFromTrashOutlinedIcon />} onClick={() => void restore(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
|
||||
{t("jobTableRestore")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="error" variant="outlined" startIcon={<DeleteOutlineIcon />} onClick={() => void softDelete(job)} sx={{ minHeight: 42, fontWeight: 700 }}>
|
||||
{t("jobTableSoftDelete")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={9}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{filteredJobs.length === 0 ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Table size="small" sx={{ minWidth: 980 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
||||
<TableCell width={1} />
|
||||
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
|
||||
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>{t("jobTableRole")}</TableSortLabel></TableCell>
|
||||
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>{t("jobTableStatus")}</TableSortLabel></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>{t("jobTableDateApplied")}</TableSortLabel></TableCell> : null}
|
||||
{columns.daysSince ? <TableCell sortDirection={sortBy === "daysSince" ? sortDir : false}><TableSortLabel active={sortBy === "daysSince"} direction={sortBy === "daysSince" ? sortDir : "asc"} onClick={() => requestSort("daysSince")}>{t("jobTableDays")}</TableSortLabel></TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{t("settingsColumnJobUrl")}</TableCell> : null}
|
||||
<TableCell align="right">{t("jobTableActions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredJobs.map((job) => {
|
||||
const open = expanded.includes(job.id);
|
||||
const toneName = statusTone(job.status);
|
||||
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
|
||||
const primaryAction = getPrimaryAction(job);
|
||||
const actionSignals = getActionSignals(job);
|
||||
return (
|
||||
<React.Fragment key={job.id}>
|
||||
<TableRow sx={{ backgroundColor: alpha(tone, theme.palette.mode === "dark" ? 0.1 : 0.06) }}>
|
||||
<TableCell padding="checkbox"><Checkbox checked={selectedIds.includes(job.id)} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell><IconButton size="small" onClick={() => toggleExpanded(job.id)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
|
||||
<TableCell>{job.company?.name ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<span>{job.jobTitle}</span>
|
||||
{actionSignals.map((signal) => (
|
||||
<Chip
|
||||
key={`${job.id}-${signal.label}-${signal.detail}`}
|
||||
size="small"
|
||||
label={signal.label}
|
||||
color={signal.color}
|
||||
variant={signal.variant === "contained" ? "filled" : "outlined"}
|
||||
title={signal.detail}
|
||||
sx={{ fontWeight: 800, cursor: "pointer" }}
|
||||
clickable
|
||||
onClick={signal.onClick}
|
||||
aria-label={`${job.jobTitle} — ${signal.label} signal`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</TableCell>
|
||||
{columns.status ? <TableCell><Chip label={normalizeStatus(job.status)} size="small" color={toneName as any} /></TableCell> : null}
|
||||
{columns.dateApplied ? <TableCell>{new Date(job.dateApplied).toLocaleDateString()}</TableCell> : null}
|
||||
{columns.daysSince ? <TableCell>{job.daysSince}</TableCell> : null}
|
||||
{columns.jobUrl ? <TableCell>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableLink")}</a> : ""}</TableCell> : null}
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 0.75 }}>
|
||||
{primaryAction ? (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700 }}>
|
||||
{t("editJobNextAction")}
|
||||
</Typography>
|
||||
<Button size="small" variant={primaryAction.variant} color={primaryAction.color} onClick={primaryAction.onClick} aria-label={`${t("editJobNextAction")}: ${job.jobTitle} — ${primaryAction.label}`}>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", maxWidth: 220, textAlign: "right" }}>
|
||||
{primaryAction.detail}
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
|
||||
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
|
||||
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell sx={{ py: 0 }} colSpan={visibleDesktopColumns}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">{t("jobTableLocation")}</Typography><Typography>{job.location ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("addJobModalSalary")}</Typography><Typography>{job.salary ?? "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("settingsColumnJobUrl")}</Typography><Typography>{job.jobUrl ? <a href={job.jobUrl} target="_blank" rel="noreferrer">{t("jobTableOpenListing")}</a> : "-"}</Typography></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableSkills")}</Typography><Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => <Chip key={tag} label={tag} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobTableNoTags")}</Typography>}</Box></Box>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}><Typography variant="overline">{t("jobTableOverview")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{generateOverview(job) || t("jobTableNoSummaryYet")}</Typography></Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||
</Paper>
|
||||
|
||||
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} />
|
||||
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
|
||||
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
|
||||
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
|
||||
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
|
||||
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })}</MenuItem>)}
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -200,6 +200,9 @@ export const translations = {
|
||||
profileCvUploadFailed: "Failed to upload CV.",
|
||||
profileCvTextLabel: "Profile CV / master resume text",
|
||||
profileCvTextHelp: "Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next. If extraction misses something, edit it here manually.",
|
||||
profileCvStructuredDefaultHint: "The structured CV stays front and center. Open the original extraction only when you need to verify or clean up parser output.",
|
||||
profileCvRawPanelTitle: "Original extraction",
|
||||
profileCvRawPanelHelp: "Usually messy, but useful for checking what the parser actually pulled from the uploaded file.",
|
||||
profileCvPreferredUploads: "Supported uploads: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||
profileCvSectionTools: "Section rewrite tools",
|
||||
profileCvStructureOverview: "CV structure overview",
|
||||
@@ -213,6 +216,7 @@ export const translations = {
|
||||
profileCvStructuredEditorHelp: "Edit reusable CV data directly so generators and matching can work from stable fields instead of raw text alone.",
|
||||
profileCvExtractionHistory: "Extraction history",
|
||||
profileCvExtractionHistoryHelp: "See which parser run produced the current structured profile and reprocess from the stored source artifact when needed.",
|
||||
profileCvExtractionHistoryEmpty: "No extraction runs yet.",
|
||||
profileCvProfileVersion: "Profile v{count}",
|
||||
profileCvCurrentRun: "Current run",
|
||||
profileCvNoStoredArtifact: "No stored artifact",
|
||||
@@ -430,6 +434,10 @@ export const translations = {
|
||||
adminSystemLastProbe: "Last probe",
|
||||
adminSystemLastSuccessfulProbe: "Last successful probe",
|
||||
adminSystemLastSummarizationSuccess: "Last summarization success",
|
||||
adminSystemOllamaConfigured: "Ollama configured",
|
||||
adminSystemOllamaReachable: "Ollama reachable",
|
||||
adminSystemOllamaModel: "Ollama model",
|
||||
adminSystemOllamaModelAvailable: "Ollama model ready",
|
||||
adminSystemRequests: "Requests",
|
||||
adminSystemCacheHits: "Cache hits",
|
||||
adminSystemCacheMisses: "Cache misses",
|
||||
@@ -439,6 +447,7 @@ export const translations = {
|
||||
adminSystemOcrRequests: "OCR requests",
|
||||
adminSystemOcrAvgLatency: "OCR avg latency",
|
||||
adminSystemOcrUnavailable: "OCR unavailable",
|
||||
adminSystemOllamaOff: "Ollama off",
|
||||
adminSystemAiProbeFailed: "Failed to run AI service probe.",
|
||||
correspondenceNoMessages: "No messages yet.",
|
||||
correspondenceMe: "Me",
|
||||
@@ -674,6 +683,7 @@ export const translations = {
|
||||
jobTableInterviewStage: "Interview stage",
|
||||
jobTableShowDeleted: "Show deleted",
|
||||
jobTableColumns: "Columns",
|
||||
jobTableSelectAll: "Select all",
|
||||
jobTableSelected: "{count} selected",
|
||||
jobTableRestoreSelected: "Restore selected",
|
||||
jobTableDeleteSelected: "Delete selected",
|
||||
@@ -1100,6 +1110,9 @@ export const translations = {
|
||||
profileCvUploadFailed: "Kunne ikke laste opp CV.",
|
||||
profileCvTextLabel: "Profil-CV / hovedtekst for CV",
|
||||
profileCvTextHelp: "Hold denne oppdatert og konkret. Ta med nylige roller, verktøy, prestasjoner, målbare resultater og arbeidet du vil bli ansatt for neste gang. Hvis tekstuttrekket mangler noe, kan du redigere manuelt her.",
|
||||
profileCvStructuredDefaultHint: "Den strukturerte CV-en er hovedvisningen. Åpne originaluttrekket bare når du vil kontrollere eller rydde opp i parserresultatet.",
|
||||
profileCvRawPanelTitle: "Originalt uttrekk",
|
||||
profileCvRawPanelHelp: "Som regel rotete, men nyttig når du vil se nøyaktig hva parseren hentet ut fra den opplastede filen.",
|
||||
profileCvPreferredUploads: "Støttede opplastinger: PDF, DOCX, TXT, MD, PNG, JPG, JPEG, WEBP.",
|
||||
profileCvSectionTools: "Verktøy for CV-seksjoner",
|
||||
profileCvStructureOverview: "Oversikt over CV-struktur",
|
||||
@@ -1113,6 +1126,7 @@ export const translations = {
|
||||
profileCvStructuredEditorHelp: "Rediger gjenbrukbare CV-data direkte slik at generatorer og matching kan jobbe fra stabile felt i stedet for bare råtekst.",
|
||||
profileCvExtractionHistory: "Ekstraksjonshistorikk",
|
||||
profileCvExtractionHistoryHelp: "Se hvilken parserkjøring som produserte den nåværende strukturerte profilen, og kjør på nytt fra det lagrede kildeartefaktet ved behov.",
|
||||
profileCvExtractionHistoryEmpty: "Ingen ekstraksjonskjøringer ennå.",
|
||||
profileCvProfileVersion: "Profil v{count}",
|
||||
profileCvCurrentRun: "Gjeldende kjøring",
|
||||
profileCvNoStoredArtifact: "Ingen lagret kildefil",
|
||||
@@ -1330,6 +1344,10 @@ export const translations = {
|
||||
adminSystemLastProbe: "Siste probe",
|
||||
adminSystemLastSuccessfulProbe: "Siste vellykkede probe",
|
||||
adminSystemLastSummarizationSuccess: "Siste vellykkede oppsummering",
|
||||
adminSystemOllamaConfigured: "Ollama konfigurert",
|
||||
adminSystemOllamaReachable: "Ollama tilgjengelig",
|
||||
adminSystemOllamaModel: "Ollama-modell",
|
||||
adminSystemOllamaModelAvailable: "Ollama-modell klar",
|
||||
adminSystemRequests: "Forespørsler",
|
||||
adminSystemCacheHits: "Cache-treff",
|
||||
adminSystemCacheMisses: "Cache-miss",
|
||||
@@ -1339,6 +1357,7 @@ export const translations = {
|
||||
adminSystemOcrRequests: "OCR-forespørsler",
|
||||
adminSystemOcrAvgLatency: "OCR snittlatens",
|
||||
adminSystemOcrUnavailable: "OCR utilgjengelig",
|
||||
adminSystemOllamaOff: "Ollama av",
|
||||
adminSystemAiProbeFailed: "Kunne ikke kjøre AI-tjenesteprobe.",
|
||||
correspondenceNoMessages: "Ingen meldinger ennå.",
|
||||
correspondenceMe: "Meg",
|
||||
@@ -1574,6 +1593,7 @@ export const translations = {
|
||||
jobTableInterviewStage: "Intervjustadium",
|
||||
jobTableShowDeleted: "Vis slettede",
|
||||
jobTableColumns: "Kolonner",
|
||||
jobTableSelectAll: "Velg alle",
|
||||
jobTableSelected: "{count} valgt",
|
||||
jobTableRestoreSelected: "Gjenopprett valgte",
|
||||
jobTableDeleteSelected: "Slett valgte",
|
||||
|
||||
@@ -43,6 +43,10 @@ beforeEach(() => {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
Object.assign(URL, {
|
||||
createObjectURL: jest.fn().mockReturnValue('blob:preview-pdf'),
|
||||
revokeObjectURL: jest.fn(),
|
||||
});
|
||||
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
@@ -61,7 +65,7 @@ beforeEach(() => {
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/auth/me') {
|
||||
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
|
||||
return Promise.resolve({ data: { roles: [], avatarImageDataUrl: 'data:image/png;base64,avatar123' } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/history') {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
@@ -69,6 +73,26 @@ beforeEach(() => {
|
||||
if (url === '/attachments/42') {
|
||||
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/tailored-cv-draft') {
|
||||
return Promise.resolve({ data: {
|
||||
id: 5,
|
||||
canonicalProfileVersion: 3,
|
||||
templateId: 'ats-minimal',
|
||||
headline: 'Backend Engineer',
|
||||
summary: ['Built APIs', 'Shipped backend work'],
|
||||
selectedSkills: ['.NET', 'SQL'],
|
||||
experience: [],
|
||||
education: [],
|
||||
customSections: [],
|
||||
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
|
||||
generationContextHash: 'abc123',
|
||||
lastGeneratedAtUtc: new Date().toISOString(),
|
||||
lastEditedAtUtc: null,
|
||||
status: 'generated',
|
||||
renderedText: 'Backend Engineer\n\nProfessional Summary\n- Built APIs\n- Shipped backend work',
|
||||
isLegacyFallback: false,
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/candidate-fit') {
|
||||
return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any);
|
||||
}
|
||||
@@ -77,7 +101,44 @@ beforeEach(() => {
|
||||
}
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
mockedApi.post.mockResolvedValue({ data: { tailoredCvText: 'Generated CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
|
||||
|
||||
mockedApi.post.mockImplementation((url: string, body?: any, config?: any) => {
|
||||
if (url === '/jobapplications/42/generate-tailored-cv-draft') {
|
||||
return Promise.resolve({ data: {
|
||||
id: 5,
|
||||
canonicalProfileVersion: 3,
|
||||
templateId: 'ats-minimal',
|
||||
headline: 'Senior Backend Engineer',
|
||||
summary: ['Owned API delivery', 'Improved SQL workflows'],
|
||||
selectedSkills: ['.NET', 'SQL', 'APIs'],
|
||||
experience: [],
|
||||
education: [],
|
||||
customSections: [],
|
||||
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
|
||||
generationContextHash: 'def456',
|
||||
lastGeneratedAtUtc: new Date().toISOString(),
|
||||
lastEditedAtUtc: null,
|
||||
status: 'generated',
|
||||
renderedText: 'Senior Backend Engineer\n\nProfessional Summary\n- Owned API delivery\n- Improved SQL workflows',
|
||||
isLegacyFallback: false,
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/tailored-cv-preview') {
|
||||
return Promise.resolve({ data: {
|
||||
templateId: body?.templateId ?? 'ats-minimal',
|
||||
suggestedFileName: `${body?.templateId ?? 'ats-minimal'}.pdf`,
|
||||
html: `<html><body data-template="${body?.templateId ?? 'ats-minimal'}" data-accent="${body?.renderOptions?.accentColor ?? ''}" data-photo="${body?.useProfileAvatar ? 'profile' : 'custom'}"></body></html>`,
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/export-tailored-cv-pdf') {
|
||||
return Promise.resolve({ data: new Blob(['pdf'], { type: 'application/pdf' }) } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/generate-application-package') {
|
||||
return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||
});
|
||||
|
||||
@@ -85,20 +146,40 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('application package workspace reflects saved job material, generated drafts, and save state', async () => {
|
||||
test('tailored cv tab loads, regenerates, and saves the structured tailored draft', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Backend Engineer')).toBeInTheDocument();
|
||||
expect((await screen.findByLabelText('Summary bullets')) as HTMLInputElement).toHaveValue('Built APIs\nShipped backend work');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate application package/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate tailored draft/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Senior Backend Engineer')).toBeInTheDocument();
|
||||
const headline = screen.getByDisplayValue('Senior Backend Engineer');
|
||||
fireEvent.change(headline, { target: { value: 'Principal Backend Engineer' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save tailored draft/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-draft', expect.objectContaining({
|
||||
headline: 'Principal Backend Engineer',
|
||||
summary: ['Owned API delivery', 'Improved SQL workflows'],
|
||||
selectedSkills: ['.NET', 'SQL', 'APIs'],
|
||||
status: 'edited',
|
||||
}));
|
||||
});
|
||||
|
||||
expect(mockedApi.put).not.toHaveBeenCalledWith('/jobapplications/42/tailored-cv', expect.anything());
|
||||
});
|
||||
|
||||
test('application package drafts save separately from the tailored cv draft', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /generate application package/i }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument();
|
||||
const coverLetter = await screen.findByDisplayValue('Draft letter');
|
||||
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
|
||||
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
|
||||
@@ -107,18 +188,59 @@ test('application package workspace reflects saved job material, generated draft
|
||||
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
|
||||
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save package to job/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /save package drafts/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
|
||||
coverLetterText: 'Edited cover letter',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
recruiterMessageDraft: 'Edited recruiter note',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText(/saved to job/i)).not.toHaveLength(0);
|
||||
test('template switching refreshes preview and export uses the selected template payload', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
|
||||
|
||||
const comboboxes = await screen.findAllByRole('combobox');
|
||||
fireEvent.mouseDown(comboboxes[1]);
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Harvard' }));
|
||||
|
||||
const accent = screen.getByLabelText('Accent');
|
||||
fireEvent.change(accent, { target: { value: '#123456' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /preview pdf layout/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-preview', expect.objectContaining({
|
||||
templateId: 'harvard',
|
||||
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
|
||||
useProfileAvatar: true,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(await screen.findByTitle('Tailored CV preview')).toBeInTheDocument();
|
||||
|
||||
const appendChildSpy = jest.spyOn(document.body, 'appendChild');
|
||||
const removeSpy = jest.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => {});
|
||||
const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /download pdf/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/export-tailored-cv-pdf', expect.objectContaining({
|
||||
templateId: 'harvard',
|
||||
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
|
||||
}), expect.objectContaining({ responseType: 'blob' }));
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
appendChildSpy.mockRestore();
|
||||
removeSpy.mockRestore();
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('strategy snapshot can be generated from overview', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
||||
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
|
||||
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
|
||||
|
||||
@@ -43,6 +45,8 @@ function initialsFrom(s?: string) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
|
||||
|
||||
export default function AppShell({
|
||||
pageTitle,
|
||||
breadcrumbs,
|
||||
@@ -79,7 +83,23 @@ export default function AppShell({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const drawerWidth = 254;
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const [desktopNavCollapsed, setDesktopNavCollapsed] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(DESKTOP_SIDEBAR_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const drawerWidth = isMobile ? 254 : desktopNavCollapsed ? 84 : 254;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_SIDEBAR_KEY, desktopNavCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}, [desktopNavCollapsed]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groupItems = (items: NavItem[]) => {
|
||||
@@ -98,15 +118,15 @@ export default function AppShell({
|
||||
}, [nav, navBottom]);
|
||||
|
||||
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
|
||||
<Box sx={{ px: 1.25, pt: 1 }}>
|
||||
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
|
||||
{groups.map(([section, rows]) => (
|
||||
<Box key={section || "_"} sx={{ mb: 1.25 }}>
|
||||
{section ? (
|
||||
<Box key={section || "_"} sx={{ mb: desktopNavCollapsed ? 1 : 1.25 }}>
|
||||
{section && !desktopNavCollapsed ? (
|
||||
<Typography variant="caption" sx={{ px: 1.25, color: "text.secondary", fontWeight: 600, textTransform: "uppercase" }}>
|
||||
{section}
|
||||
</Typography>
|
||||
) : null}
|
||||
<List sx={{ px: 0.75, pt: 0.75 }}>
|
||||
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
|
||||
{rows.map((item) => {
|
||||
const selected = pathname === item.to || pathname.startsWith(item.to + "/");
|
||||
return (
|
||||
@@ -114,24 +134,28 @@ export default function AppShell({
|
||||
key={item.to}
|
||||
selected={selected}
|
||||
onClick={() => onNavigate(item.to)}
|
||||
sx={(theme: any) => ({
|
||||
title={desktopNavCollapsed ? item.label : undefined}
|
||||
sx={(muiTheme: any) => ({
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
minHeight: 44,
|
||||
px: desktopNavCollapsed ? 1 : 1.5,
|
||||
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
|
||||
border: "1px solid transparent",
|
||||
"&.Mui-selected": {
|
||||
backgroundColor: theme.vars.palette.action.hover,
|
||||
borderColor: theme.vars.palette.divider,
|
||||
backgroundColor: muiTheme.vars.palette.action.hover,
|
||||
borderColor: muiTheme.vars.palette.divider,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<ListItemIcon sx={{ minWidth: desktopNavCollapsed ? 0 : 36, justifyContent: "center" }}>
|
||||
{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 }} />
|
||||
{!desktopNavCollapsed ? <ListItemText primary={item.label} primaryTypographyProps={{ fontWeight: 600 }} /> : null}
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
@@ -143,16 +167,20 @@ export default function AppShell({
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ px: 2.25, py: 2.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
|
||||
<JobbjaktMark style={{ width: 22, height: 22 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
{!desktopNavCollapsed ? (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("appTagline")}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("appTagline")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
@@ -179,66 +207,166 @@ export default function AppShell({
|
||||
position="fixed"
|
||||
color="inherit"
|
||||
elevation={0}
|
||||
sx={(theme: any) => ({
|
||||
borderBottom: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
sx={(muiTheme: any) => ({
|
||||
borderBottom: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
zIndex: muiTheme.zIndex.drawer + 1,
|
||||
})}
|
||||
>
|
||||
<Toolbar sx={{ gap: 1.5, px: { xs: 2, md: 3 }, minHeight: { xs: 68, md: 76 } }}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ display: { xs: "inline-flex", md: "none" }, border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<MenuIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Toolbar
|
||||
sx={{
|
||||
gap: 1.25,
|
||||
px: { xs: 2, md: 3 },
|
||||
py: { xs: 1.25, md: 0 },
|
||||
minHeight: { xs: 68, md: 76 },
|
||||
alignItems: { xs: "stretch", md: "center" },
|
||||
flexWrap: { xs: "wrap", md: "nowrap" },
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, width: "100%", minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0 }}>
|
||||
<IconButton
|
||||
edge="start"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => onToggleDrawer(true)}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<MenuIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ flex: 1, display: "flex", alignItems: "center", gap: 1.25 }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.9, minWidth: 0, pl: 0.25 }}>
|
||||
<JobbjaktMark style={{ width: 18, height: 18, flex: "0 0 auto" }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, letterSpacing: -0.2 }} noWrap>
|
||||
Jobbjakt
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
{rightActions}
|
||||
|
||||
{user ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
<Box sx={{ display: { xs: "none", sm: "block" } }}>
|
||||
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }}>{user.userName || user.displayName || user.email || t("user")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{user.roleLabel || ""}
|
||||
</Typography>
|
||||
{user ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", width: 42, height: 42, flex: "0 0 auto" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 28, height: 28, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
) : <Box sx={{ width: 42, height: 42 }} />}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, width: "100%" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flex: "0 0 auto" }}>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
'& > *': { minWidth: 0 },
|
||||
}}
|
||||
>
|
||||
{rightActions}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="secondary"
|
||||
onClick={() => setDesktopNavCollapsed((value) => !value)}
|
||||
title={desktopNavCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, width: 40, height: 40 }}
|
||||
>
|
||||
<MenuOpenIcon fontSize="small" sx={{ transform: desktopNavCollapsed ? "scaleX(-1)" : "none" }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
width: { xs: "100%", md: "auto" },
|
||||
flex: { xs: "1 1 100%", md: "0 1 auto" },
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("notifications")}
|
||||
onClick={onOpenNotifications}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<Badge color="primary" badgeContent={notificationsCount || 0} max={99}>
|
||||
<NotificationsNoneIcon fontSize="small" />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("settings")}
|
||||
onClick={onOpenSettings}
|
||||
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2 }}
|
||||
>
|
||||
<SettingsOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", flex: { xs: "1 1 auto", md: "0 1 auto" }, justifyContent: "flex-end" }}>
|
||||
{rightActions}
|
||||
</Box>
|
||||
|
||||
{user ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25, pl: { xs: 0, sm: 1 } }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setUserMenuAnchor(e.currentTarget)}
|
||||
sx={{ borderRadius: 2, border: "1px solid", borderColor: "divider" }}
|
||||
>
|
||||
<Avatar src={user.avatarImageDataUrl || undefined} sx={{ width: 30, height: 30, fontWeight: 900 }}>{initials}</Avatar>
|
||||
</IconButton>
|
||||
<Box sx={{ display: { xs: "none", sm: "block" }, minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 600, lineHeight: 1.2 }} noWrap>
|
||||
{user.userName || user.displayName || user.email || t("user")}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{user.roleLabel || ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu
|
||||
anchorEl={userMenuAnchor}
|
||||
@@ -278,15 +406,15 @@ export default function AppShell({
|
||||
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={(theme: any) => ({
|
||||
sx={(muiTheme: any) => ({
|
||||
display: { xs: "none", md: "block" },
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: drawerWidth,
|
||||
boxSizing: "border-box",
|
||||
borderRight: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
boxShadow: "none",
|
||||
},
|
||||
@@ -302,12 +430,12 @@ export default function AppShell({
|
||||
open={drawerOpen}
|
||||
onClose={() => onToggleDrawer(false)}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={(theme: any) => ({
|
||||
sx={(muiTheme: any) => ({
|
||||
display: { xs: "block", md: "none" },
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: drawerWidth,
|
||||
borderRight: `1px solid ${theme.vars.palette.grey[300]}`,
|
||||
backgroundColor: theme.vars.palette.background.default,
|
||||
borderRight: `1px solid ${muiTheme.vars.palette.grey[300]}`,
|
||||
backgroundColor: muiTheme.vars.palette.background.default,
|
||||
backgroundImage: "none",
|
||||
},
|
||||
})}
|
||||
@@ -324,18 +452,18 @@ export default function AppShell({
|
||||
minHeight: "100vh",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%" }}>
|
||||
<Toolbar sx={{ minHeight: { xs: 68, md: 76 } }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mb: 2 }}>
|
||||
<Box sx={{ mx: "auto", maxWidth: 1320, width: "100%", minWidth: 0 }}>
|
||||
<Toolbar sx={{ minHeight: { xs: isMobile ? 124 : 68, md: 76 } }} />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, mb: 2, minWidth: 0 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5 }}>
|
||||
<Breadcrumbs sx={{ color: "text.secondary", mb: 0.5, '& .MuiBreadcrumbs-ol': { flexWrap: 'wrap' } }}>
|
||||
{breadcrumbs.map((c) => (
|
||||
<Typography key={c} variant="caption" sx={{ color: "text.secondary", fontWeight: 600 }} noWrap>
|
||||
<Typography key={c} variant="caption" sx={{ color: "text.secondary", fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{c}
|
||||
</Typography>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }} noWrap>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -26,6 +26,14 @@ type AiServiceMetrics = {
|
||||
gpuName?: string | null;
|
||||
ocrAvailable?: boolean | null;
|
||||
ocrLanguages?: string | null;
|
||||
ollamaConfigured?: boolean | null;
|
||||
ollamaReachable?: boolean | null;
|
||||
ollamaModel?: string | null;
|
||||
ollamaModelAvailable?: boolean | null;
|
||||
ollamaVersion?: string | null;
|
||||
ollamaInstalledModels?: string[] | null;
|
||||
ollamaLoadedModels?: string[] | null;
|
||||
ollamaLoadedCount?: number | null;
|
||||
healthLatencyMs?: number | null;
|
||||
probeLatencyMs?: number | null;
|
||||
lastProbeAt?: string | null;
|
||||
@@ -60,6 +68,47 @@ type EditableEmailSettings = {
|
||||
hasPassword: boolean;
|
||||
};
|
||||
|
||||
type CvBenchmarkEntry = {
|
||||
FileName: string;
|
||||
Slug: string;
|
||||
Extension: string;
|
||||
Characters: number;
|
||||
OutputPath: string;
|
||||
ApprovedFixturePath?: string | null;
|
||||
CandidateFixturePath?: string | null;
|
||||
ContactLocation?: string | null;
|
||||
FirstJob?: string | null;
|
||||
FirstJobLocation?: string | null;
|
||||
FirstEducation?: string | null;
|
||||
FirstEducationLocation?: string | null;
|
||||
QualificationLevels: string[];
|
||||
SuspiciousLocations: string[];
|
||||
CoverageScore: number;
|
||||
ConfidenceScore: number;
|
||||
ConsistencyScore: number;
|
||||
DiffSummary?: string | null;
|
||||
};
|
||||
|
||||
type CvBenchmarkIndex = {
|
||||
CorpusRoot: string;
|
||||
OutputRoot: string;
|
||||
GeneratedAtUtc: string;
|
||||
TotalFiles: number;
|
||||
AverageCoverage: number;
|
||||
AverageConfidence: number;
|
||||
AverageConsistency: number;
|
||||
FilesWithSuspiciousLocations: number;
|
||||
MissingApprovedFixtures: number;
|
||||
Entries: CvBenchmarkEntry[];
|
||||
};
|
||||
|
||||
type CvBenchmarkStatus = {
|
||||
indexJson?: string | null;
|
||||
reportMarkdown?: string | null;
|
||||
rootPath: string;
|
||||
lastUpdatedAtUtc?: string | null;
|
||||
};
|
||||
|
||||
type SystemStatus = {
|
||||
environment: string;
|
||||
contentRoot: string;
|
||||
@@ -121,6 +170,26 @@ function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function formatPercent(value?: number | null) {
|
||||
return typeof value === "number" ? `${Math.round(value * 100)}%` : "-";
|
||||
}
|
||||
|
||||
function parseBenchmarkIndex(indexJson?: string | null): CvBenchmarkIndex | null {
|
||||
if (!indexJson?.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(indexJson) as CvBenchmarkIndex;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function benchmarkTone(value?: number | null) {
|
||||
if (typeof value !== "number") return "default" as const;
|
||||
if (value >= 0.8) return "success" as const;
|
||||
if (value >= 0.6) return "warning" as const;
|
||||
return "error" as const;
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -141,6 +210,7 @@ export default function AdminSystemPage() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [emailSettings, setEmailSettings] = useState<EditableEmailSettings | null>(null);
|
||||
const [benchmarkStatus, setBenchmarkStatus] = useState<CvBenchmarkStatus | null>(null);
|
||||
const [smtpPassword, setSmtpPassword] = useState("");
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -156,18 +226,21 @@ export default function AdminSystemPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [statusRes, emailRes] = await Promise.all([
|
||||
const [statusRes, emailRes, benchmarkRes] = await Promise.all([
|
||||
api.get<SystemStatus>("/admin/system"),
|
||||
api.get<EditableEmailSettings>("/admin/system/email-settings"),
|
||||
api.get<CvBenchmarkStatus>("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)),
|
||||
]);
|
||||
setStatus(statusRes.data);
|
||||
setEmailSettings(emailRes.data);
|
||||
setBenchmarkStatus(benchmarkRes.data ?? null);
|
||||
setSmtpPassword("");
|
||||
setClearPassword(false);
|
||||
} catch (e: any) {
|
||||
setError(getApiErrorMessage(e, "Failed to load system status."));
|
||||
setStatus(null);
|
||||
setEmailSettings(null);
|
||||
setBenchmarkStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -191,6 +264,34 @@ export default function AdminSystemPage() {
|
||||
return "success" as const;
|
||||
}, [status]);
|
||||
|
||||
const benchmarkIndex = useMemo(() => parseBenchmarkIndex(benchmarkStatus?.indexJson), [benchmarkStatus?.indexJson]);
|
||||
const weakestEntries = useMemo(() => {
|
||||
if (!benchmarkIndex) return [] as CvBenchmarkEntry[];
|
||||
return [...benchmarkIndex.Entries]
|
||||
.sort((a, b) => (a.CoverageScore + a.ConfidenceScore + a.ConsistencyScore) - (b.CoverageScore + b.ConfidenceScore + b.ConsistencyScore))
|
||||
.slice(0, 6);
|
||||
}, [benchmarkIndex]);
|
||||
|
||||
const benchmarkFindings = useMemo(() => {
|
||||
if (!benchmarkIndex) return [] as Array<{ file: string; issue: string }>;
|
||||
return benchmarkIndex.Entries.flatMap((entry) => {
|
||||
const findings: Array<{ file: string; issue: string }> = [];
|
||||
if (entry.ContactLocation && /(culture|education|arial|hobbies|cooperate|ag, ni|bold)/i.test(entry.ContactLocation)) {
|
||||
findings.push({ file: entry.FileName, issue: `Suspicious contact location: ${entry.ContactLocation}` });
|
||||
}
|
||||
if (entry.FirstEducation && entry.FirstEducation.length > 120) {
|
||||
findings.push({ file: entry.FileName, issue: "Education qualification looks over-captured." });
|
||||
}
|
||||
if ((entry.FirstJob ?? "").length > 120) {
|
||||
findings.push({ file: entry.FileName, issue: "Work title looks over-captured." });
|
||||
}
|
||||
if ((entry.QualificationLevels ?? []).includes("Other")) {
|
||||
findings.push({ file: entry.FileName, issue: "Qualification level fell back to Other." });
|
||||
}
|
||||
return findings;
|
||||
}).slice(0, 10);
|
||||
}, [benchmarkIndex]);
|
||||
|
||||
const sendTestEmail = async () => {
|
||||
setSendingTestEmail(true);
|
||||
try {
|
||||
@@ -363,6 +464,12 @@ export default function AdminSystemPage() {
|
||||
<DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
|
||||
<DetailRow label={t("adminSystemGpuAvailable")} value={status?.ai.gpuAvailable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemGpuName")} value={status?.ai.gpuName || "-"} />
|
||||
<DetailRow label={t("adminSystemOllamaConfigured")} value={status?.ai.ollamaConfigured ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemOllamaReachable")} value={status?.ai.ollamaReachable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemOllamaModel")} value={status?.ai.ollamaModel || "-"} />
|
||||
<DetailRow label={t("adminSystemOllamaModelAvailable")} value={status?.ai.ollamaModelAvailable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label="Ollama version" value={status?.ai.ollamaVersion || "-"} />
|
||||
<DetailRow label="Loaded models" value={status?.ai.ollamaLoadedCount ?? 0} />
|
||||
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
|
||||
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
|
||||
<DetailRow label={t("adminSystemLastProbe")} value={formatDate(status?.ai.lastProbeAt)} />
|
||||
@@ -391,8 +498,79 @@ export default function AdminSystemPage() {
|
||||
<Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" />
|
||||
<Chip label={status?.ai.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.ai.gpuAvailable ? "success" : "default"} size="small" />
|
||||
<Chip label={status?.ai.ocrAvailable ? `OCR ${status.ai.ocrLanguages || "enabled"}` : t("adminSystemOcrUnavailable")} variant="outlined" size="small" />
|
||||
{(status?.ai.ollamaInstalledModels ?? []).slice(0, 4).map((model) => (
|
||||
<Chip key={model} label={`Model · ${model}`} variant="outlined" size="small" />
|
||||
))}
|
||||
{(status?.ai.ollamaLoadedModels ?? []).slice(0, 3).map((model) => (
|
||||
<Chip key={`loaded-${model}`} label={`Loaded · ${model}`} color="primary" variant="outlined" size="small" />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>CV benchmark review</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
<DetailRow label="Benchmark root" value={benchmarkStatus?.rootPath || "-"} />
|
||||
<DetailRow label="Last benchmark update" value={formatDate(benchmarkStatus?.lastUpdatedAtUtc)} />
|
||||
<DetailRow label="Corpus root" value={benchmarkIndex?.CorpusRoot || "-"} />
|
||||
</Stack>
|
||||
|
||||
{benchmarkIndex ? (
|
||||
<>
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(5, 1fr)" }, gap: 1.25 }}>
|
||||
<SummaryCard title="Files" value={String(benchmarkIndex.TotalFiles)} subtitle="Corpus inputs" />
|
||||
<SummaryCard title="Coverage" value={formatPercent(benchmarkIndex.AverageCoverage)} subtitle="Structured field coverage" tone={benchmarkTone(benchmarkIndex.AverageCoverage)} />
|
||||
<SummaryCard title="Confidence" value={formatPercent(benchmarkIndex.AverageConfidence)} subtitle="Field metadata confidence" tone={benchmarkTone(benchmarkIndex.AverageConfidence)} />
|
||||
<SummaryCard title="Consistency" value={formatPercent(benchmarkIndex.AverageConsistency)} subtitle="Normalization consistency" tone={benchmarkTone(benchmarkIndex.AverageConsistency)} />
|
||||
<SummaryCard title="Missing approved" value={String(benchmarkIndex.MissingApprovedFixtures)} subtitle="Needs fixture review" tone={benchmarkIndex.MissingApprovedFixtures > 0 ? "warning" : "success"} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2.5, display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.1fr 0.9fr" }, gap: 2 }}>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900, mb: 1 }}>Top parser findings</Typography>
|
||||
<Stack spacing={1}>
|
||||
{benchmarkFindings.length > 0 ? benchmarkFindings.map((finding) => (
|
||||
<Box key={`${finding.file}:${finding.issue}`} sx={{ p: 1.25, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{finding.file}</Typography>
|
||||
<Typography variant="body2">{finding.issue}</Typography>
|
||||
</Box>
|
||||
)) : <Typography variant="body2" sx={{ color: "text.secondary" }}>No standout benchmark anomalies in the current run.</Typography>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900, mb: 1 }}>Weakest files in current run</Typography>
|
||||
<Stack spacing={1}>
|
||||
{weakestEntries.map((entry) => (
|
||||
<Box key={entry.Slug} sx={{ p: 1.25, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{entry.FileName}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
|
||||
<Chip size="small" label={`Coverage ${formatPercent(entry.CoverageScore)}`} color={benchmarkTone(entry.CoverageScore)} />
|
||||
<Chip size="small" label={`Confidence ${formatPercent(entry.ConfidenceScore)}`} color={benchmarkTone(entry.ConfidenceScore)} />
|
||||
<Chip size="small" label={`Consistency ${formatPercent(entry.ConsistencyScore)}`} color={benchmarkTone(entry.ConsistencyScore)} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>{entry.DiffSummary || "-"}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider", maxHeight: 280, overflow: "auto" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Latest markdown summary</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", fontFamily: "ui-monospace, SFMono-Regular, monospace" }}>
|
||||
{benchmarkStatus?.reportMarkdown || "-"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", fontFamily: "ui-monospace, SFMono-Regular, monospace" }}>
|
||||
{benchmarkStatus?.reportMarkdown || "Run scripts/run-cv-benchmark.sh to generate the latest corpus report and fixture candidates."}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
@@ -26,6 +28,7 @@ type UserDto = {
|
||||
};
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||
const { toast } = useToast();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const { t } = useI18n();
|
||||
@@ -149,23 +152,24 @@ export default function AdminUsersPage() {
|
||||
], [remove, sendReset, setAdminRole, t]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Paper sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
|
||||
{t("adminUsersTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("adminUsersSubtitle")}</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Paper sx={{ p: { xs: 1.5, sm: 2 }, mb: 2 }}>
|
||||
<Typography sx={{ fontWeight: 900, mb: 1 }}>{t("adminUsersCreateUser")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<TextField label={t("profileEmail")} value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
||||
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
<TextField label={t("profileEmail")} value={newEmail} onChange={(e) => setNewEmail(e.target.value)} fullWidth />
|
||||
<TextField label={t("profileNewPassword")} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} fullWidth />
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mt: 1.5, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", gap: 2, mt: 1.5, flexWrap: "wrap" }}>
|
||||
<FormControlLabel control={<Checkbox checked={newIsAdmin} onChange={(e) => setNewIsAdmin(e.target.checked)} />} label={t("adminUsersAdmin")} />
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!canCreate || loading}
|
||||
sx={{ width: { xs: "100%", sm: "auto" } }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.post("/users", { email: newEmail, password: newPassword, roles: newIsAdmin ? ["Admin"] : [] });
|
||||
@@ -185,33 +189,78 @@ export default function AdminUsersPage() {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider", overflow: "hidden" }}>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
disableRowSelectionOnClick
|
||||
loading={loading}
|
||||
pageSizeOptions={[5, 10, 25]}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 10, page: 0 } },
|
||||
sorting: { sortModel: [{ field: "email", sort: "asc" }] },
|
||||
}}
|
||||
sx={{
|
||||
border: 0,
|
||||
'& .MuiDataGrid-columnHeaders': {
|
||||
backgroundColor: 'action.hover',
|
||||
fontWeight: 800,
|
||||
},
|
||||
'& .MuiDataGrid-cell': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.5}>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||
) : null}
|
||||
{rows.map((row) => {
|
||||
const user = row.raw as UserDto;
|
||||
const isAdmin = user.roles.includes("Admin");
|
||||
return (
|
||||
<Paper key={row.id} sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 900, overflowWrap: "anywhere" }}>
|
||||
{row.userName || row.email || row.id}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
|
||||
{row.email || "—"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
{(row.roles as string[]).length ? (row.roles as string[]).map((role) => (
|
||||
<Chip key={role} size="small" label={role} variant="outlined" />
|
||||
)) : <Chip size="small" label="—" variant="outlined" />}
|
||||
<Chip size="small" label={row.emailConfirmed ? t("yes") : t("noWord")} color={row.emailConfirmed ? "success" : "default"} variant={row.emailConfirmed ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Button variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(user, !isAdmin)} fullWidth>
|
||||
{t("adminUsersAdmin")}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => void sendReset(user)} fullWidth>
|
||||
{t("adminUsersSendReset")}
|
||||
</Button>
|
||||
<Button color="error" variant="outlined" onClick={() => void remove(user)} fullWidth>
|
||||
{t("adminUsersDelete")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : (
|
||||
<Paper sx={{ borderRadius: 3, border: "1px solid", borderColor: "divider", overflow: "hidden" }}>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
disableRowSelectionOnClick
|
||||
loading={loading}
|
||||
pageSizeOptions={[5, 10, 25]}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 10, page: 0 } },
|
||||
sorting: { sortModel: [{ field: "email", sort: "asc" }] },
|
||||
}}
|
||||
sx={{
|
||||
border: 0,
|
||||
'& .MuiDataGrid-columnHeaders': {
|
||||
backgroundColor: 'action.hover',
|
||||
fontWeight: 800,
|
||||
},
|
||||
'& .MuiDataGrid-cell': {
|
||||
alignItems: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{!loading && rows.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("adminUsersNoUsers")}</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Alert, Avatar, Box, Button, Chip, Divider, FormControl, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
|
||||
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
|
||||
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
||||
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
||||
|
||||
import { api } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
@@ -20,10 +22,11 @@ import {
|
||||
StructuredCvFieldMetadata,
|
||||
StructuredCvProfile,
|
||||
} from "../profileCv";
|
||||
import { JobApplication } from "../types";
|
||||
|
||||
|
||||
type CvSectionOption = "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||
type CvSectionStyle = "balanced" | "concise" | "impact" | "ats";
|
||||
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
|
||||
|
||||
type ExtractionRun = {
|
||||
id: number;
|
||||
@@ -39,6 +42,36 @@ type ExtractionRun = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
type JobListResponse = {
|
||||
items: JobApplication[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
type RewriteTemplateOption = {
|
||||
id: CvSectionStyle;
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
accent: string;
|
||||
blurb: string;
|
||||
sampleHeading: string;
|
||||
sampleMeta: string;
|
||||
sampleBullets: string[];
|
||||
};
|
||||
|
||||
type CvBuilderPreview = {
|
||||
templateId: CvSectionStyle;
|
||||
html: string;
|
||||
suggestedFileName: string;
|
||||
fullText: string;
|
||||
rewrittenText: string;
|
||||
structuredCv: StructuredCvProfile;
|
||||
sectionName?: string | null;
|
||||
targetRole?: string | null;
|
||||
jobApplicationId?: number | null;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
id?: string;
|
||||
@@ -60,6 +93,68 @@ type MeResponse = {
|
||||
|
||||
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
|
||||
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
|
||||
const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
{
|
||||
id: "ats-minimal",
|
||||
title: "ATS Minimal",
|
||||
eyebrow: "Scanner-friendly",
|
||||
accent: "#0f172a",
|
||||
blurb: "Compact, direct, and easy for screening systems to parse.",
|
||||
sampleHeading: "Senior Backend Engineer",
|
||||
sampleMeta: "Acme Systems · Oslo · 2021 - Present",
|
||||
sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."]
|
||||
},
|
||||
{
|
||||
id: "harvard",
|
||||
title: "Harvard",
|
||||
eyebrow: "Traditional",
|
||||
accent: "#7f1d1d",
|
||||
blurb: "Formal hierarchy and restrained tone for conservative hiring flows.",
|
||||
sampleHeading: "Professional Summary",
|
||||
sampleMeta: "Clear structure · precise dates · credible language",
|
||||
sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."]
|
||||
},
|
||||
{
|
||||
id: "auckland",
|
||||
title: "Auckland",
|
||||
eyebrow: "Modern sidebar",
|
||||
accent: "#0f766e",
|
||||
blurb: "Sharper highlights with a more contemporary, design-forward rhythm.",
|
||||
sampleHeading: "Selected Impact",
|
||||
sampleMeta: "Focused strengths · compact highlights",
|
||||
sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."]
|
||||
},
|
||||
{
|
||||
id: "edinburgh",
|
||||
title: "Edinburgh",
|
||||
eyebrow: "Editorial",
|
||||
accent: "#5b21b6",
|
||||
blurb: "More personality and stronger section contrast without losing clarity.",
|
||||
sampleHeading: "Experience Highlights",
|
||||
sampleMeta: "Premium spacing · stronger visual voice",
|
||||
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
|
||||
},
|
||||
{
|
||||
id: "monarch",
|
||||
title: "Monarch",
|
||||
eyebrow: "Executive",
|
||||
accent: "#7c2d12",
|
||||
blurb: "High-contrast premium presentation for leadership-heavy applications.",
|
||||
sampleHeading: "Executive Profile",
|
||||
sampleMeta: "Leadership clarity · premium hierarchy",
|
||||
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
|
||||
},
|
||||
{
|
||||
id: "fjord",
|
||||
title: "Fjord",
|
||||
eyebrow: "Technical",
|
||||
accent: "#0f4c5c",
|
||||
blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.",
|
||||
sampleHeading: "Projects & Systems",
|
||||
sampleMeta: "Technical depth · practical readability",
|
||||
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."]
|
||||
},
|
||||
];
|
||||
|
||||
function initialsFrom(values: Array<string | undefined>) {
|
||||
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
|
||||
@@ -108,6 +203,7 @@ function FieldReviewNote({ metadata }: { metadata?: StructuredCvFieldMetadata })
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75, alignItems: "center" }}>
|
||||
<Chip size="small" color={tone.color} variant={tone.color === "default" ? "outlined" : "filled"} label={tone.label} />
|
||||
{metadata.method ? <Chip size="small" variant="outlined" label={metadata.method} /> : null}
|
||||
{metadata.sourceBlockId ? <Chip size="small" variant="outlined" label={metadata.sourceBlockId} /> : null}
|
||||
{metadata.reviewState ? <Chip size="small" variant="outlined" label={metadata.reviewState} /> : null}
|
||||
{metadata.sourceSnippet ? (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
@@ -140,10 +236,13 @@ export default function ProfilePage() {
|
||||
const [headline, setHeadline] = useState("");
|
||||
const [profileCvText, setProfileCvText] = useState("");
|
||||
const [rewritingSection, setRewritingSection] = useState(false);
|
||||
const [cvSection, setCvSection] = useState<CvSectionOption>("Professional Summary");
|
||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("balanced");
|
||||
const [cvSection, setCvSection] = useState<CvSectionOption>("");
|
||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
||||
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
||||
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
||||
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
||||
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
||||
@@ -153,9 +252,10 @@ export default function ProfilePage() {
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
const [profileResponse, runsResponse] = await Promise.all([
|
||||
const [profileResponse, runsResponse, jobsResponse] = await Promise.all([
|
||||
api.get<MeResponse>("/auth/me"),
|
||||
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
|
||||
api.get<JobListResponse>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }).catch(() => ({ data: { items: [], total: 0, page: 1, pageSize: 100 } } as any)),
|
||||
]);
|
||||
const r = profileResponse;
|
||||
setMe(r.data);
|
||||
@@ -167,10 +267,12 @@ export default function ProfilePage() {
|
||||
setProfileCvText(r.data?.profileCvText ?? "");
|
||||
setStructuredCv(parseStructuredCvJson(r.data?.profileCvStructureJson));
|
||||
setExtractionRuns(runsResponse.data ?? []);
|
||||
setSavedJobs(jobsResponse.data?.items ?? []);
|
||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
||||
} catch {
|
||||
setMe(null);
|
||||
setExtractionRuns([]);
|
||||
setSavedJobs([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -191,6 +293,9 @@ export default function ProfilePage() {
|
||||
: t("profileGoogleNotLinked");
|
||||
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
|
||||
const latestRun = extractionRuns[0];
|
||||
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
|
||||
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
|
||||
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
@@ -399,22 +504,40 @@ export default function ProfilePage() {
|
||||
>
|
||||
{reprocessingCv ? t("profileCvReprocessing") : t("profileCvReprocess")}
|
||||
</Button>
|
||||
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
||||
{t("profileCopyCvText")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{uploadingCv ? <LinearProgress sx={{ mb: 1.5 }} /> : null}
|
||||
<TextField
|
||||
label={t("profileCvTextLabel")}
|
||||
value={profileCvText}
|
||||
onChange={(e) => setProfileCvText(e.target.value)}
|
||||
helperText={t("profileCvTextHelp")}
|
||||
multiline
|
||||
minRows={12}
|
||||
disabled={!isLocal}
|
||||
fullWidth
|
||||
/>
|
||||
<Alert severity="info" sx={{ mb: 2, borderRadius: 2.5 }}>
|
||||
{t("profileCvStructuredDefaultHint")}
|
||||
</Alert>
|
||||
<Accordion disableGutters elevation={0} sx={{ mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", "&:before": { display: "none" } }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, alignItems: "center", width: "100%", pr: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvRawPanelTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvRawPanelHelp")}</Typography>
|
||||
</Box>
|
||||
<Chip size="small" label={t("profileCvSectionWordCount", { count: cvWordCount })} />
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<TextField
|
||||
label={t("profileCvTextLabel")}
|
||||
value={profileCvText}
|
||||
onChange={(e) => setProfileCvText(e.target.value)}
|
||||
helperText={t("profileCvTextHelp")}
|
||||
multiline
|
||||
minRows={12}
|
||||
disabled={!isLocal}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ mt: 1.5, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
||||
{t("profileCopyCvText")}
|
||||
</Button>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
@@ -652,39 +775,75 @@ export default function ProfilePage() {
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvSectionTools")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvSectionToolsHelp")}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", maxWidth: 720 }}>
|
||||
Choose a template, optionally target one section, and tailor the output toward a saved job or free-text role target. The preview below renders the actual PDF layout before you apply it.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip size="small" variant="outlined" label={cvSection || "Whole CV rewrite"} />
|
||||
{selectedRewriteJob ? <Chip size="small" color="primary" variant="outlined" label={`Saved job · ${selectedRewriteJob.jobTitle}`} /> : null}
|
||||
{rewriteReady ? <Chip size="small" color="success" label="Preview ready" /> : null}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!isLocal || !profileCvText.trim() || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
try {
|
||||
const res = await api.post<{ text?: string }>("/profile-cv/rewrite-section", {
|
||||
sectionName: cvSection,
|
||||
style: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
});
|
||||
setCvSectionDraft(res.data?.text ?? "");
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rewritingSection ? t("profileCvSectionRewriting") : t("profileCvSectionRewrite")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1.2fr" }, gap: 1.5, mb: 1.5 }}>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
||||
{REWRITE_TEMPLATES.map((option) => {
|
||||
const selected = option.id === cvSectionStyle;
|
||||
return (
|
||||
<Paper
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setCvSectionStyle(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setCvSectionStyle(option.id);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3.5,
|
||||
cursor: "pointer",
|
||||
border: "1px solid",
|
||||
borderColor: selected ? "primary.main" : "divider",
|
||||
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.18), 0 12px 30px rgba(15,23,42,0.08)" : "0 6px 18px rgba(15,23,42,0.04)",
|
||||
background: selected ? `linear-gradient(180deg, ${option.accent}12 0%, rgba(255,255,255,0.96) 100%)` : "background.paper",
|
||||
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
|
||||
'&:hover': { transform: 'translateY(-2px)' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1, mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.14em' }}>{option.eyebrow}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
|
||||
<ZoomInOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.25, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 160 }}>
|
||||
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 800, mb: 0.5 }}>{option.sampleHeading}</Typography>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
|
||||
{option.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}>• {bullet}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{option.blurb}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
||||
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
||||
<MenuItem value="">Whole CV</MenuItem>
|
||||
<MenuItem value="Professional Summary">{t("profileCvSectionSummary")}</MenuItem>
|
||||
<MenuItem value="Core Skills">{t("profileCvSectionSkills")}</MenuItem>
|
||||
<MenuItem value="Experience Highlights">{t("profileCvSectionExperience")}</MenuItem>
|
||||
@@ -692,31 +851,156 @@ export default function ProfilePage() {
|
||||
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("profileCvSectionStyle")}</InputLabel>
|
||||
<Select value={cvSectionStyle} label={t("profileCvSectionStyle")} onChange={(e) => setCvSectionStyle(e.target.value as CvSectionStyle)}>
|
||||
<MenuItem value="balanced">{t("profileCvSectionStyleBalanced")}</MenuItem>
|
||||
<MenuItem value="concise">{t("profileCvSectionStyleConcise")}</MenuItem>
|
||||
<MenuItem value="impact">{t("profileCvSectionStyleImpact")}</MenuItem>
|
||||
<MenuItem value="ats">{t("profileCvSectionStyleAts")}</MenuItem>
|
||||
<TextField
|
||||
label={t("profileCvSectionTargetRole")}
|
||||
value={cvSectionTargetRole}
|
||||
onChange={(e) => setCvSectionTargetRole(e.target.value)}
|
||||
fullWidth
|
||||
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
|
||||
/>
|
||||
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<InputLabel>Saved job context</InputLabel>
|
||||
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
||||
<MenuItem value="">None</MenuItem>
|
||||
{savedJobs.map((job) => (
|
||||
<MenuItem key={job.id} value={String(job.id)}>{job.jobTitle} · {job.company?.name ?? "Unknown company"}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth />
|
||||
</Box>
|
||||
<TextField
|
||||
label={t("profileCvSectionDraft")}
|
||||
value={cvSectionDraft}
|
||||
onChange={(e) => setCvSectionDraft(e.target.value)}
|
||||
multiline
|
||||
minRows={6}
|
||||
fullWidth
|
||||
placeholder={t("profileCvSectionDraftPlaceholder")}
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
|
||||
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim())}>{t("profileCvSectionAppend")}</Button>
|
||||
<Button variant="contained" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => replaceCvSection(prev, cvSection, cvSectionDraft))}>{t("profileCvSectionReplace")}</Button>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Builder output</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{selectedRewriteTemplate.title} · {rewritePreview?.targetRole || selectedRewriteJob?.jobTitle || cvSectionTargetRole || "General reuse"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
try {
|
||||
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
});
|
||||
setRewritePreview(res.data);
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rewritingSection ? t("profileCvSectionRewriting") : rewriteReady ? "Refresh preview" : "Build preview"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!rewriteReady}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.post("/profile-cv/export-pdf", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
}, { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = rewritePreview?.suggestedFileName || `${cvSectionStyle}-cv.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast("CV PDF downloaded.", "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || "Failed to export the CV PDF."), "error");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
|
||||
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
||||
{rewriteReady ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.25, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!rewriteReady} onClick={() => navigator.clipboard.writeText(rewritePreview?.fullText ?? "")}>{t("profileCopyCvText")}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!rewriteReady}
|
||||
onClick={() => {
|
||||
setProfileCvText(rewritePreview?.fullText ?? "");
|
||||
if (rewritePreview?.structuredCv) setStructuredCv(normalizeStructuredCv(rewritePreview.structuredCv));
|
||||
}}
|
||||
>
|
||||
Replace master CV
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Styled preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · print-ready layout</Typography>
|
||||
</Box>
|
||||
{rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||
{rewriteReady ? (
|
||||
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||
) : (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
|
||||
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, spacing, and hierarchy before you apply it.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
|
||||
<DialogContent>
|
||||
{rewritePreviewTemplate ? (
|
||||
<Box sx={{ p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${rewritePreviewTemplate.accent}12 0%, rgba(255,255,255,0) 100%)` }}>
|
||||
<Typography variant="overline" sx={{ color: rewritePreviewTemplate.accent, fontWeight: 800 }}>{rewritePreviewTemplate.eyebrow}</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 0.5 }}>{rewritePreviewTemplate.sampleHeading}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{rewritePreviewTemplate.sampleMeta}</Typography>
|
||||
{rewritePreviewTemplate.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="body2" sx={{ mb: 0.75 }}>• {bullet}</Typography>
|
||||
))}
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1.5 }}>{rewritePreviewTemplate.blurb}</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
|
||||
@@ -29,9 +29,9 @@ const structuredCv = {
|
||||
appliedExtractionRunId: 12,
|
||||
updatedAtUtc: '2026-03-28T12:00:00Z',
|
||||
fields: {
|
||||
'contact.fullName': { confidence: 0.92, method: 'llm', reviewState: 'suggested', sourceSnippet: 'Demo User' },
|
||||
summary: { confidence: 0.71, method: 'deterministic', reviewState: 'suggested', sourceSnippet: 'Built backend systems' },
|
||||
skills: { confidence: 0.68, method: 'deterministic', reviewState: 'suggested', sourceSnippet: '.NET' },
|
||||
'contact.fullName': { confidence: 0.92, method: 'llm', sourceBlockId: 'block-1', reviewState: 'suggested', sourceSnippet: 'Demo User' },
|
||||
summary: { confidence: 0.71, method: 'deterministic', sourceBlockId: 'block-2', reviewState: 'suggested', sourceSnippet: 'Built backend systems' },
|
||||
skills: { confidence: 0.68, method: 'deterministic', sourceBlockId: 'block-3', reviewState: 'suggested', sourceSnippet: '.NET' },
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
@@ -108,6 +108,27 @@ beforeEach(() => {
|
||||
],
|
||||
} as any);
|
||||
}
|
||||
if (url === '/jobapplications') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 42,
|
||||
jobTitle: 'Senior Backend Engineer',
|
||||
company: { id: 7, name: 'Acme Systems' },
|
||||
status: 'Waiting',
|
||||
dateApplied: '2026-03-20',
|
||||
daysSince: 10,
|
||||
description: 'Build API integrations and platform workflows.',
|
||||
responseReceived: false,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
@@ -125,6 +146,9 @@ beforeEach(() => {
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === '/profile-cv/rewrite-preview') {
|
||||
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
|
||||
}
|
||||
if (url === '/profile-cv/reprocess') {
|
||||
return Promise.resolve({ data: { reprocessed: true } } as any);
|
||||
}
|
||||
@@ -147,9 +171,17 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
|
||||
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/current run/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/original extraction/i).length).toBeGreaterThan(0);
|
||||
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i });
|
||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
||||
expect(screen.getByText(/high 92%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/block-1/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(originalExtractionToggle);
|
||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
||||
|
||||
const analyzeButton = screen.getByRole('button', { name: /analyze sections/i });
|
||||
await waitFor(() => expect(analyzeButton).toBeEnabled());
|
||||
@@ -174,6 +206,49 @@ test('profile page can reprocess from stored artifact history', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('profile page keeps raw extraction collapsed until expanded', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/the structured cv stays front and center/i)).toBeInTheDocument();
|
||||
|
||||
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i });
|
||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
|
||||
expect(copyButton).toBeDisabled();
|
||||
|
||||
fireEvent.click(originalExtractionToggle);
|
||||
|
||||
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
|
||||
const copyButtons = screen.getAllByRole('button', { name: /copy cv text/i });
|
||||
expect(copyButtons.some((button) => !button.hasAttribute('disabled'))).toBe(true);
|
||||
});
|
||||
|
||||
test('profile page rewrite tools use selected template and saved job context', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(/harvard/i));
|
||||
fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
|
||||
fireEvent.click(await screen.findByText(/senior backend engineer · acme systems/i));
|
||||
|
||||
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
|
||||
fireEvent.click(rewriteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-preview', expect.objectContaining({
|
||||
sectionName: null,
|
||||
style: 'harvard',
|
||||
templateId: 'harvard',
|
||||
jobApplicationId: 42,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('saving profile persists structured cv json', async () => {
|
||||
renderPage();
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export type StructuredCvJob = {
|
||||
|
||||
export type StructuredCvEducation = {
|
||||
qualification?: string;
|
||||
qualificationLevel?: string;
|
||||
institution?: string;
|
||||
location?: string;
|
||||
start?: string;
|
||||
@@ -51,6 +52,24 @@ export type StructuredCvEducation = {
|
||||
details: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvCertification = {
|
||||
name?: string;
|
||||
issuer?: string;
|
||||
location?: string;
|
||||
date?: string;
|
||||
details: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvProject = {
|
||||
name?: string;
|
||||
role?: string;
|
||||
location?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
bullets: string[];
|
||||
skills: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvLanguage = {
|
||||
name?: string;
|
||||
level?: string;
|
||||
@@ -69,6 +88,8 @@ export type StructuredCvProfile = {
|
||||
summary: string[];
|
||||
jobs: StructuredCvJob[];
|
||||
education: StructuredCvEducation[];
|
||||
certifications: StructuredCvCertification[];
|
||||
projects: StructuredCvProject[];
|
||||
skills: string[];
|
||||
languages: StructuredCvLanguage[];
|
||||
interests: string[];
|
||||
@@ -95,6 +116,8 @@ export function emptyStructuredCv(): StructuredCvProfile {
|
||||
summary: [],
|
||||
jobs: [],
|
||||
education: [],
|
||||
certifications: [],
|
||||
projects: [],
|
||||
skills: [],
|
||||
languages: [],
|
||||
interests: [],
|
||||
@@ -214,6 +237,7 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
education: Array.isArray(source.education)
|
||||
? source.education.map((education: any) => ({
|
||||
qualification: normalizeString(education?.qualification),
|
||||
qualificationLevel: normalizeString(education?.qualificationLevel),
|
||||
institution: normalizeString(education?.institution),
|
||||
location: normalizeString(education?.location),
|
||||
start: normalizeString(education?.start),
|
||||
@@ -221,6 +245,26 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
details: normalizeList(education?.details),
|
||||
}))
|
||||
: [],
|
||||
certifications: Array.isArray(source.certifications)
|
||||
? source.certifications.map((certification: any) => ({
|
||||
name: normalizeString(certification?.name),
|
||||
issuer: normalizeString(certification?.issuer),
|
||||
location: normalizeString(certification?.location),
|
||||
date: normalizeString(certification?.date),
|
||||
details: normalizeList(certification?.details),
|
||||
}))
|
||||
: [],
|
||||
projects: Array.isArray(source.projects)
|
||||
? source.projects.map((project: any) => ({
|
||||
name: normalizeString(project?.name),
|
||||
role: normalizeString(project?.role),
|
||||
location: normalizeString(project?.location),
|
||||
start: normalizeString(project?.start),
|
||||
end: normalizeString(project?.end),
|
||||
bullets: normalizeList(project?.bullets),
|
||||
skills: normalizeList(project?.skills),
|
||||
}))
|
||||
: [],
|
||||
skills: normalizeList(source.skills),
|
||||
languages: Array.isArray(source.languages)
|
||||
? source.languages.map((language: any) => ({
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { joinLines, splitLines } from "./profileCv";
|
||||
import { TailoredCvDraft } from "./types";
|
||||
|
||||
const DEFAULT_SECTION_ORDER = ["summary", "skills", "experience", "education", "custom"];
|
||||
|
||||
export function emptyTailoredCvDraft(): TailoredCvDraft {
|
||||
return {
|
||||
templateId: "ats-minimal",
|
||||
headline: "",
|
||||
summary: [],
|
||||
selectedSkills: [],
|
||||
experience: [],
|
||||
education: [],
|
||||
customSections: [],
|
||||
renderOptions: {
|
||||
showPhoto: false,
|
||||
pageMode: "one-page",
|
||||
accentColor: "slate",
|
||||
sectionOrder: DEFAULT_SECTION_ORDER,
|
||||
bulletDensity: "balanced",
|
||||
},
|
||||
status: "empty",
|
||||
renderedText: "",
|
||||
isLegacyFallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateRange(start?: string | null, end?: string | null, isCurrent?: boolean) {
|
||||
const normalizedStart = start?.trim();
|
||||
const normalizedEnd = end?.trim();
|
||||
if (!normalizedStart && !normalizedEnd) return "";
|
||||
if (!normalizedStart) return normalizedEnd ?? "";
|
||||
return `${normalizedStart} - ${isCurrent ? "Present" : normalizedEnd || "Present"}`;
|
||||
}
|
||||
|
||||
export function renderTailoredCvDraftText(source?: Partial<TailoredCvDraft> | null) {
|
||||
const draft = emptyTailoredCvDraft();
|
||||
const sourceSummary = source?.summary;
|
||||
const sourceSelectedSkills = source?.selectedSkills;
|
||||
const sourceExperience = source?.experience;
|
||||
const sourceEducation = source?.education;
|
||||
const sourceCustomSections = source?.customSections;
|
||||
|
||||
const summary = Array.isArray(sourceSummary) ? sourceSummary.filter(Boolean) : [];
|
||||
const selectedSkills = Array.isArray(sourceSelectedSkills) ? sourceSelectedSkills.filter(Boolean) : [];
|
||||
const experience = Array.isArray(sourceExperience) ? sourceExperience.filter(Boolean) : [];
|
||||
const education = Array.isArray(sourceEducation) ? sourceEducation.filter(Boolean) : [];
|
||||
const customSections = Array.isArray(sourceCustomSections) ? sourceCustomSections.filter(Boolean) : [];
|
||||
|
||||
const normalized = {
|
||||
...draft,
|
||||
...source,
|
||||
summary,
|
||||
selectedSkills,
|
||||
experience,
|
||||
education,
|
||||
customSections,
|
||||
};
|
||||
|
||||
const sections: string[] = [];
|
||||
if (normalized.headline?.trim()) {
|
||||
sections.push(normalized.headline.trim());
|
||||
}
|
||||
if (normalized.summary.length) {
|
||||
sections.push(`Professional Summary\n${normalized.summary.map((item) => `- ${item.trim()}`).join("\n")}`);
|
||||
}
|
||||
if (normalized.selectedSkills.length) {
|
||||
sections.push(`Core Skills\n${normalized.selectedSkills.map((item) => item.trim()).join("\n")}`);
|
||||
}
|
||||
if (normalized.experience.length) {
|
||||
const body = normalized.experience.map((item) => {
|
||||
const header = [item.title, item.company, item.location, formatDateRange(item.start, item.end, item.isCurrent)]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
const bullets = (item.bullets ?? []).filter(Boolean).map((bullet) => `- ${bullet.trim()}`).join("\n");
|
||||
return [header, bullets].filter(Boolean).join("\n");
|
||||
}).filter(Boolean).join("\n\n");
|
||||
if (body) sections.push(`Experience\n${body}`);
|
||||
}
|
||||
if (normalized.education.length) {
|
||||
const body = normalized.education.map((item) => {
|
||||
const header = [item.qualification, item.institution, item.location, formatDateRange(item.start, item.end, false)]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
const details = (item.details ?? []).filter(Boolean).map((detail) => `- ${detail.trim()}`).join("\n");
|
||||
return [header, details].filter(Boolean).join("\n");
|
||||
}).filter(Boolean).join("\n\n");
|
||||
if (body) sections.push(`Education\n${body}`);
|
||||
}
|
||||
normalized.customSections.forEach((section) => {
|
||||
const title = section.title?.trim() || "Additional Information";
|
||||
const items = (section.items ?? []).filter(Boolean).map((item) => item.trim()).join("\n");
|
||||
if (items) sections.push(`${title}\n${items}`);
|
||||
});
|
||||
|
||||
return sections.join("\n\n").trim();
|
||||
}
|
||||
|
||||
export function normalizeTailoredCvDraft(source?: Partial<TailoredCvDraft> | null): TailoredCvDraft {
|
||||
const empty = emptyTailoredCvDraft();
|
||||
const sourceSummary = source?.summary;
|
||||
const sourceSelectedSkills = source?.selectedSkills;
|
||||
const sourceExperience = source?.experience;
|
||||
const sourceEducation = source?.education;
|
||||
const sourceCustomSections = source?.customSections;
|
||||
const sourceRenderOptions = source?.renderOptions;
|
||||
const sourceSectionOrder = sourceRenderOptions?.sectionOrder;
|
||||
|
||||
const normalized: TailoredCvDraft = {
|
||||
...empty,
|
||||
...source,
|
||||
templateId: source?.templateId?.trim() || empty.templateId,
|
||||
headline: source?.headline ?? "",
|
||||
summary: Array.isArray(sourceSummary) ? sourceSummary.filter(Boolean) : [],
|
||||
selectedSkills: Array.isArray(sourceSelectedSkills) ? sourceSelectedSkills.filter(Boolean) : [],
|
||||
experience: Array.isArray(sourceExperience) ? sourceExperience.map((item) => ({
|
||||
title: item?.title ?? "",
|
||||
company: item?.company ?? "",
|
||||
location: item?.location ?? "",
|
||||
start: item?.start ?? "",
|
||||
end: item?.end ?? "",
|
||||
isCurrent: Boolean(item?.isCurrent),
|
||||
bullets: Array.isArray(item?.bullets) ? item.bullets.filter(Boolean) : [],
|
||||
})) : [],
|
||||
education: Array.isArray(sourceEducation) ? sourceEducation.map((item) => ({
|
||||
qualification: item?.qualification ?? "",
|
||||
institution: item?.institution ?? "",
|
||||
location: item?.location ?? "",
|
||||
start: item?.start ?? "",
|
||||
end: item?.end ?? "",
|
||||
details: Array.isArray(item?.details) ? item.details.filter(Boolean) : [],
|
||||
})) : [],
|
||||
customSections: Array.isArray(sourceCustomSections) ? sourceCustomSections.map((item) => ({
|
||||
title: item?.title ?? "",
|
||||
items: Array.isArray(item?.items) ? item.items.filter(Boolean) : [],
|
||||
})) : [],
|
||||
renderOptions: {
|
||||
...empty.renderOptions,
|
||||
...sourceRenderOptions,
|
||||
sectionOrder: Array.isArray(sourceSectionOrder) && sourceSectionOrder.length > 0
|
||||
? sourceSectionOrder.filter(Boolean)
|
||||
: DEFAULT_SECTION_ORDER,
|
||||
},
|
||||
status: source?.status?.trim() || empty.status,
|
||||
renderedText: "",
|
||||
isLegacyFallback: Boolean(source?.isLegacyFallback),
|
||||
};
|
||||
|
||||
normalized.renderedText = renderTailoredCvDraftText(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export { joinLines, splitLines };
|
||||
@@ -28,6 +28,58 @@ export interface WorkflowSignal {
|
||||
hasInterviewPrepNotes: boolean;
|
||||
}
|
||||
|
||||
export interface TailoredCvExperienceItem {
|
||||
title?: string | null;
|
||||
company?: string | null;
|
||||
location?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
isCurrent?: boolean;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
export interface TailoredCvEducationItem {
|
||||
qualification?: string | null;
|
||||
qualificationLevel?: string | null;
|
||||
institution?: string | null;
|
||||
location?: string | null;
|
||||
start?: string | null;
|
||||
end?: string | null;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export interface TailoredCvCustomSection {
|
||||
title?: string | null;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface TailoredCvRenderOptions {
|
||||
showPhoto: boolean;
|
||||
pageMode: string;
|
||||
accentColor: string;
|
||||
sectionOrder: string[];
|
||||
bulletDensity: string;
|
||||
}
|
||||
|
||||
export interface TailoredCvDraft {
|
||||
id?: number | null;
|
||||
canonicalProfileVersion?: number | null;
|
||||
templateId: string;
|
||||
headline?: string | null;
|
||||
summary: string[];
|
||||
selectedSkills: string[];
|
||||
experience: TailoredCvExperienceItem[];
|
||||
education: TailoredCvEducationItem[];
|
||||
customSections: TailoredCvCustomSection[];
|
||||
renderOptions: TailoredCvRenderOptions;
|
||||
generationContextHash?: string | null;
|
||||
lastGeneratedAtUtc?: string | null;
|
||||
lastEditedAtUtc?: string | null;
|
||||
status: string;
|
||||
renderedText: string;
|
||||
isLegacyFallback: boolean;
|
||||
}
|
||||
|
||||
export interface JobApplication {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUTPUT_DIR="${CV_BENCHMARK_OUTPUT_DIR:-$ROOT_DIR/tmp/cv-benchmarks/latest}"
|
||||
APPROVED_DIR="${CV_BENCHMARK_APPROVED_DIR:-$ROOT_DIR/tmp/cv-benchmarks/approved-fixtures}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR" "$APPROVED_DIR"
|
||||
|
||||
echo "CV benchmark output: $OUTPUT_DIR"
|
||||
echo "Approved fixtures: $APPROVED_DIR"
|
||||
|
||||
CV_BENCHMARK_OUTPUT_DIR="$OUTPUT_DIR" \
|
||||
CV_BENCHMARK_APPROVED_DIR="$APPROVED_DIR" \
|
||||
dotnet test "$ROOT_DIR/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj" --filter CvCorpusHarnessTests
|
||||
|
||||
if [[ -f "$OUTPUT_DIR/report.md" ]]; then
|
||||
echo
|
||||
echo "Benchmark report written to: $OUTPUT_DIR/report.md"
|
||||
fi
|
||||
if [[ -f "$OUTPUT_DIR/index.json" ]]; then
|
||||
echo "Benchmark index written to: $OUTPUT_DIR/index.json"
|
||||
fi
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
MODEL="${OLLAMA_MODEL:-qwen2.5:7b}"
|
||||
OLLAMA_WAIT_SECONDS="${OLLAMA_WAIT_SECONDS:-180}"
|
||||
PULL_WAIT_SECONDS="${OLLAMA_PULL_WAIT_SECONDS:-1800}"
|
||||
|
||||
compose() {
|
||||
docker compose "$@"
|
||||
}
|
||||
|
||||
wait_for_ollama() {
|
||||
local deadline=$((SECONDS + OLLAMA_WAIT_SECONDS))
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
if compose exec -T ollama ollama list >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
model_present() {
|
||||
compose exec -T ollama ollama list 2>/dev/null | awk 'NR>1 {print $1}' | grep -Fx "$MODEL" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
wait_for_model() {
|
||||
local deadline=$((SECONDS + PULL_WAIT_SECONDS))
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
if model_present; then
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "Starting Ollama service..."
|
||||
compose up -d ollama
|
||||
|
||||
if ! wait_for_ollama; then
|
||||
echo "Ollama did not become ready within ${OLLAMA_WAIT_SECONDS}s."
|
||||
compose logs --tail=200 ollama || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Ollama is responding."
|
||||
|
||||
if model_present; then
|
||||
echo "Model already present: $MODEL"
|
||||
else
|
||||
echo "Pulling Ollama model: $MODEL"
|
||||
compose exec -T ollama ollama pull "$MODEL" || {
|
||||
echo "Model pull command failed."
|
||||
compose logs --tail=200 ollama || true
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if ! wait_for_model; then
|
||||
echo "Model ${MODEL} did not appear within ${PULL_WAIT_SECONDS}s."
|
||||
compose exec -T ollama ollama list || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Ollama model ready: $MODEL"
|
||||
|
||||
echo "Restarting AI service so it can use the ready Ollama model."
|
||||
compose up -d ai-service
|
||||
|
||||
if ! compose ps ai-service --format '{{.State}}' 2>/dev/null | head -n 1 | tr '[:upper:]' '[:lower:]' | grep -qx 'running'; then
|
||||
echo "AI service is not running after Ollama warmup."
|
||||
compose logs --tail=200 ai-service || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Ollama warmup complete."
|
||||
@@ -8,6 +8,7 @@ This service runs a local Hugging Face summarization model and also exposes docu
|
||||
- OCR fallback for scanned PDFs
|
||||
- OCR for image uploads (`png`, `jpg`, `jpeg`, `webp`)
|
||||
- DOCX / TXT / MD extraction
|
||||
- optional Ollama-backed CV block classification for harder sectioning
|
||||
|
||||
## Install
|
||||
|
||||
@@ -29,15 +30,57 @@ pip install -r requirements.txt
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
||||
```
|
||||
|
||||
If the host is missing `python3-venv` or `pip`, use the bootstrap script instead:
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap-and-test.sh bootstrap
|
||||
```
|
||||
|
||||
## Docker
|
||||
The Dockerfile installs Tesseract OCR so scanned PDFs and supported images can be processed inside the container.
|
||||
|
||||
## Tests
|
||||
|
||||
Run the summarizer unit tests with:
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap-and-test.sh test
|
||||
```
|
||||
|
||||
The script:
|
||||
- creates `.venv` with stdlib `venv` when available
|
||||
- falls back to user-space `virtualenv` when host `venv` support is missing
|
||||
- installs `requirements-dev.txt`
|
||||
- writes pytest cache under `tmp/pytest-cache` to avoid stale root-owned `.pytest_cache` directories
|
||||
|
||||
## API
|
||||
- `GET /health` — health check and runtime capabilities
|
||||
- `GET /health` — health check and runtime capabilities, including Ollama version/model metadata when configured
|
||||
- `POST /summarize` — JSON body `{ "text": "...", "max_length": 150, "min_length": 30 }`
|
||||
- `POST /extract-text` — multipart file upload, returns extracted text and OCR metadata
|
||||
- `POST /cv/classify-block` — JSON body `{ "block": "..." }`, uses Ollama when `OLLAMA_MODEL` is configured
|
||||
|
||||
## Notes
|
||||
- Model weights are downloaded on first run.
|
||||
## Ollama
|
||||
Set these before starting the service if you want the hybrid CV classifier enabled:
|
||||
|
||||
```bash
|
||||
export OLLAMA_BASE_URL=http://ollama:11434
|
||||
export OLLAMA_MODEL=qwen2.5:7b
|
||||
```
|
||||
|
||||
Choose the model by setting `OLLAMA_MODEL` and then warming it with the helper script:
|
||||
|
||||
```bash
|
||||
OLLAMA_MODEL=qwen2.5:7b ./scripts/start-ollama-cv.sh
|
||||
```
|
||||
|
||||
Equivalent manual flow:
|
||||
|
||||
```bash
|
||||
docker compose up -d ollama
|
||||
docker compose exec ollama ollama pull qwen2.5:7b
|
||||
docker compose up -d ai-service
|
||||
```
|
||||
|
||||
- Model weights are downloaded on first pull.
|
||||
- OCR quality depends on scan quality and language support.
|
||||
- Default OCR language is English (`eng`).
|
||||
|
||||
+167
-1
@@ -8,9 +8,13 @@ from docx import Document
|
||||
import fitz
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import torch
|
||||
import pytesseract
|
||||
from urllib import request as urllib_request
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
app = FastAPI(title="Local AI Service")
|
||||
|
||||
@@ -20,6 +24,9 @@ MAX_CONTEXT_CHARS = 2200
|
||||
MAX_EXTRACT_FILE_BYTES = 8 * 1024 * 1024
|
||||
OCR_LANGUAGES = "eng"
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "")
|
||||
SKIP_MODEL_LOAD = os.getenv("AI_SERVICE_SKIP_MODEL_LOAD", "") == "1"
|
||||
|
||||
|
||||
def _load_runtime():
|
||||
@@ -33,7 +40,10 @@ def _load_runtime():
|
||||
return tokenizer, model, device, has_cuda, gpu_name
|
||||
|
||||
|
||||
tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime()
|
||||
if SKIP_MODEL_LOAD:
|
||||
tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = None, None, torch.device("cpu"), False, None
|
||||
else:
|
||||
tokenizer, model, device, GPU_AVAILABLE, GPU_NAME = _load_runtime()
|
||||
cache = TTLCache(maxsize=1024, ttl=60 * 60)
|
||||
|
||||
|
||||
@@ -44,11 +54,69 @@ class SummarizeRequest(BaseModel):
|
||||
top_skills: int = Field(default=8, ge=3, le=12)
|
||||
|
||||
|
||||
class CvClassifyBlockRequest(BaseModel):
|
||||
block: str = Field(min_length=1, max_length=6000)
|
||||
|
||||
|
||||
def _key(text: str, max_length: int, min_length: int, top_skills: int) -> str:
|
||||
h = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
return f"{h}:{max_length}:{min_length}:{top_skills}"
|
||||
|
||||
|
||||
def _ollama_json(path: str):
|
||||
req = urllib_request.Request(f"{OLLAMA_BASE_URL}{path}", method="GET")
|
||||
with urllib_request.urlopen(req, timeout=5) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _ollama_status():
|
||||
configured = bool(OLLAMA_MODEL)
|
||||
if not configured:
|
||||
return {
|
||||
"ollama_configured": False,
|
||||
"ollama_reachable": False,
|
||||
"ollama_model": None,
|
||||
"ollama_model_available": False,
|
||||
"ollama_version": None,
|
||||
"ollama_installed_models": [],
|
||||
"ollama_loaded_models": [],
|
||||
"ollama_loaded_count": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
tags_body = _ollama_json("/api/tags")
|
||||
version_body = _ollama_json("/api/version")
|
||||
try:
|
||||
ps_body = _ollama_json("/api/ps")
|
||||
except Exception:
|
||||
ps_body = {"models": []}
|
||||
except Exception:
|
||||
return {
|
||||
"ollama_configured": True,
|
||||
"ollama_reachable": False,
|
||||
"ollama_model": OLLAMA_MODEL,
|
||||
"ollama_model_available": False,
|
||||
"ollama_version": None,
|
||||
"ollama_installed_models": [],
|
||||
"ollama_loaded_models": [],
|
||||
"ollama_loaded_count": 0,
|
||||
}
|
||||
|
||||
models = tags_body.get("models") or []
|
||||
names = sorted({item.get("name") for item in models if isinstance(item, dict) and item.get("name")})
|
||||
loaded_models = sorted({item.get("name") for item in (ps_body.get("models") or []) if isinstance(item, dict) and item.get("name")})
|
||||
return {
|
||||
"ollama_configured": True,
|
||||
"ollama_reachable": True,
|
||||
"ollama_model": OLLAMA_MODEL,
|
||||
"ollama_model_available": OLLAMA_MODEL in names,
|
||||
"ollama_version": version_body.get("version"),
|
||||
"ollama_installed_models": names,
|
||||
"ollama_loaded_models": loaded_models,
|
||||
"ollama_loaded_count": len(loaded_models),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
@@ -59,6 +127,7 @@ async def health():
|
||||
"gpu_name": GPU_NAME,
|
||||
"ocr_available": True,
|
||||
"ocr_languages": OCR_LANGUAGES,
|
||||
**_ollama_status(),
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +324,8 @@ def _role_focused_excerpt(text: str) -> dict:
|
||||
|
||||
|
||||
def _model_summarize(text: str, max_length: int, min_length: int) -> str:
|
||||
if tokenizer is None or model is None:
|
||||
raise HTTPException(status_code=503, detail="Summarizer model is not loaded.")
|
||||
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=1024)
|
||||
input_ids = inputs.input_ids.to(device)
|
||||
attention_mask = inputs.attention_mask.to(device) if hasattr(inputs, "attention_mask") else None
|
||||
@@ -272,6 +343,101 @@ def _model_summarize(text: str, max_length: int, min_length: int) -> str:
|
||||
return tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
|
||||
|
||||
|
||||
def _ollama_generate_json(prompt: str):
|
||||
if not OLLAMA_MODEL:
|
||||
raise HTTPException(status_code=503, detail="OLLAMA_MODEL is not configured.")
|
||||
|
||||
payload = json.dumps({
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0.1}
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib_request.Request(
|
||||
f"{OLLAMA_BASE_URL}/api/generate",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=30) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as ex:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama request failed with {ex.code}.")
|
||||
except URLError as ex:
|
||||
raise HTTPException(status_code=503, detail=f"Ollama is unreachable: {ex.reason}.")
|
||||
|
||||
raw = (body.get("response") or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=502, detail="Ollama returned an empty response.")
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(raw[start:end + 1])
|
||||
raise HTTPException(status_code=502, detail="Ollama did not return valid JSON.")
|
||||
|
||||
|
||||
@app.post("/cv/classify-block")
|
||||
async def classify_cv_block(req: CvClassifyBlockRequest):
|
||||
prompt = f"""
|
||||
You classify one CV text block into structured JSON.
|
||||
Return ONLY valid JSON with this exact shape:
|
||||
{{
|
||||
"section": "Contact|Professional Summary|Work Experience|Education|Skills|Languages|Interests|Other",
|
||||
"confidence": 0.0,
|
||||
"reason": "short reason",
|
||||
"title": string|null,
|
||||
"company": string|null,
|
||||
"location": string|null,
|
||||
"start": string|null,
|
||||
"end": string|null,
|
||||
"bullets": string[],
|
||||
"summary": string[],
|
||||
"skills": string[]
|
||||
}}
|
||||
|
||||
Rules:
|
||||
- Preserve facts only.
|
||||
- section must be one of the listed values.
|
||||
- Use Work Experience only for job/employment blocks.
|
||||
- Use Education only for degree/course/certification blocks.
|
||||
- For Contact blocks, keep title/company/start/end null and bullets/summary/skills empty.
|
||||
- For Professional Summary blocks, prefer summary for concise summary lines and keep bullets empty unless the source is already bullet-like.
|
||||
- For Skills blocks, prefer skills for normalized skill items and keep title/company/start/end null.
|
||||
- For non-work and non-education blocks, title/company/start/end should usually be null.
|
||||
- location must look like a place, not a sentence.
|
||||
- dates must be one of: year, month+year, dd/mm/yyyy, Present, Current.
|
||||
- bullets should only be concrete tasks/achievements/details, not titles, companies, dates, or headings.
|
||||
- skills should be short normalized skill/tool terms, not sentences.
|
||||
- If unsure, choose Other and keep fields null/empty.
|
||||
|
||||
Block:
|
||||
{req.block.strip()}
|
||||
""".strip()
|
||||
|
||||
parsed = _ollama_generate_json(prompt)
|
||||
return {
|
||||
"section": parsed.get("section") or "Other",
|
||||
"confidence": parsed.get("confidence"),
|
||||
"reason": parsed.get("reason"),
|
||||
"title": parsed.get("title"),
|
||||
"company": parsed.get("company"),
|
||||
"location": parsed.get("location"),
|
||||
"start": parsed.get("start"),
|
||||
"end": parsed.get("end"),
|
||||
"bullets": parsed.get("bullets") or [],
|
||||
"summary": parsed.get("summary") or [],
|
||||
"skills": parsed.get("skills") or [],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/summarize")
|
||||
async def summarize(req: SummarizeRequest):
|
||||
if req.min_length >= req.max_length:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
cache_dir = tmp/pytest-cache
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.5
|
||||
httpx==0.28.1
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV_DIR="$ROOT_DIR/.venv"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
VIRTUALENV_BIN="${VIRTUALENV_BIN:-$HOME/.local/bin/virtualenv}"
|
||||
|
||||
ensure_user_pip() {
|
||||
if "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p /tmp/jobtracker-python-bootstrap
|
||||
cd /tmp/jobtracker-python-bootstrap
|
||||
if [[ ! -f get-pip.py ]]; then
|
||||
curl -fsSLo get-pip.py https://bootstrap.pypa.io/get-pip.py
|
||||
fi
|
||||
"$PYTHON_BIN" get-pip.py --user --break-system-packages
|
||||
}
|
||||
|
||||
ensure_venv() {
|
||||
if [[ -x "$VENV_DIR/bin/python" && -x "$VENV_DIR/bin/pip" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if "$PYTHON_BIN" -m venv "$VENV_DIR" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_user_pip
|
||||
if [[ ! -x "$VIRTUALENV_BIN" ]]; then
|
||||
"$PYTHON_BIN" -m pip install --user --break-system-packages virtualenv
|
||||
fi
|
||||
"$VIRTUALENV_BIN" "$VENV_DIR"
|
||||
}
|
||||
|
||||
install_requirements() {
|
||||
"$VENV_DIR/bin/pip" install -r "$ROOT_DIR/requirements-dev.txt"
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
mkdir -p "$ROOT_DIR/tmp/pytest-cache"
|
||||
cd "$ROOT_DIR"
|
||||
AI_SERVICE_SKIP_MODEL_LOAD=1 "$VENV_DIR/bin/python" -m pytest -q -o cache_dir=tmp/pytest-cache tests/test_app.py
|
||||
}
|
||||
|
||||
case "${1:-test}" in
|
||||
bootstrap)
|
||||
ensure_venv
|
||||
install_requirements
|
||||
;;
|
||||
test)
|
||||
ensure_venv
|
||||
install_requirements
|
||||
run_tests
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [bootstrap|test]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,85 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def load_app_module(monkeypatch):
|
||||
monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1")
|
||||
monkeypatch.delenv("OLLAMA_MODEL", raising=False)
|
||||
if "app" in sys.modules:
|
||||
del sys.modules["app"]
|
||||
module = importlib.import_module("app")
|
||||
return importlib.reload(module)
|
||||
|
||||
|
||||
def test_health_reports_runtime_without_ollama(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["device"] == "cpu"
|
||||
assert payload["ollama_configured"] is False
|
||||
assert payload["ollama_model"] is None
|
||||
assert payload["ollama_installed_models"] == []
|
||||
assert payload["ollama_loaded_models"] == []
|
||||
|
||||
|
||||
def test_classify_block_returns_structured_json(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
def fake_generate_json(prompt: str):
|
||||
assert "Senior Platform Engineer" in prompt
|
||||
return {
|
||||
"section": "Work Experience",
|
||||
"confidence": 0.91,
|
||||
"reason": "job block",
|
||||
"title": "Senior Platform Engineer",
|
||||
"company": "Atlas Systems",
|
||||
"location": "Oslo",
|
||||
"start": "2019",
|
||||
"end": "Present",
|
||||
"bullets": ["Built event-driven APIs and migration tooling."],
|
||||
"summary": [],
|
||||
"skills": ["Python", "SQL"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json)
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.post("/cv/classify-block", json={"block": "Senior Platform Engineer at Atlas Systems, Oslo, 2019 - Present. Built event-driven APIs and migration tooling."})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["section"] == "Work Experience"
|
||||
assert payload["title"] == "Senior Platform Engineer"
|
||||
assert payload["company"] == "Atlas Systems"
|
||||
assert payload["bullets"] == ["Built event-driven APIs and migration tooling."]
|
||||
assert payload["summary"] == []
|
||||
assert payload["skills"] == ["Python", "SQL"]
|
||||
|
||||
|
||||
def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
monkeypatch.setattr(module, "_ollama_generate_json", lambda prompt: {"bullets": []})
|
||||
client = TestClient(module.app)
|
||||
|
||||
response = client.post("/cv/classify-block", json={"block": "Miscellaneous profile text"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["section"] == "Other"
|
||||
assert payload["bullets"] == []
|
||||
assert payload["summary"] == []
|
||||
assert payload["skills"] == []
|
||||
Reference in New Issue
Block a user