diff --git a/.idea/workspace.xml b/.idea/workspace.xml index d3735c6..5c76385 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,13 +4,10 @@ - @@ -343,7 +360,9 @@ - diff --git a/api/app/agent.py b/api/app/agent.py index 24f8ca0..865edf9 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -252,6 +252,18 @@ "required": ["onet_code"], }, }, + { + "name": "generate_tailored_cv", + "description": "Generate a tailored CV (Markdown) for a specific job, emphasising the most relevant experience and skills from the user persona.", + "input_schema": { + "type": "object", + "properties": { + "job": {"type": "object", "description": "Target job object with title, company, description"}, + "persona": {"type": "object", "description": "Structured User Persona"}, + }, + "required": ["job", "persona"], + }, + }, ] @@ -435,10 +447,12 @@ async def execute_tool(name: str, tool_input: dict) -> dict: data = lookup_by_title(skill) raw = data.get("overall_exposure", 0.0) exposure = round(raw, 2) if raw and raw != 0.5 else 0.55 + pct = round(exposure * 100) + risk_label = "High" if exposure >= 0.65 else ("Medium" if exposure >= 0.35 else "Low") results.append({ "skill": skill, - "exposure": round(exposure, 2), - "risk": "high" if exposure >= 0.5 else "low", + "exposure_score": pct, + "risk_label": risk_label, }) return {"skill_risks": results} @@ -534,7 +548,8 @@ def _skill_gaps(desc: str) -> list[str]: exposure = job.get("exposure_score", 0.5) desc = job.get("description", "") desc_lower = desc.lower() - skill_matches = sum(1 for s in cv_skills if s in desc_lower) + matched = [s for s in cv_skills if s in desc_lower] + skill_matches = len(matched) fit_score = min(skill_matches / max(len(cv_skills), 1), 1.0) level, level_bonus = _level_match(job.get("title", ""), desc) gaps = _skill_gaps(desc) @@ -547,9 +562,10 @@ def _skill_gaps(desc: str) -> list[str]: "url": job.get("url"), "description": desc, "exposure_score": exposure, - "fit_score": round(fit_score, 2), + "fit_score": round(fit_score * 100), "level_match": level, "skill_gaps": gaps, + "matched_skills": sorted(matched)[:10], "composite_score": round(composite, 2), }) @@ -623,10 +639,10 @@ def _skill_gaps(desc: str) -> list[str]: persona = tool_input.get("persona", {}) target_job_title = tool_input.get("target_job_title", "") - confirmed_high = [s for s in skill_risks if s.get("exposure", 0) > 0.65] - confirmed_low = [s for s in skill_risks if 0 < s.get("exposure", 0) <= 0.35] + confirmed_high = [s for s in skill_risks if s.get("exposure_score", s.get("exposure", 0) * 100) > 65] + confirmed_low = [s for s in skill_risks if 0 < s.get("exposure_score", s.get("exposure", 0) * 100) <= 35] all_skills_str = ", ".join( - f"{s['skill']} ({int(s.get('exposure',0)*100)}%)" for s in skill_risks + f"{s['skill']} ({s.get('exposure_score', round(s.get('exposure', 0) * 100))}%)" for s in skill_risks ) core = persona.get("core_info", {}) @@ -690,6 +706,64 @@ def _skill_gaps(desc: str) -> list[str]: onet_code = tool_input.get("onet_code", "") return lookup_onet(onet_code) + elif name == "generate_tailored_cv": + job = tool_input.get("job", {}) + persona = tool_input.get("persona", {}) + + core = persona.get("core_info", {}) + full_name = f"{core.get('first_name', '')} {core.get('last_name', '')}".strip() + contact_parts = filter(None, [ + core.get("email"), core.get("phone"), + core.get("city"), core.get("linkedin"), core.get("github"), + ]) + contact_line = " | ".join(contact_parts) + + skills_str = "" + for cat in persona.get("skills", []): + skills_str += f"**{cat.get('category','Skills')}:** {', '.join(cat.get('skills',[]))}\n" + + history_str = "" + for exp in persona.get("history", [])[:5]: + end = "Present" if exp.get("is_current") else exp.get("end_date", "") + history_str += ( + f"**{exp.get('title','')}** — {exp.get('employer','')} " + f"({exp.get('start_date','')} – {end})\n" + f"{exp.get('summary','')}\n" + f"{exp.get('metrics','')}\n\n" + ) + + edu_str = "" + for edu in persona.get("education", []): + edu_str += f"**{edu.get('degree','')}** — {edu.get('institution','')} ({edu.get('grade','')})\n" + + proj_str = "" + for proj in persona.get("projects", [])[:3]: + proj_str += f"**{proj.get('name','')}**: {proj.get('problem_solved','')} → {proj.get('outcomes','')}\n" + + cv_text = await _llm( + system=( + "You are a professional CV writer. Produce a tailored, ATS-optimised CV in clean Markdown. " + "Reorder and emphasise experience, skills, and projects that are most relevant to the target job. " + "Mirror keywords from the job description naturally. " + "Use this structure: Contact header, Professional Summary (3 sentences), Skills (grouped), " + "Experience (reverse-chronological, bullets using STAR), Education, Projects. " + "Do NOT add fictional information — only use what is provided. " + "Do NOT include placeholders. Output only the Markdown, no preamble." + ), + user=( + f"# Target Job\n{job.get('title','')} at {job.get('company','')}\n" + f"Job Description:\n{job.get('description','')[:2000]}\n\n" + f"# Candidate\nName: {full_name}\nContact: {contact_line}\n\n" + f"## Skills\n{skills_str}\n" + f"## Experience\n{history_str}\n" + f"## Education\n{edu_str}\n" + f"## Projects\n{proj_str}\n" + f"Summary from persona: {persona.get('summary','')}" + ), + max_tokens=2048, + ) + return {"cv_text": cv_text.strip()} + return {"error": f"Unknown tool: {name}"} diff --git a/api/app/browser_agent.py b/api/app/browser_agent.py index daca09d..0cd9cf1 100644 --- a/api/app/browser_agent.py +++ b/api/app/browser_agent.py @@ -14,12 +14,100 @@ from browser_use import Agent, BrowserSession, BrowserProfile if _BACKEND == "groq": + import json as _json + import re as _re + import typing as _typing from langchain_groq import ChatGroq as _BaseChatGroq - from typing import ClassVar + from langchain_core.messages import ( + SystemMessage as _SystemMessage, + HumanMessage as _HumanMessage, + AIMessage as _AIMessage, + ) + + def _to_lc_messages(messages): + """Convert browser_use message types to standard LangChain messages.""" + result = [] + for msg in messages: + cls_name = type(msg).__name__ + content = getattr(msg, "content", "") + if cls_name == "SystemMessage": + result.append(_SystemMessage(content=content)) + elif cls_name == "HumanMessage": + result.append(_HumanMessage(content=content)) + elif cls_name == "AIMessage": + result.append(_AIMessage(content=content)) + else: + role = getattr(msg, "role", "user") + if role == "system": + result.append(_SystemMessage(content=content)) + elif role == "assistant": + result.append(_AIMessage(content=content)) + else: + result.append(_HumanMessage(content=content)) + return result + + def _parse_with_defaults(model_class, text: str): + """ + Extract JSON from LLM text and fill in defaults for any missing fields + before constructing the pydantic model. Handles the case where the + model omits required string fields (evaluation_previous_goal, etc.). + """ + m = _re.search(r'\{.*\}', text, _re.DOTALL) + data = _json.loads(m.group()) if m else {} + + for name, fi in model_class.model_fields.items(): + if name in data: + continue + ann = fi.annotation + origin = getattr(ann, '__origin__', None) + if origin is list or ann is list: + data[name] = [] + elif ann is str or ann == type(""): + data[name] = "" + elif ann is int: + data[name] = 0 + elif ann is bool: + data[name] = False + else: + data[name] = None + + return model_class.model_validate(data) + + class _BrowserLLM: + """ + Plain adapter around ChatGroq for browser-use 0.11.x. + + browser-use calls: + response = await llm.ainvoke(messages, output_format=AgentOutput) + parsed = response.completion + + We use Groq's json_object mode (no tool-call schema validation) and + fill in sensible defaults for any fields the model omits. + """ + + provider = "groq" + + def __init__(self, model: str, groq_api_key: str): + self.model = model + self._inner = _BaseChatGroq(model=model, groq_api_key=groq_api_key) + + async def ainvoke(self, messages, output_format=None, **kwargs): + kwargs.pop("session_id", None) + lc_messages = _to_lc_messages(messages) + + if output_format is not None: + # json_object mode: Groq validates JSON syntax only, not schema. + # The model already knows the expected structure from the system prompt. + llm = self._inner.bind(response_format={"type": "json_object"}) + response = await llm.ainvoke(lc_messages) + content = getattr(response, "content", str(response)) + completion = _parse_with_defaults(output_format, content) + return type("_Resp", (), {"completion": completion})() + + return await self._inner.ainvoke(lc_messages, **kwargs) - class _BrowserLLM(_BaseChatGroq): - """ChatGroq with provider attribute required by browser-use.""" - provider: ClassVar[str] = "groq" + def __getattr__(self, name: str): + return getattr(self._inner, name) def _make_browser_llm(): return _BrowserLLM( diff --git a/api/app/main.py b/api/app/main.py index 02d623d..b47b7f0 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -12,16 +12,21 @@ from fastapi.security import OAuth2PasswordRequestForm from .cv_parser import extract_text -from .agent import run_pipeline, run_apply +from .agent import run_pipeline, run_apply, execute_tool from .guardrails import check_cv_for_injection, get_audit_log, GuardrailViolation +from .job_proxy import search_jobs from . import tracker from . import auth +from . import pdf_generator from .models import Persona @asynccontextmanager async def lifespan(app: FastAPI): + import os + data_dir = os.environ.get("APP_DATA_PATH", "/tmp") tracker.init_db() + pdf_generator.init_pdf_dir(data_dir) yield @@ -257,13 +262,14 @@ async def pipeline_with_cv_session(): async def apply( job_id: str = Form(...), job_url: str = Form(default=""), - cover_letter: str = Form(...), + cover_letter: str = Form(default=""), cv_profile: str = Form(default="{}"), skill_risks: str = Form(default="[]"), job_email: str = Form(default=""), job_password: str = Form(default=""), cv_session_id: str = Form(default=""), - # Score data for tracker (sent from frontend after pipeline completes) + tailored_cv: str = Form(default=""), + cv_doc_id: str = Form(default=""), job_title: str = Form(default=""), company: str = Form(default=""), location: str = Form(default=""), @@ -287,10 +293,21 @@ async def apply( except json.JSONDecodeError: gaps = [] - cv_path = _cv_files.get(cv_session_id, "") + # Fall back to persona credentials if not provided in form + persona = tracker.get_user_persona(current_user.id) + core = persona.get("core_info", {}) + effective_email = job_email or core.get("job_email", "") + effective_password = job_password or core.get("job_password", "") + + # Tailored CV PDF takes precedence over original upload if the user chose it + if cv_doc_id: + tailored_path = pdf_generator.get_pdf_path(cv_doc_id) + import os as _os2 + cv_path = tailored_path if _os2.path.exists(tailored_path) else _cv_files.get(cv_session_id, "") + else: + cv_path = _cv_files.get(cv_session_id, "") session_id = str(uuid.uuid4()) - # Add to tracker before starting browser session job_dict = { "job_id": job_id, "title": job_title, @@ -305,7 +322,9 @@ async def apply( "skill_gaps": gaps, "level_match": level_match, } - app_id = tracker.add_application(current_user.id, job_dict, profile, cover_letter, score_data) + app_id = tracker.add_application( + current_user.id, job_dict, profile, cover_letter, score_data, tailored_cv=tailored_cv + ) q: asyncio.Queue = asyncio.Queue() fq: asyncio.Queue = asyncio.Queue(maxsize=8) @@ -313,24 +332,23 @@ async def apply( _browser_frames[session_id] = fq async def cleanup_gen(): - applied_successfully = False + user_confirmed = False try: async for chunk in run_apply( current_user.id, job_id, job_url, cover_letter, risks, session_id, q, fq, - job_email=job_email, job_password=job_password, cv_path=cv_path, + job_email=effective_email, job_password=effective_password, cv_path=cv_path, ): - # Detect successful completion from done event try: payload = json.loads(chunk.removeprefix("data: ").strip()) if payload.get("type") == "done": - applied_successfully = True + user_confirmed = True except Exception: pass yield chunk finally: _browser_queues.pop(session_id, None) _browser_frames.pop(session_id, None) - if app_id and applied_successfully: + if app_id and user_confirmed: tracker.update_status(app_id, "Applied") return StreamingResponse( @@ -419,9 +437,203 @@ async def update_application_status( ): status = body.get("status", "") notes = body.get("notes") - # In a multi-user system, we should verify the app_id belongs to current_user.id - # but for now update_status is global. Let's fix tracker later if needed. ok = tracker.update_status(app_id, status, notes) if not ok: raise HTTPException(status_code=400, detail=f"Invalid status: {status!r}") return {"id": app_id, "status": status, "updated": True} + + +# ── Onboarding ──────────────────────────────────────────────────────────────── + +@app.get("/onboarding/status") +async def onboarding_status(current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + return {"complete": tracker.get_onboarding_status(current_user.id)} + + +@app.post("/onboarding/complete") +async def onboarding_complete(current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + tracker.set_onboarding_complete(current_user.id) + return {"complete": True} + + +# ── Job Feed ────────────────────────────────────────────────────────────────── + +@app.get("/jobs") +async def get_jobs( + query: str = Query(default=""), + location: str = Query(default="London"), + current_user: Annotated[auth.User, Depends(auth.get_current_user)] = None, +): + """Return job listings for the job feed, defaulting to persona preferences.""" + if not query: + persona = tracker.get_user_persona(current_user.id) + prefs = persona.get("preferences", {}) + titles = prefs.get("target_titles", []) + query = titles[0] if titles else "Software Engineer" + locs = prefs.get("preferred_locations", []) + if not location or location == "London": + location = locs[0] if locs else "London" + jobs = await search_jobs(query, location) + return {"jobs": jobs, "query": query, "location": location} + + +# ── Per-job agent endpoints (SSE) ───────────────────────────────────────────── + +def _sse_json(event_type: str, data: dict) -> str: + import time + payload = {"type": event_type, "timestamp": time.time(), **data} + return f"data: {json.dumps(payload)}\n\n" + + +@app.post("/jobs/analyze") +async def analyze_job( + body: dict, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """SSE: score fit + AI risk for a single job against the user's persona.""" + job = body.get("job", {}) + persona = tracker.get_user_persona(current_user.id) + + async def gen(): + yield _sse_json("start", {"message": "Analysing job…"}) + + # Score AI risk for persona skills + all_skills: list[str] = [] + for cat in persona.get("skills", []): + all_skills.extend(cat.get("skills", [])) + for exp in persona.get("history", []): + title = exp.get("title", "") + if title: + all_skills.append(title) + if all_skills: + risk_result = await execute_tool("score_ai_risk", {"skills": list(set(all_skills))}) + yield _sse_json("skill_risks", risk_result) + + # Score job fit + fit_result = await execute_tool("score_job_fit", {"jobs": [job], "persona": persona}) + yield _sse_json("job_fit", fit_result) + yield _sse_json("done", {"message": "Analysis complete"}) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +@app.post("/jobs/cover-letter") +async def generate_cover_letter_endpoint( + body: dict, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """SSE: generate a tailored cover letter for a job.""" + job = body.get("job", {}) + tone = body.get("tone", "professional") + persona = tracker.get_user_persona(current_user.id) + + async def gen(): + yield _sse_json("start", {"message": "Writing cover letter…"}) + result = await execute_tool("generate_cover_letter", {"job": job, "persona": persona, "tone": tone}) + yield _sse_json("cover_letter", result) + yield _sse_json("done", {"message": "Cover letter ready"}) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +@app.post("/jobs/tailored-cv") +async def generate_tailored_cv_endpoint( + body: dict, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """SSE: generate a tailored CV (Markdown) for a specific job.""" + job = body.get("job", {}) + persona = tracker.get_user_persona(current_user.id) + + async def gen(): + yield _sse_json("start", {"message": "Tailoring your CV…"}) + result = await execute_tool("generate_tailored_cv", {"job": job, "persona": persona}) + yield _sse_json("tailored_cv", result) + yield _sse_json("done", {"message": "CV ready"}) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +# ── Careers ─────────────────────────────────────────────────────────────────── + +@app.post("/score-skills") +async def score_skills( + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """Non-streaming: score AI risk for all skills in the user's persona.""" + persona = tracker.get_user_persona(current_user.id) + all_skills: list[str] = [] + for cat in persona.get("skills", []): + all_skills.extend(cat.get("skills", [])) + for exp in persona.get("history", []): + title = exp.get("title", "") + if title: + all_skills.append(title) + if not all_skills: + return {"skill_risks": []} + result = await execute_tool("score_ai_risk", {"skills": list(set(all_skills))}) + return result + + +@app.post("/careers/roadmap") +async def careers_roadmap( # noqa: E302 + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """SSE: generate a skill development roadmap from the user's persona.""" + persona = tracker.get_user_persona(current_user.id) + all_skills: list[str] = [] + for cat in persona.get("skills", []): + all_skills.extend(cat.get("skills", [])) + + async def gen(): + yield _sse_json("start", {"message": "Building your skill roadmap…"}) + risk_result = await execute_tool("score_ai_risk", {"skills": list(set(all_skills)) or ["software engineering"]}) + skill_risks = risk_result.get("skill_risks", []) + result = await execute_tool("generate_skill_roadmap", {"skill_risks": skill_risks, "persona": persona}) + yield _sse_json("roadmap", result) + yield _sse_json("done", {"message": "Roadmap ready"}) + + return StreamingResponse(gen(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +# ── Document / PDF export ───────────────────────────────────────────────────── + +@app.post("/documents/pdf") +async def create_pdf( + body: dict, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """Generate a PDF from CV or cover-letter Markdown text.""" + doc_type = body.get("type", "cv") + text = body.get("text", "") + metadata = body.get("metadata", {}) + if not text: + raise HTTPException(status_code=400, detail="text is required") + try: + doc_id = pdf_generator.generate_pdf(doc_type, text, metadata) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + return {"doc_id": doc_id, "download_url": f"/api/documents/{doc_id}/download"} + + +from fastapi.responses import FileResponse # noqa: E402 + + +@app.get("/documents/{doc_id}/download") +async def download_pdf( + doc_id: str, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + import re + import os as _os + if not re.fullmatch(r"[a-f0-9]{32}", doc_id): + raise HTTPException(status_code=400, detail="Invalid doc_id") + path = pdf_generator.get_pdf_path(doc_id) + if not _os.path.exists(path): + raise HTTPException(status_code=404, detail="Document not found") + return FileResponse(path, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="windrush_{doc_id[:8]}.pdf"'}) diff --git a/api/app/models.py b/api/app/models.py index f5e4007..7e2147e 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -108,6 +108,8 @@ class CoreInfo(BaseModel): require_sponsorship: bool = False security_clearance: str = "" has_government_ties: bool = False + job_email: str = "" + job_password: str = "" class JobPreferences(BaseModel): target_titles: List[str] = [] diff --git a/api/app/pdf_generator.py b/api/app/pdf_generator.py new file mode 100644 index 0000000..5232c27 --- /dev/null +++ b/api/app/pdf_generator.py @@ -0,0 +1,265 @@ +""" +PDF generation for Windrush documents (CV and cover letter). +Uses fpdf2 for layout with Unicode normalisation for Helvetica compatibility. +""" +import logging +import os +import re +import unicodedata +import uuid + +from fpdf import FPDF + +logger = logging.getLogger("windrush.pdf") + +_PDF_DIR: str = "" + + +def init_pdf_dir(base_path: str) -> None: + global _PDF_DIR + _PDF_DIR = os.path.join(base_path, "pdfs") + os.makedirs(_PDF_DIR, exist_ok=True) + logger.info("PDF dir: %s", _PDF_DIR) + + +def get_pdf_path(doc_id: str) -> str: + return os.path.join(_PDF_DIR or "/tmp", f"{doc_id}.pdf") + + +# ── Unicode safety ───────────────────────────────────────────────────────────── + +_UNICODE_MAP = { + "‘": "'", "’": "'", # smart single quotes + "“": '"', "”": '"', # smart double quotes + "–": "-", "—": "--", # en/em dash + "…": "...", # ellipsis + " ": " ", # non-breaking space + "•": "-", # bullet + "·": "-", # middle dot + "→": "->", # right arrow +} + + +def _safe(text: str) -> str: + """Normalise Unicode to something Helvetica/Latin-1 can render.""" + for ch, sub in _UNICODE_MAP.items(): + text = text.replace(ch, sub) + # NFKD decomposition, then drop anything that won't encode to latin-1 + text = unicodedata.normalize("NFKD", text) + return text.encode("latin-1", errors="replace").decode("latin-1") + + +# ── Inline Markdown parser ───────────────────────────────────────────────────── + +def _inline(text: str) -> list[tuple[bool, bool, str]]: + """Return list of (bold, italic, segment) tuples.""" + segs: list[tuple[bool, bool, str]] = [] + pat = re.compile(r"\*\*(.+?)\*\*|\*(.+?)\*|__(.+?)__|_(.+?)_") + cur = 0 + for m in pat.finditer(text): + if m.start() > cur: + segs.append((False, False, text[cur:m.start()])) + if m.group(1) is not None: + segs.append((True, False, m.group(1))) + elif m.group(2) is not None: + segs.append((False, True, m.group(2))) + elif m.group(3) is not None: + segs.append((True, False, m.group(3))) + elif m.group(4) is not None: + segs.append((False, True, m.group(4))) + cur = m.end() + if cur < len(text): + segs.append((False, False, text[cur:])) + return segs + + +# ── PDF document class ───────────────────────────────────────────────────────── + +_MARGIN = 18 +_PAGE_W = 210 - _MARGIN * 2 + +_C_DARK = (15, 15, 15) +_C_TEAL = (15, 118, 110) +_C_MUTED = (100, 100, 100) +_C_RULE = (210, 210, 210) + + +class _Doc(FPDF): + def header(self): + pass + + def footer(self): + self.set_y(-12) + self.set_font("Helvetica", "I", 8) + self.set_text_color(*_C_MUTED) + self.cell(0, 5, f"Page {self.page_no()}", align="C") + self.set_text_color(*_C_DARK) + + # ── helpers ──────────────────────────────────────────────────────────────── + + def rule(self): + self.set_draw_color(*_C_RULE) + self.set_line_width(0.3) + y = self.get_y() + 1 + self.line(_MARGIN, y, _MARGIN + _PAGE_W, y) + self.ln(4) + + def h1(self, text: str): + self.set_font("Helvetica", "B", 20) + self.set_text_color(*_C_DARK) + self.set_x(_MARGIN) + self.multi_cell(_PAGE_W, 10, _safe(text), align="L") + self.ln(1) + + def h2(self, text: str): + self.ln(3) + self.set_font("Helvetica", "B", 10) + self.set_text_color(*_C_TEAL) + self.set_x(_MARGIN) + self.multi_cell(_PAGE_W, 7, _safe(text).upper(), align="L") + self.rule() + + def h3(self, text: str): + self.set_font("Helvetica", "B", 10) + self.set_text_color(*_C_DARK) + self.set_x(_MARGIN) + self.multi_cell(_PAGE_W, 6, _safe(text), align="L") + + def para(self, text: str): + """Render a paragraph with inline bold/italic.""" + segs = _inline(_safe(text)) + self.set_x(_MARGIN) + for bold, italic, seg in segs: + style = ("B" if bold else "") + ("I" if italic else "") + self.set_font("Helvetica", style, 10) + self.set_text_color(*_C_DARK) + self.write(5, seg) + self.ln(5) + + def bullet(self, text: str, level: int = 0): + indent = _MARGIN + level * 4 + self.set_x(indent) + self.set_font("Helvetica", "", 10) + self.set_text_color(*_C_TEAL) + self.write(5, "- ") + segs = _inline(_safe(text)) + for bold, italic, seg in segs: + style = ("B" if bold else "") + ("I" if italic else "") + self.set_font("Helvetica", style, 10) + self.set_text_color(*_C_DARK) + self.write(5, seg) + self.ln(5) + + def muted(self, text: str): + self.set_font("Helvetica", "", 9) + self.set_text_color(*_C_MUTED) + self.set_x(_MARGIN) + self.multi_cell(_PAGE_W, 5, _safe(text), align="L") + self.set_text_color(*_C_DARK) + + +# ── CV PDF ───────────────────────────────────────────────────────────────────── + +def _cv_pdf(text: str) -> str: + doc = _Doc("P", "mm", "A4") + doc.set_margins(_MARGIN, 15, _MARGIN) + doc.set_auto_page_break(auto=True, margin=15) + doc.add_page() + doc.set_font("Helvetica", "", 10) + + in_contact = True + for raw in text.splitlines(): + line = raw.rstrip() + if not line: + if in_contact: + in_contact = False + doc.ln(4) + else: + doc.ln(2) + continue + if line.startswith("### "): + in_contact = False + doc.h3(line[4:]) + elif line.startswith("## "): + in_contact = False + doc.h2(line[3:]) + elif line.startswith("# "): + doc.h1(line[2:]) + elif re.match(r"^[\-\*] ", line): + in_contact = False + level = (len(line) - len(line.lstrip())) // 2 + doc.bullet(line.lstrip()[2:], level) + else: + if in_contact: + doc.muted(line) + else: + doc.para(line) + + doc_id = uuid.uuid4().hex + doc.output(get_pdf_path(doc_id)) + return doc_id + + +# ── Cover letter PDF ─────────────────────────────────────────────────────────── + +def _letter_pdf(text: str, name: str = "", job_title: str = "", company: str = "") -> str: + doc = _Doc("P", "mm", "A4") + doc.set_margins(_MARGIN, 20, _MARGIN) + doc.set_auto_page_break(auto=True, margin=20) + doc.add_page() + doc.set_font("Helvetica", "", 10) + + if name: + doc.set_font("Helvetica", "B", 16) + doc.set_text_color(*_C_DARK) + doc.set_x(_MARGIN) + doc.multi_cell(_PAGE_W, 9, _safe(name), align="L") + if job_title and company: + doc.set_font("Helvetica", "", 10) + doc.set_text_color(*_C_MUTED) + doc.set_x(_MARGIN) + doc.multi_cell(_PAGE_W, 6, _safe(f"Application for {job_title} at {company}"), align="L") + doc.set_text_color(*_C_DARK) + if name or (job_title and company): + doc.rule() + doc.ln(2) + + for raw in text.splitlines(): + line = raw.rstrip() + if not line: + doc.ln(3) + continue + if line.startswith("## "): + doc.h2(line[3:]) + elif line.startswith("# "): + doc.h1(line[2:]) + elif re.match(r"^[\-\*] ", line): + doc.bullet(line.lstrip()[2:]) + else: + doc.para(line) + + doc_id = uuid.uuid4().hex + doc.output(get_pdf_path(doc_id)) + return doc_id + + +# ── Public API ───────────────────────────────────────────────────────────────── + +def generate_pdf(doc_type: str, text: str, metadata: dict | None = None) -> str: + """Generate a PDF and return its doc_id. Raises on failure.""" + meta = metadata or {} + try: + if doc_type == "cv": + return _cv_pdf(text) + elif doc_type == "cover_letter": + return _letter_pdf( + text, + name=meta.get("name", ""), + job_title=meta.get("job_title", ""), + company=meta.get("company", ""), + ) + else: + raise ValueError(f"Unknown doc_type: {doc_type!r}") + except Exception as exc: + logger.error("PDF generation failed (type=%s): %s", doc_type, exc, exc_info=True) + raise diff --git a/api/app/tracker.py b/api/app/tracker.py index 0e82d82..f6165d0 100644 --- a/api/app/tracker.py +++ b/api/app/tracker.py @@ -14,16 +14,17 @@ _DB_PATH: str = "" VALID_STATUSES = { - "Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded" + "Pending Review", "Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded" } _CREATE_USERS_TABLE = """ CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - persona TEXT DEFAULT '{}', - created_at TEXT NOT NULL + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + persona TEXT DEFAULT '{}', + onboarding_complete INTEGER DEFAULT 0, + created_at TEXT NOT NULL ); """ @@ -40,6 +41,7 @@ date_applied TEXT, cv_profile TEXT, cover_letter TEXT, + tailored_cv TEXT, composite_score REAL, exposure_score REAL, fit_score REAL, @@ -68,12 +70,22 @@ def init_db(db_path: str = "") -> None: con.execute("PRAGMA journal_mode=WAL") con.execute(_CREATE_USERS_TABLE) con.execute(_CREATE_TABLE) - con.execute(_CREATE_INDEX) - # Migration: add persona column if it doesn't exist + # Migrations — safe to run on existing DBs + for stmt in [ + "ALTER TABLE users ADD COLUMN persona TEXT DEFAULT '{}'", + "ALTER TABLE users ADD COLUMN onboarding_complete INTEGER DEFAULT 0", + "ALTER TABLE applications ADD COLUMN tailored_cv TEXT", + "ALTER TABLE applications ADD COLUMN user_id TEXT", + ]: + try: + con.execute(stmt) + except sqlite3.OperationalError: + pass + # Create index after migrations so user_id column is guaranteed to exist try: - con.execute("ALTER TABLE users ADD COLUMN persona TEXT DEFAULT '{}'") + con.execute(_CREATE_INDEX) except sqlite3.OperationalError: - pass # column already exists + pass con.commit() con.close() logger.info("Tracker DB initialised at %s", _DB_PATH) @@ -137,12 +149,27 @@ def update_user_persona(user_id: str, persona: dict) -> bool: return False +def get_onboarding_status(user_id: str) -> bool: + con = sqlite3.connect(_DB_PATH) + row = con.execute("SELECT onboarding_complete FROM users WHERE id=?", (user_id,)).fetchone() + con.close() + return bool(row and row[0]) + + +def set_onboarding_complete(user_id: str) -> None: + con = sqlite3.connect(_DB_PATH) + con.execute("UPDATE users SET onboarding_complete=1 WHERE id=?", (user_id,)) + con.commit() + con.close() + + def add_application( user_id: str, job: dict, cv_profile: dict, cover_letter: str, score_data: dict, + tailored_cv: str = "", ) -> str | None: """ Insert a new application row for a specific user. @@ -156,10 +183,10 @@ def add_application( con.execute( """INSERT INTO applications (id, user_id, job_id, job_title, company, location, job_url, - status, cv_profile, cover_letter, + status, cv_profile, cover_letter, tailored_cv, composite_score, exposure_score, fit_score, skill_gaps, level_match, created_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( app_id, user_id, @@ -168,9 +195,10 @@ def add_application( job.get("company", ""), job.get("location", ""), job.get("url", ""), - "Evaluated", + "Pending Review", json.dumps(cv_profile), cover_letter, + tailored_cv, score_data.get("composite_score"), score_data.get("exposure_score"), score_data.get("fit_score"), diff --git a/api/requirements.txt b/api/requirements.txt index 74f4a6e..d3f9825 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -16,3 +16,5 @@ langchain-ollama>=0.2 # Ollama LangChain integration (browser agent) # Keep groq + langchain-groq if switching back via LLM_BACKEND=groq groq>=0.9 langchain-groq>=0.2 +fpdf2>=2.7.9 +markdown>=3.5 diff --git a/frontend/src/app/app/page.tsx b/frontend/src/app/app/page.tsx index 8545f12..4057fe0 100644 --- a/frontend/src/app/app/page.tsx +++ b/frontend/src/app/app/page.tsx @@ -1,521 +1,14 @@ "use client"; -import { useState, useEffect, Suspense } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import AgentLog, { AgentEvent } from "@/components/AgentLog"; -import RiskRadar, { SkillRisk } from "@/components/RiskRadar"; -import JobList, { Job } from "@/components/JobList"; -import CoverLetter from "@/components/CoverLetter"; -import SkillRoadmap, { RoadmapItem } from "@/components/SkillRoadmap"; -import BrowserView from "@/components/BrowserView"; -import GuardrailBadge from "@/components/GuardrailBadge"; -import { authFetch } from "../api"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; -interface CVProfile { - name?: string; - email?: string; - phone?: string; - address?: string; - location?: string; - linkedin?: string; - github?: string; - skills?: string[]; - job_titles?: string[]; - experience_years?: number; - summary?: string; - education?: { institution: string; degree: string; dates: string }[]; - experience?: { employer: string; title: string; dates: string; summary: string }[]; -} - -type Phase = - | "idle" - | "uploading" - | "streaming" - | "awaiting_approval" - | "applying" - | "browser" - | "done"; - -function AppContent() { +export default function AppRedirect() { const router = useRouter(); - const searchParams = useSearchParams(); - const cvParam = searchParams.get("cv"); - - const [phase, setPhase] = useState("idle"); - const [events, setEvents] = useState([]); - const [cvProfile, setCvProfile] = useState(null); - const [skillRisks, setSkillRisks] = useState([]); - const [jobs, setJobs] = useState([]); - const [selectedJob, setSelectedJob] = useState(null); - const [coverLetter, setCoverLetter] = useState(""); - const [roadmap, setRoadmap] = useState([]); - const [appliedConfirmation, setAppliedConfirmation] = useState(""); - const [dragOver, setDragOver] = useState(false); - const [pendingCoverLetter, setPendingCoverLetter] = useState(""); - const [archetype, setArchetype] = useState(undefined); - const [sessionId, setSessionId] = useState(""); - const [lastGuardrailEvent, setLastGuardrailEvent] = useState<{ fired: boolean; check: string; detail?: string } | null>(null); - const [cvSessionId, setCvSessionId] = useState(""); - const [jobEmail, setJobEmail] = useState(""); - const [jobPassword, setJobPassword] = useState(""); - const [browserScreenshot, setBrowserScreenshot] = useState(null); - const [browserAction, setBrowserAction] = useState(""); - const [browserBlocked, setBrowserBlocked] = useState(false); - const [browserReason, setBrowserReason] = useState(null); - const [browserInteractive, setBrowserInteractive] = useState(false); - const [showBrowser, setShowBrowser] = useState(false); - - const pushEvent = (ev: AgentEvent) => setEvents((prev) => [...prev, ev]); - - const handleEvent = (ev: AgentEvent) => { - pushEvent(ev); - - if (ev.type === "tool_call" && ev.tool_input) { - const input = ev.tool_input as Record; - const profileData = (input.persona || input.cv_profile) as any; - if ( - (ev.tool_name === "score_job_fit" || ev.tool_name === "generate_cover_letter") && - profileData && - typeof profileData === "object" - ) { - // Map granular persona fields to the simpler CVProfile interface used for display - if (profileData.core_info) { - setCvProfile({ - name: `${profileData.core_info.first_name || ""} ${profileData.core_info.last_name || ""}`.trim(), - email: profileData.core_info.email, - location: profileData.core_info.city, - skills: profileData.skills?.flatMap((c: any) => c.skills || []) || [], - }); - } - else { - setCvProfile(profileData as CVProfile); - } - } - } - - if (ev.type === "guardrail") { - setLastGuardrailEvent({ fired: !!ev.fired, check: ev.check ?? "", detail: ev.detail }); - } - - if (ev.type === "start" && (ev as AgentEvent & { session_id?: string }).session_id) { - setSessionId((ev as AgentEvent & { session_id?: string }).session_id!); - } - - if (ev.type === "cv_session" && (ev as AgentEvent & { cv_session_id?: string }).cv_session_id) { - setCvSessionId((ev as AgentEvent & { cv_session_id?: string }).cv_session_id!); - } - - if (ev.type === "browser_action") { - setBrowserAction(ev.action ?? ""); - if (ev.screenshot) setBrowserScreenshot(ev.screenshot); - setBrowserBlocked(false); - setBrowserReason(null); - setBrowserInteractive(false); - setShowBrowser(true); - setPhase("browser"); - } - - if (ev.type === "browser_blocked") { - setBrowserAction(ev.action ?? ""); - if (ev.screenshot) setBrowserScreenshot(ev.screenshot); - setBrowserBlocked(true); - setBrowserReason(ev.reason ?? null); - setBrowserInteractive(!!(ev as AgentEvent & { interactive?: boolean }).interactive); - setShowBrowser(true); - setPhase("browser"); - } - - if (ev.type === "tool_call") { - setShowBrowser(false); - } - - if (ev.type === "tool_result" && ev.tool_name && ev.result) { - const result = ev.result as Record; - - if (ev.tool_name === "score_ai_risk" && result.skill_risks) { - setSkillRisks(result.skill_risks as SkillRisk[]); - } - - if (ev.tool_name === "score_job_fit" && result.ranked_jobs) { - const ranked = result.ranked_jobs as Job[]; - setJobs(ranked); - if (ranked.length > 0) setSelectedJob(ranked[0]); - } - - if (ev.tool_name === "generate_cover_letter" && result.cover_letter) { - setPendingCoverLetter(result.cover_letter as string); - if (result.archetype) setArchetype(result.archetype as string); - } - - if (ev.tool_name === "generate_skill_roadmap" && result.items) { - setRoadmap(result.items as RoadmapItem[]); - } - - if (ev.tool_name === "apply_to_job") { - const r = result as { message?: string }; - setAppliedConfirmation(r.message ?? "Application submitted!"); - } - } - - if (ev.type === "done") { - setPhase((prev) => { - if (prev === "streaming") return "awaiting_approval"; - return "done"; - }); - } - }; - - const startPipeline = async (cvSessionIdParam: string) => { - setPhase("streaming"); - setEvents([]); - setCvProfile(null); - setSkillRisks([]); - setJobs([]); - setSelectedJob(null); - setCoverLetter(""); - setPendingCoverLetter(""); - setRoadmap([]); - setAppliedConfirmation(""); - setCvSessionId(""); - setBrowserScreenshot(null); - setBrowserAction(""); - setBrowserBlocked(false); - setJobEmail(""); - setJobPassword(""); - - const form = new FormData(); - form.append("cv_session_id", cvSessionIdParam); - - let response: Response; - try { - response = await authFetch("/api/stream", { method: "POST", body: form }); - } catch (err) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: `Connection error: ${err}` }); - setPhase("idle"); - return; - } - - if (!response.body) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: "Server returned no response body." }); - setPhase("idle"); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (line.startsWith("data: ")) { - try { - const ev = JSON.parse(line.slice(6)) as AgentEvent; - handleEvent(ev); - } catch { - // Partial JSON — ignore - } - } - } - } - - setPendingCoverLetter((cl) => { if (cl) setCoverLetter(cl); return cl; }); - }; - - // On mount: check token and auto-start pipeline if cv param is present - useEffect(() => { - const token = localStorage.getItem("windrush_token"); - if (!token) { - router.push("/login"); - return; - } - if (cvParam) startPipeline(cvParam); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cvParam, router]); - - // In-app file upload (for re-runs without going back to landing) - const handleFileInput = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setPhase("uploading"); - try { - const form = new FormData(); - form.append("file", file); - const res = await authFetch("/api/upload", { method: "POST", body: form }); - if (!res.ok) throw new Error("Upload failed"); - const { cv_session_id } = await res.json() as { cv_session_id: string }; - await startPipeline(cv_session_id); - } catch (err) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: `Upload error: ${err}` }); - setPhase("idle"); - } - }; - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault(); - setDragOver(false); - const file = e.dataTransfer.files[0]; - if (!file || file.type !== "application/pdf") return; - setPhase("uploading"); - try { - const form = new FormData(); - form.append("file", file); - const res = await authFetch("/api/upload", { method: "POST", body: form }); - if (!res.ok) throw new Error("Upload failed"); - const { cv_session_id } = await res.json() as { cv_session_id: string }; - await startPipeline(cv_session_id); - } catch (err) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: `Upload error: ${err}` }); - setPhase("idle"); - } - }; - - const handleApprove = async () => { - if (!selectedJob) return; - const cl = coverLetter || pendingCoverLetter; - setPhase("applying"); - setCoverLetter(""); - - const form = new FormData(); - form.append("job_id", selectedJob.job_id); - form.append("job_url", selectedJob.url ?? ""); - form.append("cover_letter", cl); - form.append("cv_profile", JSON.stringify(cvProfile ?? {})); - form.append("skill_risks", JSON.stringify(skillRisks)); - form.append("job_email", jobEmail); - form.append("job_password", jobPassword); - form.append("cv_session_id", cvSessionId); - - try { - const response = await authFetch("/api/apply", { method: "POST", body: form }); - if (!response.body) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: "Apply returned no response body." }); - setPhase("done"); - return; - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (line.startsWith("data: ")) { - try { - const ev = JSON.parse(line.slice(6)) as AgentEvent; - handleEvent(ev); - } catch {} - } - } - } - } catch (err) { - pushEvent({ type: "text", timestamp: Date.now() / 1000, text: `Apply error: ${err}` }); - } finally { - setPhase("done"); - } - }; - - const isStreaming = phase === "streaming" || phase === "uploading" || phase === "applying"; - const showApproval = phase === "awaiting_approval" && (coverLetter || pendingCoverLetter) && selectedJob; - const isApplying = phase === "applying"; - - return ( -
- {/* Header */} -
-
-

router.push("/")} - > - Windrush -

-

AI-powered career transition navigator

-
-
- - My Applications → - - - Persona Profile - - - - {isStreaming && ( - - - Agent running - - )} - {phase === "done" && appliedConfirmation && ( - {appliedConfirmation} - )} -
-
- - {/* Body — 3-column layout */} -
- {/* Left column (1/3): upload + results */} -
- {/* Upload zone */} -
- -
- - {/* CV Profile */} - {cvProfile && ( -
-

Profile

- {cvProfile.name &&
{cvProfile.name}
} - {cvProfile.email &&
{cvProfile.email}
} - {cvProfile.location &&
{cvProfile.location}
} - {cvProfile.experience_years != null && ( -
{cvProfile.experience_years} yrs exp
- )} - {cvProfile.skills && cvProfile.skills.length > 0 && ( -
- {cvProfile.skills.slice(0, 8).map((s) => ( - - {s} - - ))} -
- )} -
- )} - - {/* Risk Radar */} - {skillRisks.length > 0 && ( -
- -
- )} - - {/* Job List */} - {jobs.length > 0 && ( -
- -
- )} - - {/* Skill Roadmap */} - {roadmap.length > 0 && ( -
- -
- )} - - {/* Empty state */} - {!cvProfile && !skillRisks.length && !jobs.length && phase === "idle" && ( -
- Upload a CV PDF to begin -
- )} -
- - {/* Right column (2/3): Agent Log */} -
-
-

Agent Log

- {events.length > 0 && ( - - )} -
-
- -
-
-
- - {/* Browser View */} - {showBrowser && ( - setBrowserBlocked(false)} - /> - )} - - {/* Cover Letter Approval Modal */} - {showApproval && ( - { setCoverLetter(""); setPendingCoverLetter(""); setPhase("done"); }} - applying={isApplying} - jobEmail={jobEmail} - jobPassword={jobPassword} - onJobEmailChange={setJobEmail} - onJobPasswordChange={setJobPassword} - /> - )} -
- ); -} - -export default function AppPage() { + useEffect(() => { router.replace("/jobs"); }, [router]); return ( - - - +
+ Redirecting… +
); } diff --git a/frontend/src/app/applications/page.tsx b/frontend/src/app/applications/page.tsx new file mode 100644 index 0000000..eb21126 --- /dev/null +++ b/frontend/src/app/applications/page.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { authFetch } from "../api"; + +interface Application { + id: string; + job_title: string; + company: string; + location: string; + job_url: string; + status: string; + date_applied: string | null; + composite_score: number | null; + exposure_score: number | null; + skill_gaps: string[]; + level_match: string; + notes: string; + created_at: string; +} + +const STATUSES = ["Pending Review", "Evaluated", "Applied", "Responded", "Interview", "Offer", "Rejected", "Discarded"]; + +const STATUS_STYLE: Record = { + "Pending Review": "bg-yellow-500/20 text-yellow-400 border-yellow-500/20", + Evaluated: "bg-zinc-700/50 text-zinc-400 border-zinc-700", + Applied: "bg-blue-500/20 text-blue-400 border-blue-500/20", + Responded: "bg-amber-500/20 text-amber-400 border-amber-500/20", + Interview: "bg-teal-500/20 text-teal-400 border-teal-500/20", + Offer: "bg-green-500/20 text-green-400 border-green-500/20", + Rejected: "bg-red-500/20 text-red-400 border-red-500/20", + Discarded: "bg-zinc-800 text-zinc-600 border-zinc-800", +}; + +export default function ApplicationsPage() { + const [apps, setApps] = useState([]); + const [filter, setFilter] = useState("all"); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState(null); + const [editingNotes, setEditingNotes] = useState>({}); + + const fetchApps = async (status?: string) => { + setLoading(true); + const url = status && status !== "all" ? `/api/applications?status=${status}` : "/api/applications"; + try { + const res = await authFetch(url); + if (res.ok) setApps(await res.json()); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchApps(); }, []); + + const handleFilterChange = (s: string) => { + setFilter(s); + fetchApps(s !== "all" ? s : undefined); + }; + + const handleStatusChange = async (id: string, newStatus: string) => { + await authFetch(`/api/applications/${id}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + fetchApps(filter !== "all" ? filter : undefined); + }; + + const saveNotes = async (id: string) => { + const app = apps.find((a) => a.id === id); + if (!app) return; + await authFetch(`/api/applications/${id}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: app.status, notes: editingNotes[id] ?? "" }), + }); + setApps((prev) => prev.map((a) => a.id === id ? { ...a, notes: editingNotes[id] ?? a.notes } : a)); + }; + + const toggleExpand = (id: string, notes: string) => { + if (expandedId === id) { + setExpandedId(null); + } else { + setExpandedId(id); + setEditingNotes((prev) => ({ ...prev, [id]: notes ?? "" })); + } + }; + + return ( +
+
+

+ Applications +

+

{apps.length} application{apps.length !== 1 ? "s" : ""} tracked

+
+ + {/* Filter tabs */} +
+ {["all", ...STATUSES].map((s) => ( + + ))} +
+ + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : apps.length === 0 ? ( +
+

No applications yet. Apply to jobs from the Job Feed.

+
+ ) : ( +
+ {apps.map((app) => ( +
+ {/* Row */} +
toggleExpand(app.id, app.notes)} + > +
+
+ {app.job_title} + · + {app.company} +
+
{app.date_applied ?? app.created_at?.slice(0, 10)}
+
+ +
+ {app.composite_score != null && ( + {Math.round(app.composite_score * 100)}% fit + )} + + {app.status} + + + ▾ +
+
+ + {/* Expanded row */} + {expandedId === app.id && ( +
+
+
+

Location

+

{app.location || "—"}

+
+
+

Level Match

+

{app.level_match || "—"}

+
+
+

AI Exposure

+

{app.exposure_score != null ? `${app.exposure_score}%` : "—"}

+
+ {app.job_url && ( +
+

Posting

+ + View ↗ + +
+ )} +
+ + {app.skill_gaps && app.skill_gaps.length > 0 && ( +
+

Skill Gaps

+
+ {app.skill_gaps.map((gap) => ( + + {gap} + + ))} +
+
+ )} + +
+

Notes

+