From b3a1f588efa616fad0793042826d37e424267e30 Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 30 Jul 2026 20:52:25 +0000 Subject: [PATCH] WB1: migration 004, cluster assignment, GET /clusters, tailor-cv endpoint, deadline integration, Dockerfile.test matching install --- apps/api/Dockerfile.test | 1 + apps/api/app/db/repo_app.py | 54 ++- apps/api/app/llm.py | 30 ++ apps/api/app/main.py | 380 +++++++++++++++++- apps/api/app/schemas.py | 38 +- apps/api/migrations/004_dedupe_deadline.sql | 4 + apps/web/src/api/index.ts | 36 ++ apps/web/src/components/CostDisplay.vue | 42 ++ apps/web/src/types/index.ts | 64 +++ .../views/ApplicationDetail.tailor.test.ts | 137 +++++++ apps/web/src/views/ApplicationDetail.vue | 110 ++++- apps/web/src/views/Research.clusters.test.ts | 103 +++++ apps/web/src/views/Research.vue | 202 +++++++--- .../web/src/views/TodayView.deadlines.test.ts | 79 ++++ .../src/views/TodayView.suggestions.test.ts | 125 ++++++ apps/web/src/views/TodayView.vue | 170 +++++++- 16 files changed, 1522 insertions(+), 53 deletions(-) create mode 100644 apps/api/migrations/004_dedupe_deadline.sql create mode 100644 apps/web/src/views/ApplicationDetail.tailor.test.ts create mode 100644 apps/web/src/views/Research.clusters.test.ts create mode 100644 apps/web/src/views/TodayView.deadlines.test.ts create mode 100644 apps/web/src/views/TodayView.suggestions.test.ts diff --git a/apps/api/Dockerfile.test b/apps/api/Dockerfile.test index b763aa4..71ac28d 100644 --- a/apps/api/Dockerfile.test +++ b/apps/api/Dockerfile.test @@ -20,6 +20,7 @@ WORKDIR /app/apps/api RUN pip install --no-cache-dir -e ".[dev]" \ && pip install --no-cache-dir -e /app/packages/llm-gateway \ && pip install --no-cache-dir -e /app/packages/artifacts \ + && pip install --no-cache-dir -e /app/packages/matching \ && pip install --no-cache-dir pypdf python-docx apscheduler CMD ["pytest", "-q"] \ No newline at end of file diff --git a/apps/api/app/db/repo_app.py b/apps/api/app/db/repo_app.py index 739bc81..2913014 100644 --- a/apps/api/app/db/repo_app.py +++ b/apps/api/app/db/repo_app.py @@ -55,6 +55,28 @@ def get_job_posting(posting_id: str) -> dict[str, Any] | None: return _normalize_posting(row) +def update_posting_cluster_id(posting_id: str, cluster_id: str) -> dict[str, Any] | None: + """Set the cluster_id on a job posting.""" + row = execute( + "UPDATE job_posting SET cluster_id = %s WHERE id = %s RETURNING *", + (cluster_id, posting_id), + ) + if row is None: + return None + return _normalize_posting(row) + + +def update_posting_apply_by(posting_id: str, apply_by: Any) -> dict[str, Any] | None: + """Set the apply_by date on a job posting.""" + row = execute( + "UPDATE job_posting SET apply_by = %s WHERE id = %s RETURNING *", + (apply_by, posting_id), + ) + if row is None: + return None + return _normalize_posting(row) + + def list_postings() -> list[dict[str, Any]]: rows = fetch_all("SELECT * FROM job_posting ORDER BY fetched_at DESC") return [_normalize_posting(r) for r in rows] @@ -71,6 +93,8 @@ def _normalize_posting(row: dict[str, Any]) -> dict[str, Any]: "location": row.get("location"), "description": row.get("description", ""), "fetched_at": row["fetched_at"].isoformat() if row.get("fetched_at") else None, + "cluster_id": row.get("cluster_id"), + "apply_by": row.get("apply_by").isoformat() if row.get("apply_by") else None, } @@ -482,4 +506,32 @@ def get_digest(limit: int = 20) -> list[dict[str, Any]]: """, (limit,), ) - return [_normalize_application(r) for r in rows] \ No newline at end of file + return [_normalize_application(r) for r in rows] + + +def get_upcoming_deadlines(days: int = 7) -> list[dict[str, Any]]: + """Return applications whose job_posting has apply_by within the next *days* days. + + Returns list of dicts: {application_id, title, company, apply_by}. + """ + rows = fetch_all( + """ + SELECT a.id AS application_id, j.title, j.company, j.apply_by + FROM application a + JOIN job_posting j ON a.job_posting_id = j.id + WHERE j.apply_by IS NOT NULL + AND j.apply_by >= CURRENT_DATE + AND j.apply_by <= CURRENT_DATE + %s * INTERVAL '1 day' + ORDER BY j.apply_by ASC + """, + (days,), + ) + result: list[dict[str, Any]] = [] + for row in rows: + result.append({ + "application_id": str(row["application_id"]), + "title": row["title"], + "company": row["company"], + "apply_by": row["apply_by"].isoformat() if row.get("apply_by") else None, + }) + return result \ No newline at end of file diff --git a/apps/api/app/llm.py b/apps/api/app/llm.py index 7b9bc84..e72689d 100644 --- a/apps/api/app/llm.py +++ b/apps/api/app/llm.py @@ -128,6 +128,36 @@ MOCK_OUTPUTS: dict[str, dict[str, Any]] = { "state_proposal": "interviewing", "reason": "The email mentions an interview invitation.", }, + "cv_tailor": { + "tailored_cv": { + "summary": "Senior Python Developer with 6+ years building scalable backend systems.", + "skills": [ + "Python", + "Fast API", + "PostgreSQL", + "Docker", + "Kubernetes", + "AWS", + ], + "experience": [ + { + "company": "TechCorp", + "role": "Senior Backend Engineer", + "bullets": [ + "Led migration of monolith to microservices using Fast API", + "Reduced API latency by 40% through query optimization and caching", + ], + }, + ], + }, + "change_log": [ + {"action": "reordered", "detail": "Moved Python and Fast API to top of skills"}, + {"action": "rephrased", "detail": "Rewrote first experience bullet to emphasize Fast API"}, + ], + }, + "deadline_extract": { + "apply_by": None, + }, } diff --git a/apps/api/app/main.py b/apps/api/app/main.py index b3ae3df..d7c7d92 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -27,6 +27,8 @@ from app.schemas import ( BatchScoreRequest, BatchScoreResponse, BatchScoreResult, + ClusterOut, + ClusterPostingOut, CoverLetterRequest, CoverLetterResponse, CvImportConfirmRequest, @@ -36,6 +38,7 @@ from app.schemas import ( CvSectionCreate, CvSectionOut, CvSectionUpdate, + DeadlineItem, DigestItem, EmailSuggestionOut, ErrorOut, @@ -52,6 +55,7 @@ from app.schemas import ( ProfileUpdate, ScoreResponse, SeedDemoResponse, + TailorCvResponse, TaskRunOut, TodayResponse, TransitionRequest, @@ -59,6 +63,20 @@ from app.schemas import ( from app.statemachine import TransitionContext, check_transition, InvalidTransition from app.transport import get_transport, reset_transport +# v1.1: matching package for cluster + coverage +try: + from matching import cluster as _cluster_postings, coverage as _keyword_coverage + HAS_MATCHING = True +except ImportError: + HAS_MATCHING = False + +# v1.1: artifacts package for PDF rendering +try: + from artifacts import render_cv_pdf as _render_cv_pdf, hash_bytes as _hash_bytes, next_version as _next_version + HAS_ARTIFACTS = True +except ImportError: + HAS_ARTIFACTS = False + # Defensive import for connectors (may not exist yet) try: from packages.connectors.arbetsformedlingen import ArbetsformedlingenConnector # type: ignore @@ -159,6 +177,71 @@ def ai_assist(section_id: str, body: AiAssistRequest) -> Any: # --- Job Postings --- +def _assign_cluster_id(new_posting_id: str) -> str | None: + """Run cluster() over the new posting + all existing postings. + + Reconcile for stability: if the new posting joins an existing cluster, + reuse the existing cluster_id. Otherwise assign a fresh cluster_id from + the clustering output. + + Returns the cluster_id assigned to the new posting (or None if matching + package is unavailable or no postings exist). + """ + if not HAS_MATCHING: + return None + + all_postings = repo_app.list_postings() + if not all_postings: + return None + + # Build posting dicts for cluster() -- needs id, employer, title, description + posting_dicts = [ + { + "id": p["id"], + "employer": p.get("company", ""), + "title": p.get("title", ""), + "description": p.get("description", ""), + } + for p in all_postings + ] + + clusters = _cluster_postings(posting_dicts) + + # Find which cluster the new posting ended up in + for cluster_id, posting_ids in clusters.items(): + if new_posting_id in posting_ids: + # Check if any existing posting in this cluster already has a cluster_id + existing_cluster_ids: set[str] = set() + for pid in posting_ids: + if pid == new_posting_id: + continue + for p in all_postings: + if p["id"] == pid and p.get("cluster_id"): + existing_cluster_ids.add(p["cluster_id"]) + + if existing_cluster_ids: + # Prefer existing cluster_id for stability + stable_id = sorted(existing_cluster_ids)[0] + else: + # New cluster: use the computed cluster_id + stable_id = cluster_id + + # Assign cluster_id to all postings in the cluster that don't have one yet + for pid in posting_ids: + # Check if this posting already has the cluster_id + p_row = None + for p in all_postings: + if p["id"] == pid: + p_row = p + break + if p_row and p_row.get("cluster_id") != stable_id: + repo_app.update_posting_cluster_id(pid, stable_id) + + return stable_id + + return None + + @app.post("/api/postings", response_model=ApplicationOut, status_code=201) def create_posting(body: JobPostingCreate) -> Any: """Create a job posting from a URL. For POC: manual_url source.""" @@ -177,6 +260,10 @@ def create_posting(body: JobPostingCreate) -> Any: description="", raw={"url": url}, ) + + # Cluster assignment + _assign_cluster_id(posting["id"]) + application = repo_app.create_application(posting["id"]) return application @@ -186,6 +273,60 @@ def get_postings() -> Any: return repo_app.list_postings() +@app.get("/api/clusters", response_model=list[ClusterOut]) +def get_clusters() -> Any: + """Return job postings grouped by cluster_id, sorted by best score desc. + + Each cluster includes postings with their id, title, company, source, url, score. + Clusters with no cluster_id are excluded. + """ + if not HAS_MATCHING: + return [] + + postings = repo_app.list_postings() + # Build a map of cluster_id -> list of postings + cluster_map: dict[str, list[dict[str, Any]]] = {} + for p in postings: + cid = p.get("cluster_id") + if not cid: + continue + cluster_map.setdefault(cid, []).append(p) + + # For each cluster, look up application scores + apps = repo_app.list_applications() + # Build posting_id -> score map + score_map: dict[str, float | None] = {} + for a in apps: + score_map[a["job_posting_id"]] = a.get("score") + + result: list[dict[str, Any]] = [] + for cid, cluster_postings in cluster_map.items(): + cluster_postings_out: list[dict[str, Any]] = [] + best_score: float | None = None + for p in cluster_postings: + score = score_map.get(p["id"]) + cluster_postings_out.append({ + "id": p["id"], + "title": p.get("title", ""), + "company": p.get("company", ""), + "source": p.get("source", ""), + "url": p.get("url", ""), + "score": score, + }) + if score is not None: + if best_score is None or score > best_score: + best_score = score + result.append({ + "cluster_id": cid, + "postings": cluster_postings_out, + "_best_score": best_score, + }) + + # Sort by best score descending + result.sort(key=lambda c: (-(c.pop("_best_score") or -1))) + return result + + @app.post("/api/postings/{posting_id}/score", response_model=ScoreResponse) def score_posting(posting_id: str) -> Any: """Score a job posting against the profile.""" @@ -217,6 +358,32 @@ def score_posting(posting_id: str) -> Any: rationale = result.get("rationale", {}) repo_app.update_application_score(app_for_posting["id"], score, rationale) + + # Deadline extraction (CHEAP task) + deadline_result = llm.run_task( + "deadline_extract", + f"Extract apply-by deadline from: {posting['title']} at {posting['company']}. Description: {posting.get('description', '')}", + telemetry_sink=lambda info: repo_app.create_task_run({ + **info, + "application_id": app_for_posting["id"], + }), + ) + apply_by = deadline_result.get("apply_by") + if apply_by is not None: + # Parse and persist + try: + from datetime import date as date_cls + if isinstance(apply_by, str): + parsed_date = date_cls.fromisoformat(apply_by) + elif isinstance(apply_by, date_cls): + parsed_date = apply_by + else: + parsed_date = None + if parsed_date is not None: + repo_app.update_posting_apply_by(posting["id"], parsed_date) + except (ValueError, TypeError): + pass # Skip invalid date + return {"score": score, "rationale": rationale} @@ -654,6 +821,8 @@ def _fetch_and_create_postings(query: str, region: str | None = None) -> dict[st raw=rp.get("raw", {}), ) repo_app.create_application(posting["id"]) + # Cluster assignment + _assign_cluster_id(posting["id"]) new_count += 1 return {"new": new_count, "dupes": dupe_count} @@ -713,6 +882,31 @@ def _batch_score_internal(application_ids: list[str]) -> list[dict[str, Any]]: red_flags = result.get("red_flags", []) repo_app.update_application_score(app_id, score, rationale) + + # Deadline extraction (CHEAP task) + deadline_result = llm.run_task( + "deadline_extract", + f"Extract apply-by deadline from: {posting['title']} at {posting['company']}. Description: {posting.get('description', '')}", + telemetry_sink=lambda info, aid=app_id: repo_app.create_task_run({ + **info, + "application_id": aid, + }), + ) + apply_by = deadline_result.get("apply_by") + if apply_by is not None: + try: + from datetime import date as date_cls + if isinstance(apply_by, str): + parsed_date = date_cls.fromisoformat(apply_by) + elif isinstance(apply_by, date_cls): + parsed_date = apply_by + else: + parsed_date = None + if parsed_date is not None: + repo_app.update_posting_apply_by(posting["id"], parsed_date) + except (ValueError, TypeError): + pass # Skip invalid date + results.append({ "application_id": app_id, "score": score, @@ -763,10 +957,23 @@ def get_today() -> Any: # Pending approvals count pending = repo_app.count_pending_approvals() + # Deadlines: apply_by within next 7 days + deadline_rows = repo_app.get_upcoming_deadlines(days=7) + deadlines = [ + DeadlineItem( + application_id=d["application_id"], + title=d["title"], + company=d["company"], + apply_by=d.get("apply_by"), + ) + for d in deadline_rows + ] + return { "digest": digest, "nudges": nudges, "pending_approvals": pending, + "deadlines": deadlines, } @@ -1039,4 +1246,175 @@ def dismiss_suggestion(suggestion_id: str) -> Any: def get_notification_log() -> Any: """Return last 50 notification log entries.""" from app.notify import list_notification_log - return list_notification_log(limit=50) \ No newline at end of file + return list_notification_log(limit=50) + + +# --- v1.1: Tailor CV --- + +@app.post("/api/applications/{app_id}/tailor-cv", response_model=TailorCvResponse) +def tailor_cv(app_id: str) -> Any: + """Tailor the user's CV for the posting linked to this application. + + Uses cv_tailor (STRONG task) to generate a tailored CV variant. + Validates the output: every tailored bullet must map to a source bullet + id from the input (hallucination guard). Rejects + 502 on unmapped bullets. + Stores artifact kind='cv' origin='ai_drafted' + renders PDF via packages/artifacts. + Returns {artifact_id, change_log, keyword_coverage}. + """ + app_row = repo_app.get_application(app_id) + if app_row is None: + raise HTTPException(status_code=404, detail="Application not found") + + posting = repo_app.get_job_posting(app_row["job_posting_id"]) + if posting is None: + raise HTTPException(status_code=404, detail="Posting not found") + + # Build profile + sections from the profile repo + profile = repo_profile.get_or_create_profile() + if profile is None: + raise HTTPException(status_code=500, detail="No profile found") + sections = repo_profile.list_sections() + + # Build the prompt with profile + sections + posting description + import json as _json + prompt_parts = [ + f"Profile: {_json.dumps({k: profile.get(k) for k in ('full_name', 'headline', 'email', 'phone', 'location', 'summary')}, default=str)}", + f"Sections: {_json.dumps(sections, default=str)}", + f"Posting: title={posting.get('title', '')}, company={posting.get('company', '')}, description={posting.get('description', '')}", + ] + prompt = "\n".join(prompt_parts) + + # Run cv_tailor (STRONG task) + result = llm.run_task( + "cv_tailor", + prompt, + telemetry_sink=lambda info: repo_app.create_task_run({ + **info, + "application_id": app_id, + }), + ) + + tailored_cv = result.get("tailored_cv", {}) + change_log = result.get("change_log", []) + + # Hallucination guard: every tailored bullet must map to a source bullet id. + # We collect all source bullet texts from sections and check that each + # bullet in the tailored output is a rephrase/reorder of an existing one. + source_bullets: set[str] = set() + for s in sections: + for b in s.get("bullets", []): + source_bullets.add(str(b).lower().strip()) + + # Check bullets in tailored experience sections + unmapped_bullets: list[str] = [] + for exp in tailored_cv.get("experience", []): + for bullet in exp.get("bullets", []): + bullet_lower = str(bullet).lower().strip() + # Check if this bullet is a rephrase of any source bullet. + # We use a simple containment check: at least 50% of the words + # in the tailored bullet should appear in some source bullet. + bullet_words = set(bullet_lower.split()) + if not bullet_words: + continue + found = False + for sb in source_bullets: + sb_words = set(sb.split()) + if not sb_words: + continue + overlap = len(bullet_words & sb_words) / len(bullet_words) + if overlap >= 0.5: + found = True + break + if not found and source_bullets: + unmapped_bullets.append(bullet) + + if unmapped_bullets: + raise HTTPException( + status_code=502, + detail={ + "code": "hallucination_guard", + "message": f"Tailored CV contains bullets that do not map to source bullets: {unmapped_bullets}", + }, + ) + + # Build sections for PDF rendering + pdf_sections: list[dict[str, Any]] = [] + # Add experience sections from tailored CV + for exp in tailored_cv.get("experience", []): + pdf_sections.append({ + "kind": "experience", + "title": exp.get("role", ""), + "org": exp.get("company", ""), + "bullets": exp.get("bullets", []), + }) + # Add skills section + if tailored_cv.get("skills"): + pdf_sections.append({ + "kind": "skills", + "title": "Technical Skills", + "bullets": tailored_cv.get("skills", []), + }) + # If no experience sections from tailor, fall back to original sections + if not pdf_sections: + pdf_sections = sections + + # Render PDF + if not HAS_ARTIFACTS: + raise HTTPException( + status_code=500, + detail={"code": "missing_dependency", "message": "artifacts package not installed"}, + ) + + pdf_profile = { + "full_name": profile.get("full_name", ""), + "headline": profile.get("headline", ""), + "email": profile.get("email", ""), + "phone": profile.get("phone", ""), + "location": profile.get("location", ""), + "summary": tailored_cv.get("summary", profile.get("summary", "")), + } + + pdf_bytes = _render_cv_pdf(pdf_profile, pdf_sections) + + # Store artifact + storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts") + os.makedirs(storage_dir, exist_ok=True) + filename = f"cv_tailored_{app_id[:8]}.pdf" + storage_path = os.path.join(storage_dir, filename) + with open(storage_path, "wb") as f: + f.write(pdf_bytes) + + # Get existing CV artifacts for version numbering + existing_artifacts = repo_app.list_artifacts(app_id) + cv_versions = [a["version"] for a in existing_artifacts if a["kind"] == "cv"] + version = _next_version(cv_versions) + + artifact = repo_app.create_artifact( + application_id=app_id, + kind="cv", + filename=filename, + content_bytes=pdf_bytes, + storage_path=storage_path, + origin="ai_drafted", + version=version, + ) + + # Compute keyword coverage + # Build CV text from the tailored sections + cv_text_parts = [ + pdf_profile.get("summary", ""), + ] + for s in pdf_sections: + cv_text_parts.append(s.get("title", "")) + cv_text_parts.append(s.get("org", "")) + cv_text_parts.extend(s.get("bullets", [])) + cv_text = " ".join(str(p) for p in cv_text_parts) + posting_description = posting.get("description", "") or f"{posting.get('title', '')} at {posting.get('company', '')}" + + keyword_coverage = _keyword_coverage(cv_text, posting_description) if HAS_MATCHING else {"matched": [], "missing": [], "ratio": 0.0} + + return { + "artifact_id": artifact["id"], + "change_log": change_log, + "keyword_coverage": keyword_coverage, + } \ No newline at end of file diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 30acad0..47725c8 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -108,6 +108,8 @@ class JobPostingOut(BaseModel): location: str | None = None description: str = "" fetched_at: str | None = None + cluster_id: str | None = None + apply_by: str | None = None class ScoreResponse(BaseModel): @@ -304,6 +306,7 @@ class TodayResponse(BaseModel): digest: list[DigestItem] nudges: list[NudgeItem] pending_approvals: int + deadlines: list[DeadlineItem] = [] # --- v1: Interview Prep --- @@ -346,4 +349,37 @@ class NotificationLogOut(BaseModel): payload: dict[str, Any] = {} delivered: bool error: str | None = None - created_at: str | None = None \ No newline at end of file + created_at: str | None = None + + +# --- v1.1: Clusters --- + +class ClusterPostingOut(BaseModel): + id: str + title: str + company: str + source: str + url: str + score: float | None = None + + +class ClusterOut(BaseModel): + cluster_id: str + postings: list[ClusterPostingOut] = [] + + +# --- v1.1: Tailor CV --- + +class TailorCvResponse(BaseModel): + artifact_id: str + change_log: list[dict[str, Any]] + keyword_coverage: dict[str, Any] + + +# --- v1.1: Deadlines --- + +class DeadlineItem(BaseModel): + application_id: str + title: str + company: str + apply_by: str | None = None \ No newline at end of file diff --git a/apps/api/migrations/004_dedupe_deadline.sql b/apps/api/migrations/004_dedupe_deadline.sql new file mode 100644 index 0000000..0da4965 --- /dev/null +++ b/apps/api/migrations/004_dedupe_deadline.sql @@ -0,0 +1,4 @@ +-- 004_dedupe_deadline.sql -- cluster_id for dedupe + apply_by deadline + +ALTER TABLE job_posting ADD COLUMN IF NOT EXISTS cluster_id text; +ALTER TABLE job_posting ADD COLUMN IF NOT EXISTS apply_by date; \ No newline at end of file diff --git a/apps/web/src/api/index.ts b/apps/web/src/api/index.ts index fb534e6..d3a62f7 100644 --- a/apps/web/src/api/index.ts +++ b/apps/web/src/api/index.ts @@ -7,18 +7,22 @@ import type { Approval, Artifact, BatchScoringResponse, + Cluster, CoverLetterResponse, CritiqueComment, CvImportConfirmResponse, CvImportResponse, CvSection, DemoSeedResponse, + EmailSuggestion, InterviewPrepResponse, JobPosting, + NotificationLogEntry, PostingsFetchResponse, Profile, RenderCvResponse, ScoreResponse, + TailorCvResponse, TaskRun, TodayResponse } from '@/types' @@ -214,6 +218,34 @@ export function seedDemo(): Promise { return request('/concierge/seed-demo', { method: 'POST' }) } +// --- v1.1 additions (wave A/B) --- + +export function getSuggestions(): Promise { + return request('/suggestions') +} + +export function acceptSuggestion(id: string): Promise { + return request(`/suggestions/${id}/accept`, { method: 'POST' }) +} + +export function dismissSuggestion(id: string): Promise { + return request(`/suggestions/${id}/dismiss`, { method: 'POST' }) +} + +export function getNotificationLog(): Promise { + return request('/notifications/log') +} + +export function getClusters(): Promise { + return request('/clusters') +} + +export function tailorCv(applicationId: string): Promise { + return request(`/applications/${applicationId}/tailor-cv`, { + method: 'POST' + }) +} + // Re-export types for convenience export type { AiAssistResponse, @@ -221,18 +253,22 @@ export type { Approval, Artifact, BatchScoringResponse, + Cluster, CoverLetterResponse, CritiqueComment, CvImportConfirmResponse, CvImportResponse, CvSection, DemoSeedResponse, + EmailSuggestion, InterviewPrepResponse, JobPosting, + NotificationLogEntry, PostingsFetchResponse, Profile, RenderCvResponse, ScoreResponse, + TailorCvResponse, TaskRun, TodayResponse } \ No newline at end of file diff --git a/apps/web/src/components/CostDisplay.vue b/apps/web/src/components/CostDisplay.vue index e30ec5a..a4ea5f4 100644 --- a/apps/web/src/components/CostDisplay.vue +++ b/apps/web/src/components/CostDisplay.vue @@ -18,6 +18,23 @@ const totalCost = computed(() => ) const hasCost = computed(() => tasks.value.some((t) => t.cost != null)) +// Group task runs by model name (proxy for provider) and compute totals per group +const byProvider = computed(() => { + const map = new Map() + for (const t of tasks.value) { + const key = t.model || 'unknown' + if (!map.has(key)) { + map.set(key, { model: key, tokensIn: 0, tokensOut: 0, cost: 0, count: 0 }) + } + const entry = map.get(key)! + entry.tokensIn += t.tokens_in ?? 0 + entry.tokensOut += t.tokens_out ?? 0 + entry.cost += t.cost ?? 0 + entry.count += 1 + } + return Array.from(map.values()).sort((a, b) => b.cost - a.cost) +}) + async function loadTasks() { try { tasks.value = await api.getTelemetryTasks() @@ -50,6 +67,31 @@ onMounted(loadTasks) {{ totalCost.toFixed(4) }}
{{ tasks.length }} task runs
+ + +
+
By Provider
+ + + + + + + + + + + + + + + + + + + +
ModelInOutCostRuns
{{ p.model }}{{ p.tokensIn.toLocaleString() }}{{ p.tokensOut.toLocaleString() }}{{ p.cost.toFixed(4) }}{{ p.count }}
+
\ No newline at end of file diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index de93125..97cbe20 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -208,4 +208,68 @@ export interface TaskRun { export interface RedFlagsMap { [applicationId: string]: string[] +} + +// --- v1.1 additions (wave A/B) --- + +export interface TodayDeadline { + application_id: string + title: string + company: string + apply_by: string +} + +export interface TodayResponseV11 extends TodayResponse { + deadlines?: TodayDeadline[] +} + +export type SuggestionClassification = + | 'interview_invite' + | 'rejection' + | 'question' + | 'noise' + +export interface EmailSuggestion { + id: string + application_id: string | null + from_address: string + subject: string + snippet: string + classification: SuggestionClassification + created_at: string + status: 'pending' | 'accepted' | 'dismissed' +} + +export interface NotificationLogEntry { + id: string + channel: string + message: string + data: Record | null + created_at: string +} + +export interface ClusterPosting { + id: string + title: string + company: string + source: string + url: string + score: number +} + +export interface Cluster { + cluster_id: string + postings: ClusterPosting[] +} + +export interface TailorChangeLogEntry { + section: string + change: string + bullet_id: string +} + +export interface TailorCvResponse { + artifact_id: string + change_log: TailorChangeLogEntry[] + keyword_coverage: number } \ No newline at end of file diff --git a/apps/web/src/views/ApplicationDetail.tailor.test.ts b/apps/web/src/views/ApplicationDetail.tailor.test.ts new file mode 100644 index 0000000..04ae89b --- /dev/null +++ b/apps/web/src/views/ApplicationDetail.tailor.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import type { Application, Artifact, TailorCvResponse } from '@/types' + +vi.mock('@/api', () => ({ + getApplications: vi.fn(), + getArtifacts: vi.fn(), + createCoverLetter: vi.fn(), + createApproval: vi.fn(), + confirmApproval: vi.fn(), + rejectApproval: vi.fn(), + outboxSend: vi.fn(), + interviewPrep: vi.fn(), + tailorCv: vi.fn(), + HttpError: class HttpError extends Error { + status: number + body: unknown + constructor(status: number, body: unknown, msg?: string) { + super(msg ?? `HTTP ${status}`) + this.status = status + this.body = body + } + } +})) + +function makeApp(): Application { + return { + id: 'app-1', + job_posting_id: 'j-1', + state: 'drafting', + score: 90, + score_rationale: null, + notes: '', + state_changed_at: '2026-01-01T00:00:00Z', + created_at: '2026-01-01T00:00:00Z', + posting: { + id: 'j-1', source: 'manual_url', external_id: null, url: 'http://x', + company: 'Acme', title: 'Engineer', location: 'Remote', description: '', + raw: {}, fetched_at: '2026-01-01T00:00:00Z' + } + } +} + +function makeArtifact(): Artifact { + return { + id: 'art-1', + application_id: 'app-1', + kind: 'cover_letter', + filename: 'cover.pdf', + content_hash: 'abcdef0123456789', + storage_path: '/tmp/cover.pdf', + version: 1, + origin: 'user_drafted', + created_at: '2026-01-01T00:00:00Z' + } +} + +function makeTailorResult(): TailorCvResponse { + return { + artifact_id: 'art-tailor-1', + change_log: [ + { section: 'experience', change: 'Reordered to highlight Python backend work', bullet_id: 'b-1' }, + { section: 'skills', change: 'Moved Docker and Kubernetes higher', bullet_id: 'b-5' } + ], + keyword_coverage: 0.75 + } +} + +async function mountDetail(app: Application, artifacts: Artifact[]) { + setActivePinia(createPinia()) + const api = await import('@/api') + ;(api.getApplications as ReturnType).mockResolvedValue([app]) + ;(api.getArtifacts as ReturnType).mockResolvedValue(artifacts) + const ApplicationDetail = (await import('@/views/ApplicationDetail.vue')).default + const wrapper = mount(ApplicationDetail, { props: { id: 'app-1' } }) + await flushPromises() + return { wrapper } +} + +describe('Tailor CV panel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders change log, coverage bar, and download link after tailoring', async () => { + const { wrapper } = await mountDetail(makeApp(), [makeArtifact()]) + const api = await import('@/api') + + // Panel should not be visible before clicking + expect(wrapper.find('[data-testid="tailor-panel"]').exists()).toBe(false) + + // Mock tailorCv to return a result + ;(api.tailorCv as ReturnType).mockResolvedValue(makeTailorResult()) + // After tailoring, getArtifacts is called again to refresh + ;(api.getArtifacts as ReturnType).mockResolvedValue([makeArtifact(), { + id: 'art-tailor-1', + application_id: 'app-1', + kind: 'cv', + filename: 'tailored_cv.pdf', + content_hash: 'deadbeef01234567', + storage_path: '/tmp/tailored_cv.pdf', + version: 1, + origin: 'ai_drafted', + created_at: '2026-07-30T00:00:00Z' + }]) + + // Click the Tailor CV button + const btn = wrapper.find('[data-testid="tailor-cv-btn"]') + expect(btn.exists()).toBe(true) + await btn.trigger('click') + await flushPromises() + + // Panel should now be visible + const panel = wrapper.find('[data-testid="tailor-panel"]') + expect(panel.exists()).toBe(true) + + // Change log entries should be visible + expect(panel.text()).toContain('Reordered to highlight Python backend work') + expect(panel.text()).toContain('Moved Docker and Kubernetes higher') + + // Coverage bar should be present with 75% + expect(panel.text()).toContain('Keyword Coverage') + expect(panel.text()).toContain('75%') + const bar = panel.find('[data-testid="coverage-bar"]') + expect(bar.exists()).toBe(true) + expect(bar.attributes('style')).toContain('width: 75%') + + // Download link should be present + const dl = panel.find('[data-testid="download-link"]') + expect(dl.exists()).toBe(true) + expect(dl.text()).toContain('Download tailored CV') + + // Tailored artifact should appear in artifacts list + expect(wrapper.text()).toContain('tailored_cv.pdf') + }) +}) \ No newline at end of file diff --git a/apps/web/src/views/ApplicationDetail.vue b/apps/web/src/views/ApplicationDetail.vue index bf0580b..bbb4be4 100644 --- a/apps/web/src/views/ApplicationDetail.vue +++ b/apps/web/src/views/ApplicationDetail.vue @@ -10,7 +10,8 @@ import type { Approval, ApprovalAction, CoverLetterResponse, - CritiqueComment + CritiqueComment, + TailorCvResponse } from '@/types' const props = defineProps<{ id: string }>() @@ -36,6 +37,10 @@ const sending = ref(false) // Interview prep modal const showPrepModal = ref(false) +// Tailor CV +const tailoring = ref(false) +const tailorResult = ref(null) + const isConfirmed = computed(() => approval.value?.confirmed_by_user ?? false) const canSend = computed(() => isConfirmed.value && !sending.value) @@ -47,6 +52,24 @@ const severityClass: Record = { low: 'bg-blue-50 border-blue-200' } +const coverageColor = computed(() => { + if (!tailorResult.value) return 'bg-gray-300' + const c = tailorResult.value.keyword_coverage + if (c >= 0.7) return 'bg-green-500' + if (c >= 0.4) return 'bg-yellow-500' + return 'bg-red-500' +}) + +const coveragePercent = computed(() => { + if (!tailorResult.value) return 0 + return Math.round(tailorResult.value.keyword_coverage * 100) +}) + +const downloadUrl = computed(() => { + if (!tailorResult.value) return '' + return `${import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api'}/artifacts/${tailorResult.value.artifact_id}/download` +}) + async function loadData() { try { const apps = await api.getApplications() @@ -139,6 +162,27 @@ function closeInterviewPrep() { showPrepModal.value = false } +async function tailorCv() { + tailoring.value = true + tailorResult.value = null + try { + const res = await api.tailorCv(props.id) + tailorResult.value = res + // Refresh artifacts to show the new tailored CV variant + artifacts.value = await api.getArtifacts(props.id) + toast.push('CV tailored for this job', 'success') + } catch (err) { + let msg = 'Failed to tailor CV' + if (err instanceof HttpError) { + const body = err.body as { error?: { message?: string } } | null + msg = body?.error?.message ?? msg + } + toast.push(msg, 'error') + } finally { + tailoring.value = false + } +} + onMounted(loadData) @@ -159,6 +203,69 @@ onMounted(loadData) + +
+

Tailor CV for this Job

+

+ Generate a tailored CV variant that reorders and rephrases your existing sections toward this posting's keywords. Your facts are never invented, only rephrased. +

+ + + +
+ +
+
+ Keyword Coverage + {{ coveragePercent }}% +
+
+
+
+
+ + +
+

Changes Made

+
    +
  • + {{ entry.section }}: + {{ entry.change }} +
  • +
+
+ + + +
+
+

Interview Prep

@@ -184,7 +291,6 @@ onMounted(loadData)

No artifacts yet.

-

Cover Letter

diff --git a/apps/web/src/views/Research.clusters.test.ts b/apps/web/src/views/Research.clusters.test.ts new file mode 100644 index 0000000..ceb7180 --- /dev/null +++ b/apps/web/src/views/Research.clusters.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('@/api', () => ({ + getPostings: vi.fn(), + getClusters: vi.fn(), + createPosting: vi.fn(), + fetchPostings: vi.fn(), + scorePosting: vi.fn(), + batchScore: vi.fn().mockResolvedValue({ results: [] }), + HttpError: class HttpError extends Error { + status: number + body: unknown + constructor(status: number, body: unknown, msg?: string) { + super(msg ?? `HTTP ${status}`) + this.status = status + this.body = body + } + } +})) + +describe('Research cluster alternates', () => { + it('renders cluster header with alternate count and expands to show alternates', async () => { + setActivePinia(createPinia()) + const api = await import('@/api') + const Research = (await import('@/views/Research.vue')).default + + // Two postings in the same cluster + const postings = [ + { + id: 'p-1', source: 'manual_url', external_id: null, url: 'http://a', + company: 'Acme', title: 'Backend Dev', location: 'Malmo', description: '', + raw: {}, fetched_at: '2026-07-30T00:00:00Z', cluster_id: 'c-1' + }, + { + id: 'p-2', source: 'linkedin', external_id: null, url: 'http://b', + company: 'Acme', title: 'Backend Dev', location: 'Remote', description: '', + raw: {}, fetched_at: '2026-07-30T00:00:00Z', cluster_id: 'c-1' + } + ] + + // Clusters endpoint returns the cluster with alternate postings + const clusters = [ + { + cluster_id: 'c-1', + postings: [ + { id: 'p-1', title: 'Backend Dev', company: 'Acme', source: 'manual_url', url: 'http://a', score: 85 }, + { id: 'p-2', title: 'Backend Dev', company: 'Acme', source: 'linkedin', url: 'http://b', score: 80 } + ] + } + ] + + ;(api.getPostings as ReturnType).mockResolvedValue(postings) + ;(api.getClusters as ReturnType).mockResolvedValue(clusters) + + const wrapper = mount(Research) + await flushPromises() + + // Cluster header should show "Also via 1 more" + expect(wrapper.text()).toContain('Also via 1 more') + + // Alternates should NOT be visible before expanding + const alternatesBefore = wrapper.find('[data-testid="cluster-alternates"]') + expect(alternatesBefore.exists()).toBe(false) + + // Click to expand + const toggle = wrapper.find('[data-testid="cluster-alternates-toggle"]') + expect(toggle.exists()).toBe(true) + await toggle.trigger('click') + await flushPromises() + + // Alternates should now be visible + const alternatesAfter = wrapper.find('[data-testid="cluster-alternates"]') + expect(alternatesAfter.exists()).toBe(true) + // Should show the alternate source (linkedin) and link + expect(alternatesAfter.text()).toContain('linkedin') + }) + + it('does not show cluster header for single postings without alternates', async () => { + setActivePinia(createPinia()) + const api = await import('@/api') + const Research = (await import('@/views/Research.vue')).default + + const postings = [ + { + id: 'p-3', source: 'manual_url', external_id: null, url: 'http://c', + company: 'Globex', title: 'Manager', location: 'Stockholm', description: '', + raw: {}, fetched_at: '2026-07-30T00:00:00Z' + } + ] + + ;(api.getPostings as ReturnType).mockResolvedValue(postings) + ;(api.getClusters as ReturnType).mockResolvedValue([]) + + const wrapper = mount(Research) + await flushPromises() + + // Should not show "Also via" since no alternates + expect(wrapper.text()).not.toContain('Also via') + expect(wrapper.text()).toContain('Globex') + }) +}) \ No newline at end of file diff --git a/apps/web/src/views/Research.vue b/apps/web/src/views/Research.vue index cf40d6a..0aa60b5 100644 --- a/apps/web/src/views/Research.vue +++ b/apps/web/src/views/Research.vue @@ -1,18 +1,20 @@