Compare commits
14 commits
a4a2216c2f
...
280f4271a5
| Author | SHA1 | Date | |
|---|---|---|---|
| 280f4271a5 | |||
| 1c9bfc9fb6 | |||
| 22bc57242e | |||
| 9f984c22cb | |||
| fe3e4ff539 | |||
| 43599834d5 | |||
| fe5371613e | |||
| 369bf68399 | |||
| eef6c33d94 | |||
| 53bfe6b326 | |||
| cd787a2509 | |||
| 048a5f4cc3 | |||
| fe4947a72f | |||
| 4e11cf3cfa |
14 changed files with 1163 additions and 26 deletions
175
dev-api.py
175
dev-api.py
|
|
@ -858,6 +858,80 @@ def set_default_resume_endpoint(resume_id: int):
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/resumes/{resume_id}/apply-to-profile")
|
||||
def apply_resume_to_profile(resume_id: int):
|
||||
"""Sync a library resume entry to the active profile (library→profile direction).
|
||||
|
||||
Workflow:
|
||||
1. Load the library entry (must have struct_json).
|
||||
2. Load current profile to preserve metadata fields.
|
||||
3. Backup current profile content as a new auto-named library entry.
|
||||
4. Merge content fields from the library entry into the profile.
|
||||
5. Write updated plain_text_resume.yaml.
|
||||
6. Mark the library entry synced_at.
|
||||
7. Return backup details for the frontend notification.
|
||||
"""
|
||||
import json as _json
|
||||
from scripts.resume_sync import (
|
||||
library_to_profile_content,
|
||||
profile_to_library,
|
||||
make_auto_backup_name,
|
||||
)
|
||||
from scripts.db import get_resume as _get, create_resume as _create
|
||||
|
||||
db_path = Path(_request_db.get() or DB_PATH)
|
||||
entry = _get(db_path, resume_id)
|
||||
if not entry:
|
||||
raise HTTPException(404, "Resume not found")
|
||||
|
||||
struct_json: dict = {}
|
||||
if entry.get("struct_json"):
|
||||
try:
|
||||
struct_json = _json.loads(entry["struct_json"])
|
||||
except Exception:
|
||||
raise HTTPException(422, "Library entry has malformed struct_json — re-import the resume to repair it.")
|
||||
|
||||
resume_path = _resume_path()
|
||||
current_profile: dict = {}
|
||||
if resume_path.exists():
|
||||
with open(resume_path, encoding="utf-8") as f:
|
||||
current_profile = yaml.safe_load(f) or {}
|
||||
|
||||
# Backup current content to library before overwriting
|
||||
backup_text, backup_struct = profile_to_library(current_profile)
|
||||
backup_name = make_auto_backup_name(entry["name"])
|
||||
backup = _create(
|
||||
db_path,
|
||||
name=backup_name,
|
||||
text=backup_text,
|
||||
source="auto_backup",
|
||||
struct_json=_json.dumps(backup_struct),
|
||||
)
|
||||
|
||||
# Merge: overwrite content fields, preserve metadata
|
||||
content = library_to_profile_content(struct_json)
|
||||
CONTENT_FIELDS = {
|
||||
"name", "surname", "email", "phone", "career_summary",
|
||||
"experience", "skills", "education", "achievements",
|
||||
}
|
||||
for field in CONTENT_FIELDS:
|
||||
current_profile[field] = content[field]
|
||||
|
||||
resume_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(resume_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(current_profile, f, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
from scripts.db import update_resume_synced_at as _mark_synced
|
||||
_mark_synced(db_path, resume_id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"backup_id": backup["id"],
|
||||
"backup_name": backup_name,
|
||||
"fields_updated": sorted(CONTENT_FIELDS),
|
||||
}
|
||||
|
||||
|
||||
# ── Per-job resume endpoints ───────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/jobs/{job_id}/resume")
|
||||
|
|
@ -2691,10 +2765,17 @@ class WorkEntry(BaseModel):
|
|||
title: str = ""; company: str = ""; period: str = ""; location: str = ""
|
||||
industry: str = ""; responsibilities: str = ""; skills: List[str] = []
|
||||
|
||||
class EducationEntry(BaseModel):
|
||||
institution: str = ""; degree: str = ""; field: str = ""
|
||||
start_date: str = ""; end_date: str = ""
|
||||
|
||||
class ResumePayload(BaseModel):
|
||||
name: str = ""; email: str = ""; phone: str = ""; linkedin_url: str = ""
|
||||
surname: str = ""; address: str = ""; city: str = ""; zip_code: str = ""; date_of_birth: str = ""
|
||||
career_summary: str = ""
|
||||
experience: List[WorkEntry] = []
|
||||
education: List[EducationEntry] = []
|
||||
achievements: List[str] = []
|
||||
salary_min: int = 0; salary_max: int = 0; notice_period: str = ""
|
||||
remote: bool = False; relocation: bool = False
|
||||
assessment: bool = False; background_check: bool = False
|
||||
|
|
@ -2722,32 +2803,46 @@ def _tokens_path() -> Path:
|
|||
def _normalize_experience(raw: list) -> list:
|
||||
"""Normalize AIHawk-style experience entries to the Vue WorkEntry schema.
|
||||
|
||||
Parser / AIHawk stores: bullets (list[str]), start_date, end_date
|
||||
Vue WorkEntry expects: responsibilities (str), period (str)
|
||||
AIHawk stores: key_responsibilities (numbered dicts), employment_period, skills_acquired
|
||||
Vue WorkEntry: responsibilities (str), period (str), skills (list)
|
||||
If already in Vue format (has 'period' key or 'responsibilities' key), pass through unchanged.
|
||||
"""
|
||||
out = []
|
||||
for e in raw:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
entry = dict(e)
|
||||
# bullets → responsibilities
|
||||
if "responsibilities" not in entry or not entry["responsibilities"]:
|
||||
bullets = entry.pop("bullets", None) or []
|
||||
if isinstance(bullets, list):
|
||||
entry["responsibilities"] = "\n".join(b for b in bullets if b)
|
||||
elif isinstance(bullets, str):
|
||||
entry["responsibilities"] = bullets
|
||||
# Already in Vue WorkEntry format — pass through
|
||||
if "period" in e or "responsibilities" in e:
|
||||
out.append({
|
||||
"title": e.get("title", ""),
|
||||
"company": e.get("company", ""),
|
||||
"period": e.get("period", ""),
|
||||
"location": e.get("location", ""),
|
||||
"industry": e.get("industry", ""),
|
||||
"responsibilities": e.get("responsibilities", ""),
|
||||
"skills": e.get("skills") or [],
|
||||
})
|
||||
continue
|
||||
# AIHawk format
|
||||
resps = e.get("key_responsibilities", {})
|
||||
if isinstance(resps, dict):
|
||||
resp_text = "\n".join(v for v in resps.values() if isinstance(v, str))
|
||||
elif isinstance(resps, list):
|
||||
resp_text = "\n".join(str(r) for r in resps)
|
||||
else:
|
||||
entry.pop("bullets", None)
|
||||
# start_date + end_date → period
|
||||
if "period" not in entry or not entry["period"]:
|
||||
start = entry.pop("start_date", "") or ""
|
||||
end = entry.pop("end_date", "") or ""
|
||||
entry["period"] = f"{start} – {end}".strip(" –") if (start or end) else ""
|
||||
else:
|
||||
entry.pop("start_date", None)
|
||||
entry.pop("end_date", None)
|
||||
out.append(entry)
|
||||
resp_text = str(resps)
|
||||
period = e.get("employment_period", "")
|
||||
skills_raw = e.get("skills_acquired", [])
|
||||
skills = skills_raw if isinstance(skills_raw, list) else []
|
||||
out.append({
|
||||
"title": e.get("position", ""),
|
||||
"company": e.get("company", ""),
|
||||
"period": period,
|
||||
"location": e.get("location", ""),
|
||||
"industry": e.get("industry", ""),
|
||||
"responsibilities": resp_text,
|
||||
"skills": skills,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -2756,24 +2851,58 @@ def get_resume():
|
|||
try:
|
||||
resume_path = _resume_path()
|
||||
if not resume_path.exists():
|
||||
# Backward compat: check user.yaml for career_summary
|
||||
_uy = Path(_user_yaml_path())
|
||||
if _uy.exists():
|
||||
uy = yaml.safe_load(_uy.read_text(encoding="utf-8")) or {}
|
||||
if uy.get("career_summary"):
|
||||
return {"exists": False, "legacy_career_summary": uy["career_summary"]}
|
||||
return {"exists": False}
|
||||
with open(resume_path) as f:
|
||||
with open(resume_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
data["exists"] = True
|
||||
if "experience" in data and isinstance(data["experience"], list):
|
||||
data["experience"] = _normalize_experience(data["experience"])
|
||||
# Backward compat: if career_summary missing from YAML, try user.yaml
|
||||
if not data.get("career_summary"):
|
||||
_uy = Path(_user_yaml_path())
|
||||
if _uy.exists():
|
||||
uy = yaml.safe_load(_uy.read_text(encoding="utf-8")) or {}
|
||||
data["career_summary"] = uy.get("career_summary", "")
|
||||
return data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.put("/api/settings/resume")
|
||||
def save_resume(payload: ResumePayload):
|
||||
"""Save resume profile. If a default library entry exists, sync content back to it."""
|
||||
import json as _json
|
||||
from scripts.db import (
|
||||
get_resume as _get_resume,
|
||||
update_resume_content as _update_content,
|
||||
)
|
||||
from scripts.resume_sync import profile_to_library
|
||||
try:
|
||||
resume_path = _resume_path()
|
||||
resume_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(resume_path, "w") as f:
|
||||
with open(resume_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(payload.model_dump(), f, allow_unicode=True, default_flow_style=False)
|
||||
return {"ok": True}
|
||||
|
||||
# Profile→library sync: if a default resume exists, update it
|
||||
synced_id: int | None = None
|
||||
db_path = Path(_request_db.get() or DB_PATH)
|
||||
_uy = Path(_user_yaml_path())
|
||||
if _uy.exists():
|
||||
profile_meta = yaml.safe_load(_uy.read_text(encoding="utf-8")) or {}
|
||||
default_id = profile_meta.get("default_resume_id")
|
||||
if default_id:
|
||||
entry = _get_resume(db_path, int(default_id))
|
||||
if entry:
|
||||
text, struct = profile_to_library(payload.model_dump())
|
||||
_update_content(db_path, int(default_id), text=text, struct_json=_json.dumps(struct))
|
||||
synced_id = int(default_id)
|
||||
|
||||
return {"ok": True, "synced_library_entry_id": synced_id}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
|
|
|||
1
docs/plausible.js
Normal file
1
docs/plausible.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(function(){var s=document.createElement("script");s.defer=true;s.dataset.domain="docs.circuitforge.tech,circuitforge.tech";s.dataset.api="https://analytics.circuitforge.tech/api/event";s.src="https://analytics.circuitforge.tech/js/script.js";document.head.appendChild(s);})();
|
||||
3
migrations/007_resume_sync.sql
Normal file
3
migrations/007_resume_sync.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- 007_resume_sync.sql
|
||||
-- Add synced_at to resumes: ISO datetime of last library↔profile sync, null = never synced.
|
||||
ALTER TABLE resumes ADD COLUMN synced_at TEXT;
|
||||
|
|
@ -70,3 +70,6 @@ nav:
|
|||
- Tier System: reference/tier-system.md
|
||||
- LLM Router: reference/llm-router.md
|
||||
- Config Files: reference/config-files.md
|
||||
|
||||
extra_javascript:
|
||||
- plausible.js
|
||||
|
|
|
|||
|
|
@ -973,6 +973,7 @@ def _resume_as_dict(row) -> dict:
|
|||
"is_default": row["is_default"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"synced_at": row["synced_at"] if "synced_at" in row.keys() else None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1074,6 +1075,44 @@ def set_default_resume(db_path: Path = DEFAULT_DB, resume_id: int = 0) -> None:
|
|||
conn.close()
|
||||
|
||||
|
||||
def update_resume_synced_at(db_path: Path = DEFAULT_DB, resume_id: int = 0) -> None:
|
||||
"""Mark a library entry as synced to the profile (library→profile direction)."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE resumes SET synced_at=datetime('now') WHERE id=?",
|
||||
(resume_id,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_resume_content(
|
||||
db_path: Path = DEFAULT_DB,
|
||||
resume_id: int = 0,
|
||||
text: str = "",
|
||||
struct_json: str | None = None,
|
||||
) -> None:
|
||||
"""Update text, struct_json, and synced_at for a library entry.
|
||||
|
||||
Called by the profile→library sync path (PUT /api/settings/resume).
|
||||
"""
|
||||
word_count = len(text.split()) if text else 0
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""UPDATE resumes
|
||||
SET text=?, struct_json=?, word_count=?,
|
||||
synced_at=datetime('now'), updated_at=datetime('now')
|
||||
WHERE id=?""",
|
||||
(text, struct_json, word_count, resume_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_job_resume(db_path: Path = DEFAULT_DB, job_id: int = 0) -> dict | None:
|
||||
"""Return the resume for a job: job-specific first, then default, then None."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,12 @@ def extract_jd_signals(description: str, resume_text: str = "") -> list[str]:
|
|||
# Extract JSON array from response (LLM may wrap it in markdown)
|
||||
match = re.search(r"\[.*\]", raw, re.DOTALL)
|
||||
if match:
|
||||
llm_signals = json.loads(match.group(0))
|
||||
json_str = match.group(0)
|
||||
# LLMs occasionally emit invalid JSON escape sequences (e.g. \s, \d, \p)
|
||||
# that are valid regex but not valid JSON. Replace bare backslashes that
|
||||
# aren't followed by a recognised JSON escape character.
|
||||
json_str = re.sub(r'\\([^"\\/bfnrtu])', r'\1', json_str)
|
||||
llm_signals = json.loads(json_str)
|
||||
llm_signals = [s.strip() for s in llm_signals if isinstance(s, str) and s.strip()]
|
||||
except Exception:
|
||||
log.warning("[resume_optimizer] LLM signal extraction failed", exc_info=True)
|
||||
|
|
|
|||
217
scripts/resume_sync.py
Normal file
217
scripts/resume_sync.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""
|
||||
Resume format transform — library ↔ profile.
|
||||
|
||||
Converts between:
|
||||
- Library format: struct_json produced by resume_parser.parse_resume()
|
||||
{name, email, phone, career_summary, experience[{title,company,start_date,end_date,location,bullets[]}],
|
||||
education[{institution,degree,field,start_date,end_date}], skills[], achievements[]}
|
||||
- Profile content format: ResumePayload content fields (plain_text_resume.yaml)
|
||||
{name, surname, email, phone, career_summary,
|
||||
experience[{title,company,period,location,industry,responsibilities,skills[]}],
|
||||
education[{institution,degree,field,start_date,end_date}],
|
||||
skills[], achievements[]}
|
||||
|
||||
Profile metadata fields (salary, work prefs, self-ID, PII) are never touched here.
|
||||
|
||||
License: MIT
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
|
||||
_CONTENT_FIELDS = frozenset({
|
||||
"name", "surname", "email", "phone", "career_summary",
|
||||
"experience", "skills", "education", "achievements",
|
||||
})
|
||||
|
||||
|
||||
def library_to_profile_content(struct_json: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transform a library struct_json to ResumePayload content fields.
|
||||
|
||||
Returns only content fields. Caller is responsible for merging with existing
|
||||
metadata fields (salary, preferences, self-ID) so they are not overwritten.
|
||||
|
||||
Lossy for experience[].industry (always blank — parser does not capture it).
|
||||
name is split on first space into name/surname.
|
||||
"""
|
||||
full_name: str = struct_json.get("name") or ""
|
||||
parts = full_name.split(" ", 1)
|
||||
name = parts[0]
|
||||
surname = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
experience = []
|
||||
for exp in struct_json.get("experience") or []:
|
||||
start = (exp.get("start_date") or "").strip()
|
||||
end = (exp.get("end_date") or "").strip()
|
||||
if start and end:
|
||||
period = f"{start} \u2013 {end}"
|
||||
elif start:
|
||||
period = start
|
||||
elif end:
|
||||
period = end
|
||||
else:
|
||||
period = ""
|
||||
|
||||
bullets: list[str] = exp.get("bullets") or []
|
||||
responsibilities = "\n".join(b for b in bullets if b)
|
||||
|
||||
experience.append({
|
||||
"title": exp.get("title") or "",
|
||||
"company": exp.get("company") or "",
|
||||
"period": period,
|
||||
"location": exp.get("location") or "",
|
||||
"industry": "", # not captured by parser
|
||||
"responsibilities": responsibilities,
|
||||
"skills": [],
|
||||
})
|
||||
|
||||
education = []
|
||||
for edu in struct_json.get("education") or []:
|
||||
education.append({
|
||||
"institution": edu.get("institution") or "",
|
||||
"degree": edu.get("degree") or "",
|
||||
"field": edu.get("field") or "",
|
||||
"start_date": edu.get("start_date") or "",
|
||||
"end_date": edu.get("end_date") or "",
|
||||
})
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"surname": surname,
|
||||
"email": struct_json.get("email") or "",
|
||||
"phone": struct_json.get("phone") or "",
|
||||
"career_summary": struct_json.get("career_summary") or "",
|
||||
"experience": experience,
|
||||
"skills": list(struct_json.get("skills") or []),
|
||||
"education": education,
|
||||
"achievements": list(struct_json.get("achievements") or []),
|
||||
}
|
||||
|
||||
|
||||
def profile_to_library(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
"""Transform ResumePayload content fields to (plain_text, struct_json).
|
||||
|
||||
Inverse of library_to_profile_content. The plain_text is a best-effort
|
||||
reconstruction for display and re-parsing. struct_json is the canonical
|
||||
structured representation stored in the resumes table.
|
||||
"""
|
||||
name_parts = [payload.get("name") or "", payload.get("surname") or ""]
|
||||
full_name = " ".join(p for p in name_parts if p).strip()
|
||||
|
||||
career_summary = (payload.get("career_summary") or "").strip()
|
||||
|
||||
lines: list[str] = []
|
||||
if full_name:
|
||||
lines.append(full_name)
|
||||
email = payload.get("email") or ""
|
||||
phone = payload.get("phone") or ""
|
||||
if email:
|
||||
lines.append(email)
|
||||
if phone:
|
||||
lines.append(phone)
|
||||
|
||||
if career_summary:
|
||||
lines += ["", "SUMMARY", career_summary]
|
||||
|
||||
experience_structs = []
|
||||
for exp in payload.get("experience") or []:
|
||||
title = (exp.get("title") or "").strip()
|
||||
company = (exp.get("company") or "").strip()
|
||||
period = (exp.get("period") or "").strip()
|
||||
location = (exp.get("location") or "").strip()
|
||||
|
||||
# Split period back to start_date / end_date.
|
||||
# Split on the dash/dash separator BEFORE normalising to plain hyphens
|
||||
# so that ISO dates like "2023-01 – 2025-03" round-trip correctly.
|
||||
if "\u2013" in period: # en-dash
|
||||
date_parts = [p.strip() for p in period.split("\u2013", 1)]
|
||||
elif "\u2014" in period: # em-dash
|
||||
date_parts = [p.strip() for p in period.split("\u2014", 1)]
|
||||
else:
|
||||
date_parts = [period.strip()] if period.strip() else []
|
||||
start_date = date_parts[0] if date_parts else ""
|
||||
end_date = date_parts[1] if len(date_parts) > 1 else ""
|
||||
|
||||
resp = (exp.get("responsibilities") or "").strip()
|
||||
bullets = [b.strip() for b in resp.split("\n") if b.strip()]
|
||||
|
||||
if title or company:
|
||||
header = " | ".join(p for p in [title, company, period] if p)
|
||||
lines += ["", header]
|
||||
if location:
|
||||
lines.append(location)
|
||||
for b in bullets:
|
||||
lines.append(f"\u2022 {b}")
|
||||
|
||||
experience_structs.append({
|
||||
"title": title,
|
||||
"company": company,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"location": location,
|
||||
"bullets": bullets,
|
||||
})
|
||||
|
||||
skills: list[str] = list(payload.get("skills") or [])
|
||||
if skills:
|
||||
lines += ["", "SKILLS", ", ".join(skills)]
|
||||
|
||||
education_structs = []
|
||||
for edu in payload.get("education") or []:
|
||||
institution = (edu.get("institution") or "").strip()
|
||||
degree = (edu.get("degree") or "").strip()
|
||||
field = (edu.get("field") or "").strip()
|
||||
start_date = (edu.get("start_date") or "").strip()
|
||||
end_date = (edu.get("end_date") or "").strip()
|
||||
if institution or degree:
|
||||
label = " ".join(p for p in [degree, field] if p)
|
||||
lines.append(f"{label} \u2014 {institution}" if institution else label)
|
||||
education_structs.append({
|
||||
"institution": institution,
|
||||
"degree": degree,
|
||||
"field": field,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
})
|
||||
|
||||
achievements: list[str] = list(payload.get("achievements") or [])
|
||||
|
||||
struct_json: dict[str, Any] = {
|
||||
"name": full_name,
|
||||
"email": email,
|
||||
"phone": phone,
|
||||
"career_summary": career_summary,
|
||||
"experience": experience_structs,
|
||||
"skills": skills,
|
||||
"education": education_structs,
|
||||
"achievements": achievements,
|
||||
}
|
||||
|
||||
plain_text = "\n".join(lines).strip()
|
||||
return plain_text, struct_json
|
||||
|
||||
|
||||
def make_auto_backup_name(source_name: str) -> str:
|
||||
"""Generate a timestamped auto-backup name.
|
||||
|
||||
Example: "Auto-backup before Senior Engineer Resume — 2026-04-16"
|
||||
"""
|
||||
today = date.today().isoformat()
|
||||
return f"Auto-backup before {source_name} \u2014 {today}"
|
||||
|
||||
|
||||
def blank_fields_on_import(struct_json: dict[str, Any]) -> list[str]:
|
||||
"""Return content field names that will be blank after a library→profile import.
|
||||
|
||||
Used to warn the user in the confirmation modal so they know what to fill in.
|
||||
"""
|
||||
blank: list[str] = []
|
||||
if struct_json.get("experience"):
|
||||
# industry is always blank — parser never captures it
|
||||
blank.append("experience[].industry")
|
||||
# location may be blank for some entries
|
||||
if any(not (e.get("location") or "").strip() for e in struct_json["experience"]):
|
||||
blank.append("experience[].location")
|
||||
return blank
|
||||
207
tests/test_resume_sync.py
Normal file
207
tests/test_resume_sync.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""Unit tests for scripts.resume_sync — format transform between library and profile."""
|
||||
import json
|
||||
import pytest
|
||||
from scripts.resume_sync import (
|
||||
library_to_profile_content,
|
||||
profile_to_library,
|
||||
make_auto_backup_name,
|
||||
blank_fields_on_import,
|
||||
)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
STRUCT_JSON = {
|
||||
"name": "Alex Rivera",
|
||||
"email": "alex@example.com",
|
||||
"phone": "555-0100",
|
||||
"career_summary": "Senior UX Designer with 6 years experience.",
|
||||
"experience": [
|
||||
{
|
||||
"title": "Senior UX Designer",
|
||||
"company": "StreamNote",
|
||||
"start_date": "2023",
|
||||
"end_date": "present",
|
||||
"location": "Remote",
|
||||
"bullets": ["Led queue redesign", "Built component library"],
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"institution": "State University",
|
||||
"degree": "B.F.A.",
|
||||
"field": "Graphic Design",
|
||||
"start_date": "2015",
|
||||
"end_date": "2019",
|
||||
}
|
||||
],
|
||||
"skills": ["Figma", "User Research"],
|
||||
"achievements": ["Design award 2024"],
|
||||
}
|
||||
|
||||
PROFILE_PAYLOAD = {
|
||||
"name": "Alex",
|
||||
"surname": "Rivera",
|
||||
"email": "alex@example.com",
|
||||
"phone": "555-0100",
|
||||
"career_summary": "Senior UX Designer with 6 years experience.",
|
||||
"experience": [
|
||||
{
|
||||
"title": "Senior UX Designer",
|
||||
"company": "StreamNote",
|
||||
"period": "2023 – present",
|
||||
"location": "Remote",
|
||||
"industry": "",
|
||||
"responsibilities": "Led queue redesign\nBuilt component library",
|
||||
"skills": [],
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"institution": "State University",
|
||||
"degree": "B.F.A.",
|
||||
"field": "Graphic Design",
|
||||
"start_date": "2015",
|
||||
"end_date": "2019",
|
||||
}
|
||||
],
|
||||
"skills": ["Figma", "User Research"],
|
||||
"achievements": ["Design award 2024"],
|
||||
}
|
||||
|
||||
|
||||
# ── library_to_profile_content ────────────────────────────────────────────────
|
||||
|
||||
def test_library_to_profile_splits_name():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["name"] == "Alex"
|
||||
assert result["surname"] == "Rivera"
|
||||
|
||||
def test_library_to_profile_single_word_name():
|
||||
result = library_to_profile_content({**STRUCT_JSON, "name": "Cher"})
|
||||
assert result["name"] == "Cher"
|
||||
assert result["surname"] == ""
|
||||
|
||||
def test_library_to_profile_email_phone():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["email"] == "alex@example.com"
|
||||
assert result["phone"] == "555-0100"
|
||||
|
||||
def test_library_to_profile_career_summary():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["career_summary"] == "Senior UX Designer with 6 years experience."
|
||||
|
||||
def test_library_to_profile_experience_period():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["experience"][0]["period"] == "2023 – present"
|
||||
|
||||
def test_library_to_profile_experience_bullets_joined():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["experience"][0]["responsibilities"] == "Led queue redesign\nBuilt component library"
|
||||
|
||||
def test_library_to_profile_experience_industry_blank():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["experience"][0]["industry"] == ""
|
||||
|
||||
def test_library_to_profile_education():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["education"][0]["institution"] == "State University"
|
||||
assert result["education"][0]["degree"] == "B.F.A."
|
||||
|
||||
def test_library_to_profile_skills():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["skills"] == ["Figma", "User Research"]
|
||||
|
||||
def test_library_to_profile_achievements():
|
||||
result = library_to_profile_content(STRUCT_JSON)
|
||||
assert result["achievements"] == ["Design award 2024"]
|
||||
|
||||
def test_library_to_profile_missing_fields_no_keyerror():
|
||||
result = library_to_profile_content({})
|
||||
assert result["name"] == ""
|
||||
assert result["experience"] == []
|
||||
assert result["education"] == []
|
||||
assert result["skills"] == []
|
||||
assert result["achievements"] == []
|
||||
|
||||
|
||||
# ── profile_to_library ────────────────────────────────────────────────────────
|
||||
|
||||
def test_profile_to_library_full_name():
|
||||
text, struct = profile_to_library(PROFILE_PAYLOAD)
|
||||
assert struct["name"] == "Alex Rivera"
|
||||
|
||||
def test_profile_to_library_experience_bullets_reconstructed():
|
||||
_, struct = profile_to_library(PROFILE_PAYLOAD)
|
||||
assert struct["experience"][0]["bullets"] == ["Led queue redesign", "Built component library"]
|
||||
|
||||
def test_profile_to_library_period_split():
|
||||
_, struct = profile_to_library(PROFILE_PAYLOAD)
|
||||
assert struct["experience"][0]["start_date"] == "2023"
|
||||
assert struct["experience"][0]["end_date"] == "present"
|
||||
|
||||
def test_profile_to_library_period_split_iso_dates():
|
||||
"""ISO dates (with hyphens) must round-trip through the period field correctly."""
|
||||
payload = {
|
||||
**PROFILE_PAYLOAD,
|
||||
"experience": [{
|
||||
**PROFILE_PAYLOAD["experience"][0],
|
||||
"period": "2023-01 \u2013 2025-03",
|
||||
}],
|
||||
}
|
||||
_, struct = profile_to_library(payload)
|
||||
assert struct["experience"][0]["start_date"] == "2023-01"
|
||||
assert struct["experience"][0]["end_date"] == "2025-03"
|
||||
|
||||
def test_profile_to_library_period_split_em_dash():
|
||||
"""Em-dash separator is also handled."""
|
||||
payload = {
|
||||
**PROFILE_PAYLOAD,
|
||||
"experience": [{
|
||||
**PROFILE_PAYLOAD["experience"][0],
|
||||
"period": "2022-06 \u2014 2023-12",
|
||||
}],
|
||||
}
|
||||
_, struct = profile_to_library(payload)
|
||||
assert struct["experience"][0]["start_date"] == "2022-06"
|
||||
assert struct["experience"][0]["end_date"] == "2023-12"
|
||||
|
||||
def test_profile_to_library_education_round_trip():
|
||||
_, struct = profile_to_library(PROFILE_PAYLOAD)
|
||||
assert struct["education"][0]["institution"] == "State University"
|
||||
|
||||
def test_profile_to_library_plain_text_contains_name():
|
||||
text, _ = profile_to_library(PROFILE_PAYLOAD)
|
||||
assert "Alex Rivera" in text
|
||||
|
||||
def test_profile_to_library_plain_text_contains_summary():
|
||||
text, _ = profile_to_library(PROFILE_PAYLOAD)
|
||||
assert "Senior UX Designer" in text
|
||||
|
||||
def test_profile_to_library_empty_payload_no_crash():
|
||||
text, struct = profile_to_library({})
|
||||
assert isinstance(text, str)
|
||||
assert isinstance(struct, dict)
|
||||
|
||||
|
||||
# ── make_auto_backup_name ─────────────────────────────────────────────────────
|
||||
|
||||
def test_backup_name_format():
|
||||
name = make_auto_backup_name("Senior Engineer Resume")
|
||||
import re
|
||||
assert re.match(r"Auto-backup before Senior Engineer Resume — \d{4}-\d{2}-\d{2}", name)
|
||||
|
||||
|
||||
# ── blank_fields_on_import ────────────────────────────────────────────────────
|
||||
|
||||
def test_blank_fields_industry_always_listed():
|
||||
result = blank_fields_on_import(STRUCT_JSON)
|
||||
assert "experience[].industry" in result
|
||||
|
||||
def test_blank_fields_location_listed_when_missing():
|
||||
no_loc = {**STRUCT_JSON, "experience": [{**STRUCT_JSON["experience"][0], "location": ""}]}
|
||||
result = blank_fields_on_import(no_loc)
|
||||
assert "experience[].location" in result
|
||||
|
||||
def test_blank_fields_location_not_listed_when_present():
|
||||
result = blank_fields_on_import(STRUCT_JSON)
|
||||
assert "experience[].location" not in result
|
||||
134
tests/test_resume_sync_integration.py
Normal file
134
tests/test_resume_sync_integration.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Integration tests for resume library<->profile sync endpoints."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from scripts.db import create_resume, get_resume, list_resumes
|
||||
from scripts.db_migrate import migrate_db
|
||||
|
||||
STRUCT_JSON = {
|
||||
"name": "Alex Rivera",
|
||||
"email": "alex@example.com",
|
||||
"phone": "555-0100",
|
||||
"career_summary": "Senior UX Designer.",
|
||||
"experience": [{"title": "Designer", "company": "Acme", "start_date": "2022",
|
||||
"end_date": "present", "location": "Remote", "bullets": ["Led redesign"]}],
|
||||
"education": [{"institution": "State U", "degree": "B.A.", "field": "Design",
|
||||
"start_date": "2016", "end_date": "2020"}],
|
||||
"skills": ["Figma"],
|
||||
"achievements": ["Design award"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
"""Set up a fresh isolated DB + config dir, wired to dev_api._request_db."""
|
||||
db = tmp_path / "test.db"
|
||||
cfg = tmp_path / "config"
|
||||
cfg.mkdir()
|
||||
# STAGING_DB drives _user_yaml_path() -> dirname(db)/config/user.yaml
|
||||
monkeypatch.setenv("STAGING_DB", str(db))
|
||||
migrate_db(db)
|
||||
import dev_api
|
||||
monkeypatch.setattr(
|
||||
dev_api,
|
||||
"_request_db",
|
||||
type("CV", (), {"get": lambda self: str(db), "set": lambda *a: None})(),
|
||||
)
|
||||
return db, cfg
|
||||
|
||||
|
||||
def test_apply_to_profile_updates_yaml(fresh_db, monkeypatch):
|
||||
db, cfg = fresh_db
|
||||
import dev_api
|
||||
client = TestClient(dev_api.app)
|
||||
entry = create_resume(db, name="Test Resume",
|
||||
text="Alex Rivera\n", source="uploaded",
|
||||
struct_json=json.dumps(STRUCT_JSON))
|
||||
resp = client.post(f"/api/resumes/{entry['id']}/apply-to-profile")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert "backup_id" in data
|
||||
assert "Auto-backup before Test Resume" in data["backup_name"]
|
||||
profile_yaml = cfg / "plain_text_resume.yaml"
|
||||
assert profile_yaml.exists()
|
||||
profile = yaml.safe_load(profile_yaml.read_text())
|
||||
assert profile["career_summary"] == "Senior UX Designer."
|
||||
# Name split: "Alex Rivera" -> name="Alex", surname="Rivera"
|
||||
assert profile["name"] == "Alex"
|
||||
assert profile["surname"] == "Rivera"
|
||||
assert profile["education"][0]["institution"] == "State U"
|
||||
|
||||
|
||||
def test_apply_to_profile_creates_backup(fresh_db, monkeypatch):
|
||||
db, cfg = fresh_db
|
||||
profile_path = cfg / "plain_text_resume.yaml"
|
||||
profile_path.write_text(yaml.dump({"name": "Old Name", "career_summary": "Old summary"}))
|
||||
entry = create_resume(db, name="New Resume",
|
||||
text="Alex Rivera\n", source="uploaded",
|
||||
struct_json=json.dumps(STRUCT_JSON))
|
||||
import dev_api
|
||||
client = TestClient(dev_api.app)
|
||||
client.post(f"/api/resumes/{entry['id']}/apply-to-profile")
|
||||
resumes = list_resumes(db_path=db)
|
||||
backup = next((r for r in resumes if r["source"] == "auto_backup"), None)
|
||||
assert backup is not None
|
||||
|
||||
|
||||
def test_apply_to_profile_preserves_metadata(fresh_db, monkeypatch):
|
||||
db, cfg = fresh_db
|
||||
profile_path = cfg / "plain_text_resume.yaml"
|
||||
profile_path.write_text(yaml.dump({
|
||||
"name": "Old", "salary_min": 80000, "salary_max": 120000,
|
||||
"remote": True, "gender": "non-binary",
|
||||
}))
|
||||
entry = create_resume(db, name="New",
|
||||
text="Alex\n", source="uploaded",
|
||||
struct_json=json.dumps(STRUCT_JSON))
|
||||
import dev_api
|
||||
client = TestClient(dev_api.app)
|
||||
client.post(f"/api/resumes/{entry['id']}/apply-to-profile")
|
||||
profile = yaml.safe_load(profile_path.read_text())
|
||||
assert profile["salary_min"] == 80000
|
||||
assert profile["remote"] is True
|
||||
assert profile["gender"] == "non-binary"
|
||||
|
||||
|
||||
def test_save_resume_syncs_to_default_library_entry(fresh_db, monkeypatch):
|
||||
db, cfg = fresh_db
|
||||
entry = create_resume(db, name="My Resume",
|
||||
text="Original", source="manual")
|
||||
user_yaml = cfg / "user.yaml"
|
||||
user_yaml.write_text(yaml.dump({"default_resume_id": entry["id"], "wizard_complete": True}))
|
||||
import dev_api
|
||||
client = TestClient(dev_api.app)
|
||||
resp = client.put("/api/settings/resume", json={
|
||||
"name": "Alex", "career_summary": "Updated summary",
|
||||
"experience": [], "education": [], "achievements": [], "skills": [],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["synced_library_entry_id"] == entry["id"]
|
||||
updated = get_resume(db_path=db, resume_id=entry["id"])
|
||||
assert updated["synced_at"] is not None
|
||||
struct = json.loads(updated["struct_json"])
|
||||
assert struct["career_summary"] == "Updated summary"
|
||||
|
||||
|
||||
def test_save_resume_no_default_no_crash(fresh_db, monkeypatch):
|
||||
db, cfg = fresh_db
|
||||
user_yaml = cfg / "user.yaml"
|
||||
user_yaml.write_text(yaml.dump({"wizard_complete": True}))
|
||||
import dev_api
|
||||
client = TestClient(dev_api.app)
|
||||
resp = client.put("/api/settings/resume", json={
|
||||
"name": "Alex", "career_summary": "", "experience": [],
|
||||
"education": [], "achievements": [], "skills": [],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["synced_library_entry_id"] is None
|
||||
|
|
@ -11,6 +11,9 @@
|
|||
html, body { margin: 0; background: #eaeff8; min-height: 100vh; }
|
||||
@media (prefers-color-scheme: dark) { html, body { background: #16202e; } }
|
||||
</style>
|
||||
<!-- Plausible analytics: cookie-free, GDPR-compliant, self-hosted.
|
||||
Skips localhost/127.0.0.1. Reports to hostname + circuitforge.tech rollup. -->
|
||||
<script>(function(){if(/localhost|127\.0\.0\.1/.test(location.hostname))return;var s=document.createElement('script');s.defer=true;s.dataset.domain=location.hostname+',circuitforge.tech';s.dataset.api='https://analytics.circuitforge.tech/api/event';s.src='https://analytics.circuitforge.tech/js/script.js';document.head.appendChild(s);})();</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->
|
||||
|
|
|
|||
146
web/src/components/ResumeSyncConfirmModal.vue
Normal file
146
web/src/components/ResumeSyncConfirmModal.vue
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="sync-modal__overlay" role="dialog" aria-modal="true"
|
||||
aria-labelledby="sync-modal-title" @keydown.esc="$emit('cancel')">
|
||||
<div class="sync-modal">
|
||||
<h2 id="sync-modal-title" class="sync-modal__title">Replace profile content?</h2>
|
||||
|
||||
<div class="sync-modal__comparison">
|
||||
<div class="sync-modal__col sync-modal__col--before">
|
||||
<div class="sync-modal__col-label">Current profile</div>
|
||||
<div class="sync-modal__col-name">{{ currentSummary.name || '(no name)' }}</div>
|
||||
<div class="sync-modal__col-summary">{{ currentSummary.careerSummary || '(no summary)' }}</div>
|
||||
<div class="sync-modal__col-role">{{ currentSummary.latestRole || '(no experience)' }}</div>
|
||||
</div>
|
||||
<div class="sync-modal__arrow" aria-hidden="true">→</div>
|
||||
<div class="sync-modal__col sync-modal__col--after">
|
||||
<div class="sync-modal__col-label">Replacing with</div>
|
||||
<div class="sync-modal__col-name">{{ sourceSummary.name || '(no name)' }}</div>
|
||||
<div class="sync-modal__col-summary">{{ sourceSummary.careerSummary || '(no summary)' }}</div>
|
||||
<div class="sync-modal__col-role">{{ sourceSummary.latestRole || '(no experience)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="blankFields.length" class="sync-modal__blank-warning">
|
||||
<strong>Fields that will be blank after import:</strong>
|
||||
<ul>
|
||||
<li v-for="f in blankFields" :key="f">{{ f }}</li>
|
||||
</ul>
|
||||
<p class="sync-modal__blank-note">You can fill these in after importing.</p>
|
||||
</div>
|
||||
|
||||
<p class="sync-modal__preserve-note">
|
||||
Your salary, work preferences, and contact details are not affected.
|
||||
</p>
|
||||
|
||||
<div class="sync-modal__actions">
|
||||
<button class="btn-secondary" @click="$emit('cancel')">Keep current profile</button>
|
||||
<button class="btn-danger" @click="$emit('confirm')">Replace profile content</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ContentSummary {
|
||||
name: string
|
||||
careerSummary: string
|
||||
latestRole: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
currentSummary: ContentSummary
|
||||
sourceSummary: ContentSummary
|
||||
blankFields: string[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sync-modal__overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.sync-modal {
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg, 0.75rem);
|
||||
padding: var(--space-6);
|
||||
max-width: 600px; width: 100%;
|
||||
max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.sync-modal__title {
|
||||
font-size: 1.15rem; font-weight: 700;
|
||||
margin-bottom: var(--space-5);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.sync-modal__comparison {
|
||||
display: grid; grid-template-columns: 1fr auto 1fr; gap: var(--space-3);
|
||||
align-items: start; margin-bottom: var(--space-5);
|
||||
}
|
||||
.sync-modal__arrow {
|
||||
font-size: 1.5rem; color: var(--color-text-muted);
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
.sync-modal__col {
|
||||
background: var(--color-surface-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.sync-modal__col--after { border-color: var(--color-primary); }
|
||||
.sync-modal__col-label {
|
||||
font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.sync-modal__col-name { font-weight: 600; color: var(--color-text); margin-bottom: var(--space-1); }
|
||||
.sync-modal__col-summary {
|
||||
font-size: 0.82rem; color: var(--color-text-muted);
|
||||
overflow: hidden; display: -webkit-box;
|
||||
-webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.sync-modal__col-role { font-size: 0.82rem; color: var(--color-text-muted); font-style: italic; }
|
||||
.sync-modal__blank-warning {
|
||||
background: color-mix(in srgb, var(--color-warning, #d97706) 10%, var(--color-surface-alt));
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning, #d97706) 30%, var(--color-border));
|
||||
border-radius: var(--radius-md); padding: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sync-modal__blank-warning ul { margin: var(--space-2) 0 0 var(--space-4); }
|
||||
.sync-modal__blank-note { margin-top: var(--space-2); color: var(--color-text-muted); }
|
||||
.sync-modal__preserve-note {
|
||||
font-size: 0.82rem; color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.sync-modal__actions {
|
||||
display: flex; gap: var(--space-3); justify-content: flex-end; flex-wrap: wrap;
|
||||
}
|
||||
.btn-danger {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--color-error, #dc2626);
|
||||
color: #fff; border: none;
|
||||
border-radius: var(--radius-md); cursor: pointer;
|
||||
font-size: var(--font-sm); font-weight: 600;
|
||||
}
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.btn-secondary {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md); cursor: pointer;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-surface-alt); }
|
||||
</style>
|
||||
|
|
@ -8,6 +8,12 @@ export interface WorkEntry {
|
|||
industry: string; responsibilities: string; skills: string[]
|
||||
}
|
||||
|
||||
export interface EducationEntry {
|
||||
id: string
|
||||
institution: string; degree: string; field: string
|
||||
start_date: string; end_date: string
|
||||
}
|
||||
|
||||
export const useResumeStore = defineStore('settings/resume', () => {
|
||||
const hasResume = ref(false)
|
||||
const loading = ref(false)
|
||||
|
|
@ -31,6 +37,11 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
|||
const veteran_status = ref(''); const disability = ref('')
|
||||
// Keywords
|
||||
const skills = ref<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([])
|
||||
// Extended profile fields
|
||||
const career_summary = ref('')
|
||||
const education = ref<EducationEntry[]>([])
|
||||
const achievements = ref<string[]>([])
|
||||
const lastSynced = ref<string | null>(null)
|
||||
// LLM suggestions (pending, not yet accepted)
|
||||
const skillSuggestions = ref<string[]>([])
|
||||
const domainSuggestions = ref<string[]>([])
|
||||
|
|
@ -69,6 +80,9 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
|||
skills.value = (data.skills as string[]) ?? []
|
||||
domains.value = (data.domains as string[]) ?? []
|
||||
keywords.value = (data.keywords as string[]) ?? []
|
||||
career_summary.value = String(data.career_summary ?? '')
|
||||
education.value = ((data.education as Omit<EducationEntry, 'id'>[]) ?? []).map(e => ({ ...e, id: crypto.randomUUID() }))
|
||||
achievements.value = (data.achievements as string[]) ?? []
|
||||
}
|
||||
|
||||
async function save() {
|
||||
|
|
@ -84,12 +98,19 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
|||
gender: gender.value, pronouns: pronouns.value, ethnicity: ethnicity.value,
|
||||
veteran_status: veteran_status.value, disability: disability.value,
|
||||
skills: skills.value, domains: domains.value, keywords: keywords.value,
|
||||
career_summary: career_summary.value,
|
||||
education: education.value.map(({ id: _id, ...e }) => e),
|
||||
achievements: achievements.value,
|
||||
}
|
||||
const { error } = await useApiFetch('/api/settings/resume', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
})
|
||||
saving.value = false
|
||||
if (error) saveError.value = 'Save failed — please try again.'
|
||||
if (error) {
|
||||
saveError.value = 'Save failed — please try again.'
|
||||
} else {
|
||||
lastSynced.value = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function createBlank() {
|
||||
|
|
@ -105,6 +126,16 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
|||
experience.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function addEducation() {
|
||||
education.value.push({
|
||||
id: crypto.randomUUID(), institution: '', degree: '', field: '', start_date: '', end_date: ''
|
||||
})
|
||||
}
|
||||
|
||||
function removeEducation(idx: number) {
|
||||
education.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function suggestTags(field: 'skills' | 'domains' | 'keywords') {
|
||||
suggestingField.value = field
|
||||
const current = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
|
||||
|
|
@ -149,7 +180,8 @@ export const useResumeStore = defineStore('settings/resume', () => {
|
|||
gender, pronouns, ethnicity, veteran_status, disability,
|
||||
skills, domains, keywords,
|
||||
skillSuggestions, domainSuggestions, keywordSuggestions, suggestingField,
|
||||
career_summary, education, achievements, lastSynced,
|
||||
syncFromProfile, load, save, createBlank,
|
||||
addExperience, removeExperience, addTag, removeTag, suggestTags, acceptTagSuggestion,
|
||||
addExperience, removeExperience, addEducation, removeEducation, addTag, removeTag, suggestTags, acceptTagSuggestion,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
</span>
|
||||
<div class="rv__item-info">
|
||||
<span class="rv__item-name">{{ r.name }}</span>
|
||||
<span v-if="r.is_default" class="rv__active-badge">Active profile</span>
|
||||
<span class="rv__item-meta">{{ r.word_count }} words · {{ fmtDate(r.created_at) }}</span>
|
||||
<span v-if="r.job_id" class="rv__item-source">Built for job #{{ r.job_id }}</span>
|
||||
</div>
|
||||
|
|
@ -51,6 +52,11 @@
|
|||
<button v-if="!selected.is_default" class="btn-secondary" @click="setDefault">
|
||||
★ Set as Default
|
||||
</button>
|
||||
<button class="btn-generate" @click="applyToProfile"
|
||||
:disabled="syncApplying"
|
||||
aria-describedby="apply-to-profile-desc">
|
||||
{{ syncApplying ? 'Applying…' : '⇩ Apply to profile' }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="toggleEdit">
|
||||
{{ editing ? 'Cancel' : 'Edit' }}
|
||||
</button>
|
||||
|
|
@ -90,20 +96,50 @@
|
|||
<button class="btn-secondary" @click="toggleEdit">Discard</button>
|
||||
</div>
|
||||
|
||||
<p id="apply-to-profile-desc" class="rv__sync-desc">
|
||||
Replaces your resume profile content with this version. Your current profile is backed up first.
|
||||
</p>
|
||||
<p v-if="selected.synced_at" class="rv__synced-at">
|
||||
Last synced to profile: {{ fmtDate(selected.synced_at) }}
|
||||
</p>
|
||||
|
||||
<p v-if="actionError" class="rv__error" role="alert">{{ actionError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persistent sync notice (dismissible) -->
|
||||
<div v-if="syncNotice" class="rv__sync-notice" role="status" aria-live="polite">
|
||||
Profile updated. Previous content backed up as
|
||||
<strong>{{ syncNotice.backupName }}</strong>.
|
||||
<button class="rv__sync-notice-dismiss" @click="dismissSyncNotice" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
|
||||
<ResumeSyncConfirmModal
|
||||
:show="showSyncModal"
|
||||
:current-summary="buildSummary(resumes.find(r => r.is_default === 1) ?? null)"
|
||||
:source-summary="buildSummary(selected)"
|
||||
:blank-fields="selected?.struct_json
|
||||
? (JSON.parse(selected.struct_json).experience?.length
|
||||
? ['experience[].industry']
|
||||
: [])
|
||||
: []"
|
||||
@confirm="confirmApplyToProfile"
|
||||
@cancel="showSyncModal = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
import ResumeSyncConfirmModal from '../components/ResumeSyncConfirmModal.vue'
|
||||
|
||||
interface Resume {
|
||||
id: number; name: string; source: string; job_id: number | null
|
||||
text: string; struct_json: string | null; word_count: number
|
||||
is_default: number; created_at: string; updated_at: string
|
||||
synced_at: string | null
|
||||
}
|
||||
|
||||
const resumes = ref<Resume[]>([])
|
||||
|
|
@ -116,6 +152,25 @@ const saving = ref(false)
|
|||
const actionError = ref('')
|
||||
const showDownloadMenu = ref(false)
|
||||
|
||||
const showSyncModal = ref(false)
|
||||
const syncApplying = ref(false)
|
||||
const syncNotice = ref<{ backupName: string; backupId: number } | null>(null)
|
||||
|
||||
interface ContentSummary { name: string; careerSummary: string; latestRole: string }
|
||||
|
||||
function buildSummary(r: Resume | null): ContentSummary {
|
||||
if (!r) return { name: '', careerSummary: '', latestRole: '' }
|
||||
try {
|
||||
const s = r.struct_json ? JSON.parse(r.struct_json) : {}
|
||||
const exp = Array.isArray(s.experience) ? s.experience[0] : null
|
||||
return {
|
||||
name: s.name || r.name,
|
||||
careerSummary: (s.career_summary || '').slice(0, 120),
|
||||
latestRole: exp ? `${exp.title || ''} at ${exp.company || ''}`.replace(/^ at | at $/, '') : '',
|
||||
}
|
||||
} catch { return { name: r.name, careerSummary: '', latestRole: '' } }
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
|
@ -185,6 +240,30 @@ async function confirmDelete() {
|
|||
await loadList()
|
||||
}
|
||||
|
||||
async function applyToProfile() {
|
||||
if (!selected.value) return
|
||||
showSyncModal.value = true
|
||||
}
|
||||
|
||||
async function confirmApplyToProfile() {
|
||||
if (!selected.value) return
|
||||
showSyncModal.value = false
|
||||
syncApplying.value = true
|
||||
actionError.value = ''
|
||||
const { data, error } = await useApiFetch<{
|
||||
ok: boolean; backup_id: number; backup_name: string
|
||||
}>(`/api/resumes/${selected.value.id}/apply-to-profile`, { method: 'POST' })
|
||||
syncApplying.value = false
|
||||
if (error || !data?.ok) {
|
||||
actionError.value = 'Profile sync failed — please try again.'
|
||||
return
|
||||
}
|
||||
syncNotice.value = { backupName: data.backup_name, backupId: data.backup_id }
|
||||
await loadList()
|
||||
}
|
||||
|
||||
function dismissSyncNotice() { syncNotice.value = null }
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
|
|
@ -221,6 +300,15 @@ function downloadYaml() {
|
|||
}
|
||||
|
||||
onMounted(loadList)
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (editing.value && (editName.value !== selected.value?.name || editText.value !== selected.value?.text)) {
|
||||
const confirmed = window.confirm(
|
||||
`You have unsaved edits to "${selected.value?.name}". Leave without saving?`
|
||||
)
|
||||
if (!confirmed) return false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -337,4 +425,32 @@ onMounted(loadList)
|
|||
.rv__layout { grid-template-columns: 1fr; }
|
||||
.rv__list { max-height: 200px; }
|
||||
}
|
||||
|
||||
.rv__active-badge {
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-surface-alt));
|
||||
color: var(--color-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, var(--color-border));
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
padding: 1px 6px; margin-left: var(--space-1);
|
||||
}
|
||||
.rv__sync-desc {
|
||||
font-size: 0.78rem; color: var(--color-text-muted); margin-top: var(--space-1);
|
||||
}
|
||||
.rv__synced-at {
|
||||
font-size: 0.78rem; color: var(--color-text-muted); margin-top: var(--space-1);
|
||||
}
|
||||
.rv__sync-notice {
|
||||
position: fixed; bottom: var(--space-6); left: 50%; transform: translateX(-50%);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-md); padding: var(--space-3) var(--space-5);
|
||||
font-size: 0.9rem; z-index: 500; max-width: 480px;
|
||||
display: flex; gap: var(--space-3); align-items: center;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||
}
|
||||
.rv__sync-notice-dismiss {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-text-muted); font-size: 1rem; flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,23 @@
|
|||
<p v-if="uploadError" class="error">{{ uploadError }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Sync status label -->
|
||||
<div v-if="store.lastSynced" class="sync-status-label">
|
||||
Content synced from Resume Library — {{ fmtDate(store.lastSynced) }}.
|
||||
Changes here update the default library entry when you save.
|
||||
</div>
|
||||
|
||||
<!-- Career Summary -->
|
||||
<section class="form-section">
|
||||
<h3>Career Summary</h3>
|
||||
<p class="section-note">Used in cover letter generation and as your professional introduction.</p>
|
||||
<div class="field-row">
|
||||
<label for="career-summary">Career summary</label>
|
||||
<textarea id="career-summary" v-model="store.career_summary"
|
||||
rows="4" placeholder="2-3 sentences summarising your background and what you bring."></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Personal Information -->
|
||||
<section class="form-section">
|
||||
<h3>Personal Information</h3>
|
||||
|
|
@ -130,6 +147,57 @@
|
|||
<button @click="store.addExperience()">+ Add Position</button>
|
||||
</section>
|
||||
|
||||
<!-- Education -->
|
||||
<section class="form-section">
|
||||
<h3>Education</h3>
|
||||
<div v-for="(edu, idx) in store.education" :key="edu.id" class="experience-card">
|
||||
<div class="experience-card__header">
|
||||
<span class="experience-card__label">Education {{ idx + 1 }}</span>
|
||||
<button class="btn-remove" @click="store.removeEducation(idx)"
|
||||
:aria-label="`Remove education entry ${idx + 1}`">Remove</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Institution</label>
|
||||
<input v-model="edu.institution" placeholder="University or school name" />
|
||||
</div>
|
||||
<div class="field-row-grid">
|
||||
<div class="field-row">
|
||||
<label>Degree</label>
|
||||
<input v-model="edu.degree" placeholder="e.g. B.S., M.A., Ph.D." />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Field of study</label>
|
||||
<input v-model="edu.field" placeholder="e.g. Computer Science" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row-grid">
|
||||
<div class="field-row">
|
||||
<label>Start year</label>
|
||||
<input v-model="edu.start_date" placeholder="2015" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>End year</label>
|
||||
<input v-model="edu.end_date" placeholder="2019" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-secondary" @click="store.addEducation">+ Add education</button>
|
||||
</section>
|
||||
|
||||
<!-- Achievements -->
|
||||
<section class="form-section">
|
||||
<h3>Achievements</h3>
|
||||
<p class="section-note">Awards, certifications, open-source projects, publications.</p>
|
||||
<div v-for="(ach, idx) in store.achievements" :key="idx" class="achievement-row">
|
||||
<input :value="ach"
|
||||
@input="store.achievements[idx] = ($event.target as HTMLInputElement).value"
|
||||
placeholder="Describe the achievement" />
|
||||
<button class="btn-remove" @click="store.achievements.splice(idx, 1)"
|
||||
:aria-label="`Remove achievement ${idx + 1}`">✕</button>
|
||||
</div>
|
||||
<button class="btn-secondary" @click="store.achievements.push('')">+ Add achievement</button>
|
||||
</section>
|
||||
|
||||
<!-- Preferences -->
|
||||
<section class="form-section">
|
||||
<h3>Preferences & Availability</h3>
|
||||
|
|
@ -302,6 +370,10 @@ function handleFileSelect(event: Event) {
|
|||
uploadError.value = null
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
const file = pendingFile.value
|
||||
if (!file) return
|
||||
|
|
@ -407,4 +479,34 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3); }
|
|||
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border); border-radius: 4px; color: var(--color-text-muted); cursor: pointer; font-size: 0.78rem; }
|
||||
.loading { text-align: center; padding: var(--space-8); color: var(--color-text-muted); }
|
||||
.replace-section { background: var(--color-surface-alt); border-radius: 8px; padding: var(--space-4); }
|
||||
.sync-status-label {
|
||||
font-size: 0.82rem; color: var(--color-text-muted);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface-alt));
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
.achievement-row {
|
||||
display: flex; gap: var(--space-2); align-items: center; margin-bottom: var(--space-2);
|
||||
}
|
||||
.achievement-row input { flex: 1; }
|
||||
.btn-remove {
|
||||
background: none; border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm); padding: 2px var(--space-2);
|
||||
cursor: pointer; color: var(--color-text-muted); font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-remove:hover { color: var(--color-error, #dc2626); border-color: var(--color-error, #dc2626); }
|
||||
.field-row-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
|
||||
.btn-secondary {
|
||||
padding: 7px 16px; background: transparent;
|
||||
border: 1px solid var(--color-border); border-radius: 6px;
|
||||
color: var(--color-text-muted); cursor: pointer; font-size: 0.85rem;
|
||||
}
|
||||
.btn-secondary:hover { border-color: var(--color-accent); color: var(--color-accent); }
|
||||
.experience-card__header {
|
||||
display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-3);
|
||||
}
|
||||
.experience-card__label { font-size: 0.82rem; color: var(--color-text-muted); font-weight: 500; }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue