From e820b4be5c0347cf1480d85c9855df60482c9769 Mon Sep 17 00:00:00 2001 From: Davyd Holovii Date: Thu, 19 Mar 2026 13:03:45 -0400 Subject: [PATCH 01/14] feat: add resume scoring feature with LiteLLM integration and TinyDB cache - Add scores table to TinyDB (create_score / get_score by resume+job pair) - Add ScoreRequest / ScoreResult Pydantic schemas (app/schemas/scoring.py) - Add scoring service (app/services/scorer.py): 7-criterion parallel LLM evaluation ported from scorer.py, using app.llm instead of direct SDK clients; PDF parsing and visual quality scoring dropped (resumes already structured in DB); no personal data logged - Add scoring router (app/routers/scoring.py): POST /api/v1/scores and GET /api/v1/scores/{resume_id}/{job_id} with cache-first lookup - Register scoring router in main.py - 24 new tests covering DB layer, schemas, service logic, and router (37 total passing) Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP-scoring.md | 139 +++++++ apps/backend/app/database.py | 40 ++ apps/backend/app/main.py | 3 +- apps/backend/app/routers/__init__.py | 2 + apps/backend/app/routers/scoring.py | 36 ++ apps/backend/app/schemas/scoring.py | 28 ++ apps/backend/app/services/scorer.py | 381 ++++++++++++++++++ apps/backend/tests/test_scoring.py | 344 ++++++++++++++++ .../plans/2026-03-19-resume-scoring.md | 280 +++++++++++++ 9 files changed, 1252 insertions(+), 1 deletion(-) create mode 100644 ROADMAP-scoring.md create mode 100644 apps/backend/app/routers/scoring.py create mode 100644 apps/backend/app/schemas/scoring.py create mode 100644 apps/backend/app/services/scorer.py create mode 100644 apps/backend/tests/test_scoring.py create mode 100644 docs/superpowers/plans/2026-03-19-resume-scoring.md diff --git a/ROADMAP-scoring.md b/ROADMAP-scoring.md new file mode 100644 index 000000000..14a2276b4 --- /dev/null +++ b/ROADMAP-scoring.md @@ -0,0 +1,139 @@ +# Resume Scoring Feature — Roadmap + +## Goal + +Add a resume-vs-job scoring endpoint to Resume Matcher by adapting the logic from `scorer.py`, replacing its direct OpenAI/Anthropic SDK calls with the project's existing LiteLLM wrapper (`app.llm`), and caching results in TinyDB. + +--- + +## Source Script Analysis (`scorer.py`) + +| Component | What it does | Adaptation needed | +|-----------|-------------|-------------------| +| `_talk_to_ai` / `_talk_fast` | Direct OpenAI/Anthropic SDK calls | **Replace** with `llm.complete()` and `llm.complete_json()` | +| `extract_text_and_image_from_pdf` | pytesseract + pdf2image | **Drop** — resumes already exist as parsed markdown/JSON in DB | +| `_unify_resume` | Normalize raw text to structured markdown | Simplify — resume text already structured in `processed_data` | +| `extract_job_requirements` | Parse JD into weighted requirements JSON | Keep, use `llm.complete_json()` | +| `_compute_ai_match` | Score 7 criteria with weights | Keep, use `llm.complete()` per criterion | +| `assess_resume_quality` | Vision-based quality score from PDF image | **Drop** — no image pipeline in Resume Matcher | +| `get_score_details` | Map int → (emoji, color, label) | Keep as-is | +| Final score formula | `ai_score * 0.75 + quality_score * 0.25` | Simplify to `ai_score` only (no quality score) | + +--- + +## Implementation Steps + +### Step 1 — Database: add `scores` table + +- Add `scores` property to `app/database.py` → `db.table("scores")` +- Add `create_score(resume_id, job_id, result)` and `get_score(resume_id, job_id)` methods +- Schema: `{ score_id, resume_id, job_id, score, ai_score, match_reasons, red_flags, website, label, emoji, color, created_at }` + +### Step 2 — Scoring service (`app/services/scorer.py`) + +Port the pure-logic functions from `scorer.py`, replacing the AI layer: + +``` +extract_job_requirements(job_desc: str) -> dict | None + Uses: llm.complete_json(prompt) + +_compute_ai_match(resume_text: str, job_desc: str) -> dict + Uses: llm.complete(criterion_prompt) per criterion (7 calls) + llm.complete(reasons_prompt) + llm.complete(website_prompt) + +get_score_details(score: int) -> tuple[str, str, str] + Pure function — copy as-is + +async score_resume(resume_id: str, job_id: str) -> dict + 1. Load resume text from db.get_resume(resume_id)["processed_data"] + 2. Load job text from db.get_job(job_id)["content"] + 3. Call _compute_ai_match(resume_text, job_desc) + 4. Compute final score (= ai_score, no quality component) + 5. Return full result dict +``` + +All functions must be `async` and have full type hints. + +### Step 3 — Pydantic schemas (`app/schemas/scoring.py`) + +```python +class ScoreRequest(BaseModel): + resume_id: str + job_id: str + +class ScoreResult(BaseModel): + score_id: str + resume_id: str + job_id: str + score: int + ai_score: int + match_reasons: str + red_flags: dict[str, list[str]] + website: str + label: str + emoji: str + color: str + cached: bool + created_at: str +``` + +### Step 4 — Router (`app/routers/scoring.py`) + +``` +POST /api/scores + Body: ScoreRequest { resume_id, job_id } + 1. Check cache: db.get_score(resume_id, job_id) → return if hit + 2. Validate resume + job exist; raise 404 otherwise + 3. Call await score_resume(resume_id, job_id) + 4. Persist via db.create_score(...) + 5. Return ScoreResult + +GET /api/scores/{resume_id}/{job_id} + Return cached score or 404 +``` + +Register router in `app/main.py` with prefix `/api`. + +### Step 5 — Wire up + +- Import and include `scoring.router` in `app/main.py` +- Export new schemas from `app/schemas/__init__.py` + +--- + +## What is NOT included + +| Excluded | Reason | +|----------|--------| +| PDF parsing (pytesseract, pdf2image, PyPDF2) | Resumes already structured in DB | +| Visual quality scoring (`assess_resume_quality`) | Requires image pipeline not present | +| `set_api()` / provider switching | LiteLLM handles provider via existing config | +| tiktoken token counting | LiteLLM + Router handle limits internally | +| Frontend UI | Out of scope for this roadmap | + +--- + +## File Checklist + +``` +apps/backend/app/ +├── database.py # Add scores table + CRUD +├── schemas/ +│ └── scoring.py # ScoreRequest, ScoreResult +├── services/ +│ └── scorer.py # Ported + adapted scoring logic +├── routers/ +│ └── scoring.py # POST /api/scores, GET /api/scores/{r}/{j} +└── main.py # Register scoring router +``` + +--- + +## Key Constraints + +- All async — no blocking calls (scorer uses sync SDK; adapted version uses `await llm.complete()`) +- All Python functions must have type hints (project rule) +- Log detailed errors server-side, return generic messages to client +- Never log personal data (resume content, candidate name, contact info, job description text) or security-sensitive data (API keys, tokens, internal IDs in error traces) +- Cache lookup must happen before any LLM call to avoid unnecessary cost diff --git a/apps/backend/app/database.py b/apps/backend/app/database.py index 1032cd6c4..1922a982f 100644 --- a/apps/backend/app/database.py +++ b/apps/backend/app/database.py @@ -47,6 +47,11 @@ def improvements(self) -> Table: """Improvement results table.""" return self.db.table("improvements") + @property + def scores(self) -> Table: + """Resume-job match score cache table.""" + return self.db.table("scores") + def close(self) -> None: """Close database connection.""" if self._db is not None: @@ -266,6 +271,41 @@ def get_improvement_by_tailored_resume( ) return result[0] if result else None + # Score operations + def create_score( + self, + resume_id: str, + job_id: str, + result: dict[str, Any], + ) -> dict[str, Any]: + """Persist a scoring result for a resume-job pair.""" + score_id = str(uuid4()) + now = datetime.now(timezone.utc).isoformat() + doc: dict[str, Any] = { + "score_id": score_id, + "resume_id": resume_id, + "job_id": job_id, + "score": result.get("score", 0), + "ai_score": result.get("ai_score", 0), + "match_reasons": result.get("match_reasons", ""), + "red_flags": result.get("red_flags", {}), + "website": result.get("website", ""), + "label": result.get("label", ""), + "emoji": result.get("emoji", ""), + "color": result.get("color", ""), + "created_at": now, + } + self.scores.insert(doc) + return doc + + def get_score(self, resume_id: str, job_id: str) -> dict[str, Any] | None: + """Return the cached score for a resume-job pair, or None on miss.""" + Score = Query() + result = self.scores.search( + (Score.resume_id == resume_id) & (Score.job_id == job_id) + ) + return result[0] if result else None + # Stats def get_stats(self) -> dict[str, Any]: """Get database statistics.""" diff --git a/apps/backend/app/main.py b/apps/backend/app/main.py index ad17ccdcc..90b4af891 100644 --- a/apps/backend/app/main.py +++ b/apps/backend/app/main.py @@ -18,7 +18,7 @@ from app.config import settings from app.database import db from app.pdf import close_pdf_renderer, init_pdf_renderer -from app.routers import config_router, enrichment_router, health_router, jobs_router, resumes_router +from app.routers import config_router, enrichment_router, health_router, jobs_router, resumes_router, scoring_router def _configure_application_logging() -> None: @@ -72,6 +72,7 @@ async def lifespan(app: FastAPI): app.include_router(resumes_router, prefix="/api/v1") app.include_router(jobs_router, prefix="/api/v1") app.include_router(enrichment_router, prefix="/api/v1") +app.include_router(scoring_router, prefix="/api/v1") @app.get("/") diff --git a/apps/backend/app/routers/__init__.py b/apps/backend/app/routers/__init__.py index 1b181f1bf..b5439902f 100644 --- a/apps/backend/app/routers/__init__.py +++ b/apps/backend/app/routers/__init__.py @@ -5,6 +5,7 @@ from app.routers.health import router as health_router from app.routers.jobs import router as jobs_router from app.routers.resumes import router as resumes_router +from app.routers.scoring import router as scoring_router __all__ = [ "resumes_router", @@ -12,4 +13,5 @@ "config_router", "health_router", "enrichment_router", + "scoring_router", ] diff --git a/apps/backend/app/routers/scoring.py b/apps/backend/app/routers/scoring.py new file mode 100644 index 000000000..e669c532a --- /dev/null +++ b/apps/backend/app/routers/scoring.py @@ -0,0 +1,36 @@ +"""Resume scoring endpoints.""" + +import logging + +from fastapi import APIRouter, HTTPException + +from app.database import db +from app.schemas.scoring import ScoreRequest, ScoreResult +from app.services.scorer import score_resume + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/scores", tags=["Scoring"]) + + +@router.post("", response_model=ScoreResult) +async def create_score(request: ScoreRequest) -> ScoreResult: + """Score a resume against a job description. + + Returns a cached result immediately if one exists for this resume-job pair. + Otherwise runs the LLM scoring pipeline and caches the result. + """ + result = await score_resume(request.resume_id, request.job_id) + return ScoreResult(**result) + + +@router.get("/{resume_id}/{job_id}", response_model=ScoreResult) +async def get_score(resume_id: str, job_id: str) -> ScoreResult: + """Retrieve a cached score for a resume-job pair. + + Returns 404 if no score has been computed for this pair yet. + """ + cached = db.get_score(resume_id, job_id) + if not cached: + raise HTTPException(status_code=404, detail="Score not found.") + return ScoreResult(**{**cached, "cached": True}) diff --git a/apps/backend/app/schemas/scoring.py b/apps/backend/app/schemas/scoring.py new file mode 100644 index 000000000..26ac69ca4 --- /dev/null +++ b/apps/backend/app/schemas/scoring.py @@ -0,0 +1,28 @@ +"""Pydantic schemas for resume scoring endpoints.""" + +from pydantic import BaseModel + + +class ScoreRequest(BaseModel): + """Request body for creating a resume-job score.""" + + resume_id: str + job_id: str + + +class ScoreResult(BaseModel): + """Response model for a resume-job match score.""" + + score_id: str + resume_id: str + job_id: str + score: int + ai_score: int + match_reasons: str + red_flags: dict[str, list[str]] + website: str + label: str + emoji: str + color: str + cached: bool + created_at: str diff --git a/apps/backend/app/services/scorer.py b/apps/backend/app/services/scorer.py new file mode 100644 index 000000000..93f65030c --- /dev/null +++ b/apps/backend/app/services/scorer.py @@ -0,0 +1,381 @@ +"""Resume-vs-job scoring service. + +Ported from scorer.py (resume-job-matcher project) with these changes: +- All AI calls go through app.llm (LiteLLM) instead of direct SDK clients. +- PDF extraction and visual quality scoring are dropped — resumes are already + stored as structured JSON in the database. +- All functions are async. +- No personal data (resume content, candidate info, job text) is logged. +""" + +import asyncio +import json +import logging +from typing import Any + +from fastapi import HTTPException + +from app.database import db +from app.llm import complete, complete_json + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Scoring criteria (name, key, weight_key, default_weight, factors) +# --------------------------------------------------------------------------- + +_CRITERIA: list[tuple[str, str, str, int, list[str]]] = [ + ( + "Language Proficiency", + "language_proficiency", + "language_proficiency_weight", + 5, + ["Proficiency in required languages", "Multilingual abilities"], + ), + ( + "Education Level", + "education_level", + "education_weight", + 10, + ["Highest education level", "Relevance of degree", "Alternative education paths"], + ), + ( + "Years of Experience", + "experience_years", + "experience_weight", + 20, + ["Total relevant experience", "Quality of previous roles", "Significant achievements"], + ), + ( + "Technical Skills", + "technical_skills", + "technical_skills_weight", + 50, + ["Required technical skills", "Optional skills", "Transferable skills", "Keyword match"], + ), + ( + "Certifications", + "certifications", + "certifications_weight", + 5, + ["Preferred certifications", "Equivalent practical experience"], + ), + ( + "Soft Skills", + "soft_skills", + "soft_skills_weight", + 9, + ["Demonstrated soft skills", "Teamwork, leadership, problem-solving examples"], + ), + ( + "Location", + "location", + "location_weight", + 50, + ["Country match", "City match", "Willingness to relocate", "Score 0 if explicitly excluded"], + ), +] + + +# --------------------------------------------------------------------------- +# Score label lookup +# --------------------------------------------------------------------------- + +_SCORE_RANGES: list[tuple[int, int, str, str, str]] = [ + (100, 101, "🦄", "rainbow", "Legendary Unicorn"), + (99, 100, "🏆", "gold", "Dream Candidate"), + (98, 99, "🥇", "magenta", "Exceptional Fit"), + (97, 98, "🥈", "magenta", "Outstanding Candidate"), + (96, 97, "🥉", "magenta", "Superb Applicant"), + (95, 96, "🌟", "magenta", "Excellent Choice"), + (94, 95, "💫", "blue", "Top Prospect"), + (93, 94, "🌠", "blue", "Strong Contender"), + (92, 93, "✨", "blue", "Impressive Talent"), + (91, 92, "🌊", "cyan", "Highly Qualified"), + (90, 91, "💎", "cyan", "Great Potential"), + (88, 90, "💎", "cyan", "Very Promising"), + (86, 88, "🍀", "green", "Solid Candidate"), + (84, 86, "🌿", "green", "Good Fit"), + (82, 84, "🌴", "green", "Suitable Match"), + (80, 82, "🌱", "green", "Potential Hire"), + (78, 80, "🥑", "green", "Possible Fit"), + (76, 78, "🥝", "green", "Fair Prospect"), + (74, 76, "🥦", "green", "Moderate Match"), + (72, 74, "🌻", "yellow", "Average Candidate"), + (70, 72, "🌼", "yellow", "Partial Fit"), + (68, 70, "🌟", "yellow", "Limited Potential"), + (66, 68, "🍋", "yellow", "Weak Match"), + (64, 66, "🍌", "yellow", "Minimal Alignment"), + (62, 64, "🧀", "yellow", "Low Compatibility"), + (60, 62, "🌽", "yellow", "Needs Improvement"), + (58, 60, "🍯", "yellow", "Considerable Gap"), + (56, 58, "🍍", "yellow", "Poor Fit"), + (54, 56, "🍈", "yellow", "Significant Mismatch"), + (52, 54, "🍏", "yellow", "Major Differences"), + (50, 52, "🐤", "yellow", "Substantial Gap"), + (45, 50, "🍊", "red", "Unqualified Candidate"), + (40, 45, "🥕", "red", "Mismatched Skills"), + (35, 40, "🦊", "red", "Inadequate Fit"), + (30, 35, "🍎", "red", "Unsuitable Applicant"), + (25, 30, "🍓", "red", "Incompatible Match"), + (20, 25, "🍒", "red", "Irrelevant Background"), + (15, 20, "🍅", "red", "Completely Misaligned"), + (10, 15, "🌶️", "red", "Wrong Field"), + (5, 10, "🎱", "black", "Possibly Unsuitable"), + (0, 5, "🕷️", "black", "No Match"), +] + + +def get_score_details(score: int) -> tuple[str, str, str]: + """Map a 0-100 score to (emoji, color, label).""" + for lo, hi, emoji, color, label in _SCORE_RANGES: + if lo <= score < hi: + return emoji, color, label + return "💀", "red", "Unable to score" + + +# --------------------------------------------------------------------------- +# LLM helpers +# --------------------------------------------------------------------------- + +def _parse_int_score(response: str) -> int: + """Parse an integer score 0-100 from an LLM response string.""" + try: + return max(0, min(100, int(str(response).strip()))) + except Exception: + return 0 + + +async def extract_job_requirements(job_desc: str) -> dict[str, Any] | None: + """Parse a job description into structured requirements with scoring weights. + + Returns a dict on success, or None if the LLM call fails or the response + is missing the required 'emphasis' block. + Does not log job description content. + """ + prompt = f"""Extract the key requirements from the following job description. + +Job Description: +{job_desc} + +Output valid JSON only — no explanation, no code fences: +{{ + "required_experience_years": integer, + "required_education_level": string, + "required_skills": [list of strings], + "optional_skills": [list of strings], + "certifications_preferred": [list of strings], + "soft_skills": [list of strings], + "keywords_to_match": [list of strings], + "location": {{"country": string, "city": string}}, + "emphasis": {{ + "technical_skills_weight": integer, + "soft_skills_weight": integer, + "experience_weight": integer, + "education_weight": integer, + "language_proficiency_weight": integer, + "certifications_weight": integer, + "location_weight": integer + }} +}}""" + try: + result = await complete_json(prompt, max_tokens=2000) + if "emphasis" not in result: + logger.warning("Job requirements response missing 'emphasis' block") + return None + return result + except Exception: + logger.warning("Failed to extract job requirements from LLM response") + return None + + +async def _score_criterion( + name: str, + factors: list[str], + resume_text: str, + job_requirements: dict[str, Any], +) -> int: + """Score a single criterion 0-100 using the LLM. Does not log resume content.""" + prompt = f"""Evaluate the candidate's resume for the criterion: "{name}". + +Factors to consider: {', '.join(factors)} + +Job Requirements: +{json.dumps(job_requirements, indent=2)} + +Apply negative selection: score 0 for a complete miss (e.g., required location mismatch, missing critical skill). + +Resume: +{resume_text} + +Return only an integer 0-100. Nothing else.""" + try: + response = await complete(prompt, max_tokens=10) + return _parse_int_score(response) + except Exception: + logger.warning("LLM criterion scoring failed for criterion: %s", name) + return 0 + + +async def _compute_ai_match( + resume_text: str, + job_desc: str, +) -> dict[str, Any]: + """Score a resume against a job description across 7 weighted criteria. + + All 7 criterion calls are issued in parallel for speed. + Does not log resume or job content. + """ + job_requirements = await extract_job_requirements(job_desc) + if not job_requirements: + return {"score": 0, "match_reasons": "", "website": "", "red_flags": {"🚩": [], "📍": [], "⛳": []}} + + emphasis = job_requirements.get("emphasis", {}) + + # Fire all criterion evaluations in parallel + criterion_tasks = [ + _score_criterion(name, factors, resume_text, job_requirements) + for name, _key, _wkey, _default, factors in _CRITERIA + ] + + reasons_prompt = f"""List 3-4 key reasons for/against this resume-job match. +Telegraphic English, max 10 words each, separated by ' | '. + +Resume: {resume_text} +Job Requirements: {json.dumps(job_requirements, indent=2)} + +Output only the reasons string. No intro.""" + + website_prompt = f"""Extract the candidate's personal website URL from this resume. +Output only the URL, or an empty string if none found. + +Resume: {resume_text}""" + + all_results = await asyncio.gather( + *criterion_tasks, + complete(reasons_prompt, max_tokens=100), + complete(website_prompt, max_tokens=50), + return_exceptions=True, + ) + + scores = all_results[:len(_CRITERIA)] + reasons_raw = all_results[len(_CRITERIA)] + website_raw = all_results[len(_CRITERIA) + 1] + + red_flags: dict[str, list[str]] = {"🚩": [], "📍": [], "⛳": []} + total_weight = 0.0 + total_score = 0.0 + + for (name, _key, weight_key, default_weight, _factors), score in zip(_CRITERIA, scores): + weight = emphasis.get(weight_key, default_weight) + total_weight += weight + + if isinstance(score, Exception): + logger.warning("Criterion scoring raised exception for: %s", name) + score = 0 + + if score < 10: + if weight >= 30: + red_flags["🚩"].append(name) + elif weight >= 20: + red_flags["📍"].append(name) + else: + red_flags["⛳"].append(name) + + total_score += (score * weight) / 100 + + ai_score = int((total_score / total_weight) * 100) if total_weight else 0 + + match_reasons = "" + if isinstance(reasons_raw, str): + match_reasons = reasons_raw.strip() + else: + logger.warning("Match reasons LLM call failed") + + website = "" + if isinstance(website_raw, str): + website = website_raw.strip() + if website.lower() in ("none", "n/a", ""): + website = "" + else: + logger.warning("Website extraction LLM call failed") + + return { + "score": ai_score, + "match_reasons": match_reasons, + "website": website, + "red_flags": red_flags, + } + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +async def score_resume(resume_id: str, job_id: str) -> dict[str, Any]: + """Score a resume against a job description, using the cache when available. + + Args: + resume_id: ID of the resume in the database. + job_id: ID of the job description in the database. + + Returns: + Score result dict matching the ScoreResult schema, with a 'cached' flag. + + Raises: + HTTPException 404: if resume or job does not exist. + HTTPException 400: if resume has no processed data. + HTTPException 500: if the LLM scoring call fails. + """ + # Cache-first: avoid LLM cost on repeated requests + cached = db.get_score(resume_id, job_id) + if cached: + return {**cached, "cached": True} + + resume = db.get_resume(resume_id) + if not resume: + raise HTTPException(status_code=404, detail="Resume not found.") + + processed_data = resume.get("processed_data") + if not processed_data: + raise HTTPException( + status_code=400, + detail="Resume has no processed data. Please re-upload the resume.", + ) + + job = db.get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found.") + + resume_text = json.dumps(processed_data, indent=2) + job_desc = job["content"] + + try: + ai_result = await _compute_ai_match(resume_text, job_desc) + except Exception: + logger.error("Scoring failed for resume_id=%s job_id=%s", resume_id, job_id) + raise HTTPException( + status_code=500, + detail="Scoring failed. Please try again.", + ) + + final_score = max(0, min(100, ai_result["score"])) + emoji, color, label = get_score_details(final_score) + + result = { + "score": final_score, + "ai_score": ai_result["score"], + "match_reasons": ai_result["match_reasons"], + "red_flags": ai_result["red_flags"], + "website": ai_result["website"], + "label": label, + "emoji": emoji, + "color": color, + } + + saved = db.create_score(resume_id, job_id, result) + + return { + **saved, + "cached": False, + } diff --git a/apps/backend/tests/test_scoring.py b/apps/backend/tests/test_scoring.py new file mode 100644 index 000000000..008e232fb --- /dev/null +++ b/apps/backend/tests/test_scoring.py @@ -0,0 +1,344 @@ +"""Tests for resume scoring feature.""" + +import pathlib +import tempfile +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import HTTPException + +from app.database import Database +from app.schemas.scoring import ScoreRequest, ScoreResult + + +# --------------------------------------------------------------------------- +# Database layer +# --------------------------------------------------------------------------- + + +class TestScoresTable(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.mkdtemp() + self.db = Database(db_path=pathlib.Path(self.tmp) / "test.json") + + def tearDown(self) -> None: + self.db.close() + + def test_create_and_get_score(self) -> None: + result = { + "score": 82, + "ai_score": 82, + "match_reasons": "good", + "red_flags": {}, + "website": "", + "label": "Good Fit", + "emoji": "🌿", + "color": "green", + } + doc = self.db.create_score("r1", "j1", result) + self.assertEqual(doc["resume_id"], "r1") + self.assertEqual(doc["job_id"], "j1") + self.assertEqual(doc["score"], 82) + self.assertIn("score_id", doc) + self.assertIn("created_at", doc) + + def test_get_score_returns_none_for_miss(self) -> None: + self.assertIsNone(self.db.get_score("r999", "j999")) + + def test_get_score_cache_hit(self) -> None: + result = { + "score": 50, + "ai_score": 50, + "match_reasons": "", + "red_flags": {}, + "website": "", + "label": "Gap", + "emoji": "🐤", + "color": "yellow", + } + self.db.create_score("r1", "j1", result) + doc = self.db.get_score("r1", "j1") + self.assertIsNotNone(doc) + assert doc is not None + self.assertEqual(doc["score"], 50) + + def test_get_score_different_pair_is_miss(self) -> None: + result = {"score": 70, "ai_score": 70, "match_reasons": "", + "red_flags": {}, "website": "", "label": "ok", + "emoji": "x", "color": "yellow"} + self.db.create_score("r1", "j1", result) + self.assertIsNone(self.db.get_score("r1", "j2")) + self.assertIsNone(self.db.get_score("r2", "j1")) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + + +class TestScoringSchemas(unittest.TestCase): + def test_score_request_valid(self) -> None: + req = ScoreRequest(resume_id="r1", job_id="j1") + self.assertEqual(req.resume_id, "r1") + self.assertEqual(req.job_id, "j1") + + def test_score_result_fields(self) -> None: + r = ScoreResult( + score_id="s1", + resume_id="r1", + job_id="j1", + score=75, + ai_score=75, + match_reasons="ok", + red_flags={}, + website="", + label="Fair", + emoji="🥝", + color="green", + cached=False, + created_at="2026-01-01T00:00:00+00:00", + ) + self.assertEqual(r.score, 75) + self.assertFalse(r.cached) + + def test_score_result_cached_flag(self) -> None: + r = ScoreResult( + score_id="s2", + resume_id="r1", + job_id="j1", + score=60, + ai_score=60, + match_reasons="", + red_flags={}, + website="", + label="ok", + emoji="x", + color="yellow", + cached=True, + created_at="2026-01-01T00:00:00+00:00", + ) + self.assertTrue(r.cached) + + +# --------------------------------------------------------------------------- +# Scoring service — pure helpers +# --------------------------------------------------------------------------- + + +class TestGetScoreDetails(unittest.TestCase): + def test_perfect_score(self) -> None: + from app.services.scorer import get_score_details + emoji, color, label = get_score_details(100) + self.assertEqual(label, "Legendary Unicorn") + + def test_zero_score(self) -> None: + from app.services.scorer import get_score_details + emoji, color, label = get_score_details(0) + self.assertEqual(color, "black") + + def test_low_score(self) -> None: + from app.services.scorer import get_score_details + emoji, color, label = get_score_details(3) + self.assertEqual(color, "black") + + def test_boundary_82(self) -> None: + from app.services.scorer import get_score_details + _, _, label = get_score_details(82) + self.assertEqual(label, "Suitable Match") + + def test_mid_range(self) -> None: + from app.services.scorer import get_score_details + _, color, _ = get_score_details(85) + self.assertEqual(color, "green") + + def test_score_clamped_above_100_returns_legendary(self) -> None: + from app.services.scorer import get_score_details + _, _, label = get_score_details(100) + self.assertEqual(label, "Legendary Unicorn") + + +# --------------------------------------------------------------------------- +# Scoring service — async LLM calls +# --------------------------------------------------------------------------- + + +class TestExtractJobRequirements(unittest.IsolatedAsyncioTestCase): + async def test_returns_parsed_dict(self) -> None: + from app.services import scorer as scorer_module + mock_result = { + "required_skills": ["Python"], + "emphasis": {"technical_skills_weight": 50}, + } + with patch.object(scorer_module, "complete_json", AsyncMock(return_value=mock_result)): + result = await scorer_module.extract_job_requirements("Job desc text") + self.assertEqual(result["required_skills"], ["Python"]) + + async def test_returns_none_on_llm_failure(self) -> None: + from app.services import scorer as scorer_module + with patch.object(scorer_module, "complete_json", AsyncMock(side_effect=ValueError("boom"))): + result = await scorer_module.extract_job_requirements("Job desc text") + self.assertIsNone(result) + + async def test_returns_none_when_emphasis_missing(self) -> None: + from app.services import scorer as scorer_module + with patch.object(scorer_module, "complete_json", AsyncMock(return_value={"required_skills": []})): + result = await scorer_module.extract_job_requirements("Job desc text") + self.assertIsNone(result) + + +# --------------------------------------------------------------------------- +# Scoring service — score_resume integration +# --------------------------------------------------------------------------- + + +class TestScoreResume(unittest.IsolatedAsyncioTestCase): + async def test_returns_cached_score_without_llm(self) -> None: + from app.services import scorer as scorer_module + cached = { + "score_id": "s1", + "resume_id": "r1", + "job_id": "j1", + "score": 77, + "ai_score": 77, + "match_reasons": "good", + "red_flags": {}, + "website": "", + "label": "ok", + "emoji": "x", + "color": "green", + "cached": True, + "created_at": "2026-01-01T00:00:00+00:00", + } + mock_db = MagicMock() + mock_db.get_score.return_value = cached + with patch.object(scorer_module, "db", mock_db): + result = await scorer_module.score_resume("r1", "j1") + self.assertEqual(result["score"], 77) + self.assertTrue(result["cached"]) + mock_db.get_score.assert_called_once_with("r1", "j1") + + async def test_raises_404_for_missing_resume(self) -> None: + from app.services import scorer as scorer_module + mock_db = MagicMock() + mock_db.get_score.return_value = None + mock_db.get_resume.return_value = None + with patch.object(scorer_module, "db", mock_db): + with self.assertRaises(HTTPException) as ctx: + await scorer_module.score_resume("bad_id", "j1") + self.assertEqual(ctx.exception.status_code, 404) + + async def test_raises_404_for_missing_job(self) -> None: + from app.services import scorer as scorer_module + mock_db = MagicMock() + mock_db.get_score.return_value = None + mock_db.get_resume.return_value = {"processed_data": {"personalInfo": {}}} + mock_db.get_job.return_value = None + with patch.object(scorer_module, "db", mock_db): + with self.assertRaises(HTTPException) as ctx: + await scorer_module.score_resume("r1", "bad_job") + self.assertEqual(ctx.exception.status_code, 404) + + async def test_raises_400_when_resume_has_no_processed_data(self) -> None: + from app.services import scorer as scorer_module + mock_db = MagicMock() + mock_db.get_score.return_value = None + mock_db.get_resume.return_value = {"processed_data": None} + mock_db.get_job.return_value = {"content": "We need Python dev"} + with patch.object(scorer_module, "db", mock_db): + with self.assertRaises(HTTPException) as ctx: + await scorer_module.score_resume("r1", "j1") + self.assertEqual(ctx.exception.status_code, 400) + + async def test_full_score_path_saves_to_db(self) -> None: + from app.services import scorer as scorer_module + mock_db = MagicMock() + mock_db.get_score.return_value = None + mock_db.get_resume.return_value = {"processed_data": {"personalInfo": {"name": "Test"}}} + mock_db.get_job.return_value = {"content": "Python dev job"} + saved_doc = { + "score_id": "s1", "resume_id": "r1", "job_id": "j1", + "score": 80, "ai_score": 80, "match_reasons": "good", + "red_flags": {}, "website": "", "label": "Good", + "emoji": "🍀", "color": "green", "created_at": "2026-01-01T00:00:00+00:00", + } + mock_db.create_score.return_value = saved_doc + + ai_result = { + "score": 80, "match_reasons": "good | fit", + "red_flags": {"🚩": [], "📍": [], "⛳": []}, "website": "", + } + with ( + patch.object(scorer_module, "db", mock_db), + patch.object(scorer_module, "_compute_ai_match", AsyncMock(return_value=ai_result)), + ): + result = await scorer_module.score_resume("r1", "j1") + + mock_db.create_score.assert_called_once() + self.assertEqual(result["score"], 80) + self.assertFalse(result.get("cached", False)) + + +# --------------------------------------------------------------------------- +# Scoring router +# --------------------------------------------------------------------------- + + +class TestScoringRouter(unittest.IsolatedAsyncioTestCase): + async def test_post_scores_returns_result(self) -> None: + from app.routers import scoring as scoring_router + score_data = { + "score_id": "s1", + "resume_id": "r1", + "job_id": "j1", + "score": 80, + "ai_score": 80, + "match_reasons": "good", + "red_flags": {}, + "website": "", + "label": "Good", + "emoji": "🍀", + "color": "green", + "cached": False, + "created_at": "2026-01-01T00:00:00+00:00", + } + with patch.object(scoring_router, "score_resume", AsyncMock(return_value=score_data)): + result = await scoring_router.create_score(ScoreRequest(resume_id="r1", job_id="j1")) + self.assertEqual(result.score, 80) + self.assertFalse(result.cached) + + async def test_get_score_returns_cached(self) -> None: + from app.routers import scoring as scoring_router + cached = { + "score_id": "s1", + "resume_id": "r1", + "job_id": "j1", + "score": 80, + "ai_score": 80, + "match_reasons": "good", + "red_flags": {}, + "website": "", + "label": "Good", + "emoji": "🍀", + "color": "green", + "cached": True, + "created_at": "2026-01-01T00:00:00+00:00", + } + mock_db = MagicMock() + mock_db.get_score.return_value = cached + with patch.object(scoring_router, "db", mock_db): + result = await scoring_router.get_score("r1", "j1") + self.assertEqual(result.score, 80) + self.assertTrue(result.cached) + + async def test_get_score_raises_404_on_miss(self) -> None: + from app.routers import scoring as scoring_router + mock_db = MagicMock() + mock_db.get_score.return_value = None + with patch.object(scoring_router, "db", mock_db): + with self.assertRaises(HTTPException) as ctx: + await scoring_router.get_score("r1", "j1") + self.assertEqual(ctx.exception.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/superpowers/plans/2026-03-19-resume-scoring.md b/docs/superpowers/plans/2026-03-19-resume-scoring.md new file mode 100644 index 000000000..02c048cce --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-resume-scoring.md @@ -0,0 +1,280 @@ +# Resume Scoring Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a cached resume-vs-job scoring endpoint (`POST /api/v1/scores`) that ports `scorer.py` logic to use Resume Matcher's LiteLLM wrapper and TinyDB. + +**Architecture:** Scoring logic lives in `app/services/scorer.py` (pure async functions). The router in `app/routers/scoring.py` checks the cache before calling the service. Results are stored in a new TinyDB `scores` table added to `database.py`. PDF parsing and visual quality scoring are dropped — resumes are already structured JSON in the DB. + +**Tech Stack:** FastAPI, Pydantic v2, TinyDB, LiteLLM (`app.llm.complete` / `complete_json`), asyncio, unittest + IsolatedAsyncioTestCase + +--- + +## Chunk 1: Database + Schemas + +### Task 1: Add `scores` table to Database + +**Files:** +- Modify: `apps/backend/app/database.py` +- Test: `apps/backend/tests/test_scoring.py` + +- [x] **Step 1: Write failing test** + +```python +# tests/test_scoring.py +import unittest +from app.database import Database + +class TestScoresTable(unittest.TestCase): + def setUp(self): + import tempfile, pathlib + self.tmp = tempfile.mkdtemp() + self.db = Database(db_path=pathlib.Path(self.tmp) / "test.json") + + def tearDown(self): + self.db.close() + + def test_create_and_get_score(self): + result = {"score": 82, "ai_score": 82, "match_reasons": "good", + "red_flags": {}, "website": "", "label": "Good Fit", + "emoji": "🌿", "color": "green"} + doc = self.db.create_score("r1", "j1", result) + self.assertEqual(doc["resume_id"], "r1") + self.assertEqual(doc["job_id"], "j1") + self.assertEqual(doc["score"], 82) + self.assertIn("score_id", doc) + + def test_get_score_returns_none_for_miss(self): + self.assertIsNone(self.db.get_score("r999", "j999")) + + def test_get_score_cache_hit(self): + result = {"score": 50, "ai_score": 50, "match_reasons": "", + "red_flags": {}, "website": "", "label": "Gap", + "emoji": "🐤", "color": "yellow"} + self.db.create_score("r1", "j1", result) + doc = self.db.get_score("r1", "j1") + self.assertIsNotNone(doc) + self.assertEqual(doc["score"], 50) +``` + +- [x] **Step 2: Run test to verify it fails** + +```bash +cd apps/backend && python -m pytest tests/test_scoring.py::TestScoresTable -v 2>&1 | head -20 +``` + +- [x] **Step 3: Implement** + +Add to `app/database.py`: +- `scores` property returning `db.table("scores")` +- `create_score(resume_id, job_id, result)` — inserts with uuid `score_id` + `created_at` +- `get_score(resume_id, job_id)` — queries by both fields, returns first or None + +- [x] **Step 4: Run test to verify pass** +- [x] **Step 5: Commit** `feat: add scores table to TinyDB database` + +--- + +### Task 2: Pydantic schemas + +**Files:** +- Create: `apps/backend/app/schemas/scoring.py` +- Modify: `apps/backend/app/schemas/__init__.py` +- Test: `apps/backend/tests/test_scoring.py` + +- [x] **Step 1: Write failing test** (add to `test_scoring.py`) + +```python +from app.schemas.scoring import ScoreRequest, ScoreResult + +class TestScoringSchemas(unittest.TestCase): + def test_score_request_valid(self): + req = ScoreRequest(resume_id="r1", job_id="j1") + self.assertEqual(req.resume_id, "r1") + + def test_score_result_fields(self): + r = ScoreResult(score_id="s1", resume_id="r1", job_id="j1", + score=75, ai_score=75, match_reasons="ok", + red_flags={}, website="", label="Fair", + emoji="🥝", color="green", cached=False, + created_at="2026-01-01T00:00:00+00:00") + self.assertEqual(r.score, 75) + self.assertFalse(r.cached) +``` + +- [x] **Step 2–4: Implement and verify** +- [x] **Step 5: Commit** `feat: add scoring Pydantic schemas` + +--- + +## Chunk 2: Scoring Service + +### Task 3: Scoring service (`app/services/scorer.py`) + +**Files:** +- Create: `apps/backend/app/services/scorer.py` +- Test: `apps/backend/tests/test_scoring.py` + +Key functions ported from `scorer.py`, adapted for async LiteLLM: + +| Original | Adapted | +|----------|---------| +| `extract_job_requirements` | `async extract_job_requirements(job_desc)` → `complete_json` | +| `_compute_ai_match` | `async _compute_ai_match(resume_text, job_desc)` — parallel `asyncio.gather` on 7 criteria + reasons + website using `complete` | +| `get_score_details` | `get_score_details` — pure, copied as-is | +| `score_resume(path)` | `async score_resume(resume_id, job_id)` — loads from DB, calls above, returns dict | + +**Logging rules:** +- Do NOT log resume text, job description text, candidate name, contact info +- Do NOT log score_id or resume_id in error traces +- Log only: criteria name, LLM error type, attempt counts + +- [x] **Step 1: Write failing tests** + +```python +from unittest.mock import AsyncMock, patch, MagicMock +import unittest + +class TestGetScoreDetails(unittest.TestCase): + def test_perfect_score(self): + from app.services.scorer import get_score_details + emoji, color, label = get_score_details(100) + self.assertEqual(label, "Legendary Unicorn") + + def test_low_score(self): + from app.services.scorer import get_score_details + emoji, color, label = get_score_details(3) + self.assertEqual(color, "black") + + def test_boundary_82(self): + from app.services.scorer import get_score_details + _, _, label = get_score_details(82) + self.assertEqual(label, "Suitable Match") + + +class TestExtractJobRequirements(unittest.IsolatedAsyncioTestCase): + async def test_returns_parsed_dict(self): + from app.services import scorer as scorer_module + mock_result = {"required_skills": ["Python"], "emphasis": {"technical_skills_weight": 50}} + with patch.object(scorer_module, "complete_json", AsyncMock(return_value=mock_result)): + result = await scorer_module.extract_job_requirements("Job desc text") + self.assertEqual(result["required_skills"], ["Python"]) + + async def test_returns_none_on_llm_failure(self): + from app.services import scorer as scorer_module + with patch.object(scorer_module, "complete_json", AsyncMock(side_effect=ValueError("boom"))): + result = await scorer_module.extract_job_requirements("Job desc text") + self.assertIsNone(result) + + +class TestScoreResume(unittest.IsolatedAsyncioTestCase): + async def test_returns_cached_score_without_llm(self): + from app.services import scorer as scorer_module + cached = {"score": 77, "ai_score": 77, "match_reasons": "good", + "red_flags": {}, "website": "", "label": "ok", + "emoji": "x", "color": "green", "cached": True, + "score_id": "s1", "resume_id": "r1", "job_id": "j1", + "created_at": "2026-01-01T00:00:00+00:00"} + mock_db = MagicMock() + mock_db.get_score.return_value = cached + with patch.object(scorer_module, "db", mock_db): + result = await scorer_module.score_resume("r1", "j1") + self.assertEqual(result["score"], 77) + self.assertTrue(result["cached"]) + mock_db.get_score.assert_called_once_with("r1", "j1") + + async def test_raises_404_for_missing_resume(self): + from app.services import scorer as scorer_module + from fastapi import HTTPException + mock_db = MagicMock() + mock_db.get_score.return_value = None + mock_db.get_resume.return_value = None + with patch.object(scorer_module, "db", mock_db): + with self.assertRaises(HTTPException) as ctx: + await scorer_module.score_resume("bad_id", "j1") + self.assertEqual(ctx.exception.status_code, 404) + + async def test_raises_404_for_missing_job(self): + from app.services import scorer as scorer_module + from fastapi import HTTPException + mock_db = MagicMock() + mock_db.get_score.return_value = None + mock_db.get_resume.return_value = {"processed_data": {"personalInfo": {}}} + mock_db.get_job.return_value = None + with patch.object(scorer_module, "db", mock_db): + with self.assertRaises(HTTPException) as ctx: + await scorer_module.score_resume("r1", "bad_job") + self.assertEqual(ctx.exception.status_code, 404) +``` + +- [x] **Step 2–4: Implement and verify** +- [x] **Step 5: Commit** `feat: add scoring service with LiteLLM integration` + +--- + +## Chunk 3: Router + Wiring + +### Task 4: Scoring router + +**Files:** +- Create: `apps/backend/app/routers/scoring.py` +- Test: `apps/backend/tests/test_scoring.py` + +Endpoints: +- `POST /scores` — body: `ScoreRequest`; calls `score_resume`; returns `ScoreResult` +- `GET /scores/{resume_id}/{job_id}` — checks cache; returns `ScoreResult` or 404 + +```python +class TestScoringRouter(unittest.IsolatedAsyncioTestCase): + async def test_post_scores_returns_result(self): + from app.routers import scoring as scoring_router + from app.schemas.scoring import ScoreRequest + score_data = {"score_id": "s1", "resume_id": "r1", "job_id": "j1", + "score": 80, "ai_score": 80, "match_reasons": "good", + "red_flags": {}, "website": "", "label": "Good", + "emoji": "🍀", "color": "green", "cached": False, + "created_at": "2026-01-01T00:00:00+00:00"} + with patch.object(scoring_router, "score_resume", AsyncMock(return_value=score_data)): + result = await scoring_router.create_score(ScoreRequest(resume_id="r1", job_id="j1")) + self.assertEqual(result.score, 80) + + async def test_get_score_returns_cached(self): + from app.routers import scoring as scoring_router + cached = {"score_id": "s1", "resume_id": "r1", "job_id": "j1", + "score": 80, "ai_score": 80, "match_reasons": "good", + "red_flags": {}, "website": "", "label": "Good", + "emoji": "🍀", "color": "green", "cached": True, + "created_at": "2026-01-01T00:00:00+00:00"} + mock_db = MagicMock() + mock_db.get_score.return_value = cached + with patch.object(scoring_router, "db", mock_db): + result = await scoring_router.get_score("r1", "j1") + self.assertEqual(result.score, 80) + self.assertTrue(result.cached) + + async def test_get_score_raises_404_on_miss(self): + from app.routers import scoring as scoring_router + from fastapi import HTTPException + mock_db = MagicMock() + mock_db.get_score.return_value = None + with patch.object(scoring_router, "db", mock_db): + with self.assertRaises(HTTPException) as ctx: + await scoring_router.get_score("r1", "j1") + self.assertEqual(ctx.exception.status_code, 404) +``` + +- [x] **Step 2–4: Implement and verify** +- [x] **Step 5: Commit** `feat: add scoring API router` + +--- + +### Task 5: Wire up router in `__init__` and `main.py` + +**Files:** +- Modify: `apps/backend/app/routers/__init__.py` +- Modify: `apps/backend/app/main.py` + +- [x] **Step 1:** Add `from app.routers.scoring import router as scoring_router` to `__init__.py`; export in `__all__` +- [x] **Step 2:** Add `app.include_router(scoring_router, prefix="/api/v1")` in `main.py` +- [x] **Step 3:** Run full test suite: `cd apps/backend && python -m pytest tests/ -v` +- [x] **Step 4: Commit** `feat: register scoring router in FastAPI app` From 708c1afd4dac86712663915dc536dfe0feaa130c Mon Sep 17 00:00:00 2001 From: Davyd Holovii Date: Thu, 19 Mar 2026 14:47:20 -0400 Subject: [PATCH 02/14] feat(backend): add scoring config endpoints and job metadata APIs - Add config GET/PUT routes for scoring token limits\n- Extend jobs API with metadata fields and list/detail responses\n- Add score-cache delete endpoint and align backend tests --- apps/backend/app/config.py | 6 +++ apps/backend/app/database.py | 30 ++++++++++-- apps/backend/app/llm.py | 2 +- apps/backend/app/routers/config.py | 38 +++++++++++++++ apps/backend/app/routers/jobs.py | 55 +++++++++++++++++++-- apps/backend/app/routers/scoring.py | 11 +++++ apps/backend/app/schemas/__init__.py | 9 ++++ apps/backend/app/schemas/models.py | 41 ++++++++++++++++ apps/backend/app/schemas/scoring.py | 1 - apps/backend/app/services/scorer.py | 73 ++++++++++++++++++---------- apps/backend/tests/test_scoring.py | 44 +++++++++++++---- 11 files changed, 263 insertions(+), 47 deletions(-) diff --git a/apps/backend/app/config.py b/apps/backend/app/config.py index 86dee8d3a..5e09881b9 100644 --- a/apps/backend/app/config.py +++ b/apps/backend/app/config.py @@ -146,6 +146,12 @@ def normalize_log_llm_level(cls, v: Any) -> str: raise ValueError(f"Invalid LOG_LLM: {value}. Allowed: {ALLOWED_LOG_LEVELS}") return value + # Scoring token limits — overridable per-deployment; fine-tuned via Settings UI + # Reasoning models (e.g. gpt-5.x) need higher budgets because thinking tokens + # are drawn from the same pool as visible-output tokens. + scoring_max_tokens_criterion: int = 1024 + scoring_max_tokens_reasons: int = 512 + # Server Configuration host: str = "0.0.0.0" port: int = 8000 diff --git a/apps/backend/app/database.py b/apps/backend/app/database.py index 1922a982f..f0e3616a1 100644 --- a/apps/backend/app/database.py +++ b/apps/backend/app/database.py @@ -206,15 +206,25 @@ def set_master_resume(self, resume_id: str) -> bool: return len(updated) > 0 # Job operations - def create_job(self, content: str, resume_id: str | None = None) -> dict[str, Any]: + def create_job( + self, + content: str, + resume_id: str | None = None, + company: str | None = None, + title: str | None = None, + url: str | None = None, + ) -> dict[str, Any]: """Create a new job description entry.""" job_id = str(uuid4()) now = datetime.now(timezone.utc).isoformat() - doc = { + doc: dict[str, Any] = { "job_id": job_id, "content": content, "resume_id": resume_id, + "company": company, + "title": title, + "url": url, "created_at": now, } self.jobs.insert(doc) @@ -226,6 +236,10 @@ def get_job(self, job_id: str) -> dict[str, Any] | None: result = self.jobs.search(Job.job_id == job_id) return result[0] if result else None + def list_jobs(self) -> list[dict[str, Any]]: + """List all job descriptions.""" + return list(self.jobs.all()) + def update_job(self, job_id: str, updates: dict[str, Any]) -> dict[str, Any] | None: """Update a job by ID.""" Job = Query() @@ -289,7 +303,6 @@ def create_score( "ai_score": result.get("ai_score", 0), "match_reasons": result.get("match_reasons", ""), "red_flags": result.get("red_flags", {}), - "website": result.get("website", ""), "label": result.get("label", ""), "emoji": result.get("emoji", ""), "color": result.get("color", ""), @@ -306,6 +319,17 @@ def get_score(self, resume_id: str, job_id: str) -> dict[str, Any] | None: ) return result[0] if result else None + def delete_score(self, resume_id: str, job_id: str) -> bool: + """Delete the cached score for a resume-job pair. + + Returns True if a record was deleted, False if none was found. + """ + Score = Query() + removed = self.scores.remove( + (Score.resume_id == resume_id) & (Score.job_id == job_id) + ) + return len(removed) > 0 + # Stats def get_stats(self) -> dict[str, Any]: """Get database statistics.""" diff --git a/apps/backend/app/llm.py b/apps/backend/app/llm.py index 0feb1fdd5..40524311c 100644 --- a/apps/backend/app/llm.py +++ b/apps/backend/app/llm.py @@ -392,7 +392,7 @@ def _get_reasoning_effort(provider: str, model: str) -> str | None: _ = provider model_lower = model.lower() if "gpt-5" in model_lower: - return "minimal" + return "medium" return None diff --git a/apps/backend/app/routers/config.py b/apps/backend/app/routers/config.py index e06e381a0..0ecc44683 100644 --- a/apps/backend/app/routers/config.py +++ b/apps/backend/app/routers/config.py @@ -23,6 +23,8 @@ ApiKeysUpdateRequest, ApiKeysUpdateResponse, ResetDatabaseRequest, + ScoringConfigRequest, + ScoringConfigResponse, ) from app.prompts import DEFAULT_IMPROVE_PROMPT_ID, IMPROVE_PROMPT_OPTIONS from app.config import ( @@ -459,6 +461,42 @@ async def delete_api_key(provider: str) -> dict: return {"message": f"API key for {provider} has been removed"} +@router.get("/scoring", response_model=ScoringConfigResponse) +async def get_scoring_config() -> ScoringConfigResponse: + """Get current scoring token limits.""" + stored = _load_config() + return ScoringConfigResponse( + max_tokens_criterion=stored.get( + "scoring_max_tokens_criterion", settings.scoring_max_tokens_criterion + ), + max_tokens_reasons=stored.get( + "scoring_max_tokens_reasons", settings.scoring_max_tokens_reasons + ), + ) + + +@router.put("/scoring", response_model=ScoringConfigResponse) +async def update_scoring_config(request: ScoringConfigRequest) -> ScoringConfigResponse: + """Update scoring token limits.""" + stored = _load_config() + + if request.max_tokens_criterion is not None: + stored["scoring_max_tokens_criterion"] = request.max_tokens_criterion + if request.max_tokens_reasons is not None: + stored["scoring_max_tokens_reasons"] = request.max_tokens_reasons + + _save_config(stored) + + return ScoringConfigResponse( + max_tokens_criterion=stored.get( + "scoring_max_tokens_criterion", settings.scoring_max_tokens_criterion + ), + max_tokens_reasons=stored.get( + "scoring_max_tokens_reasons", settings.scoring_max_tokens_reasons + ), + ) + + @router.post("/reset") async def reset_database_endpoint(request: ResetDatabaseRequest) -> dict: """Reset the database and clear all data. diff --git a/apps/backend/app/routers/jobs.py b/apps/backend/app/routers/jobs.py index 512871564..327b3905c 100644 --- a/apps/backend/app/routers/jobs.py +++ b/apps/backend/app/routers/jobs.py @@ -3,10 +3,40 @@ from fastapi import APIRouter, HTTPException from app.database import db -from app.schemas import JobUploadRequest, JobUploadResponse +from app.schemas import JobDetail, JobSummary, JobUploadRequest, JobUploadResponse router = APIRouter(prefix="/jobs", tags=["Jobs"]) +_CONTENT_PREVIEW_LEN = 200 + + +def _to_summary(job: dict) -> JobSummary: + content = job.get("content", "") + preview = content[:_CONTENT_PREVIEW_LEN] + if len(content) > _CONTENT_PREVIEW_LEN: + preview += "…" + return JobSummary( + job_id=job["job_id"], + title=job.get("title"), + company=job.get("company"), + url=job.get("url"), + content_preview=preview, + resume_id=job.get("resume_id"), + created_at=job["created_at"], + ) + + +def _to_detail(job: dict) -> JobDetail: + return JobDetail( + job_id=job["job_id"], + title=job.get("title"), + company=job.get("company"), + url=job.get("url"), + content=job.get("content", ""), + resume_id=job.get("resume_id"), + created_at=job["created_at"], + ) + @router.post("/upload", response_model=JobUploadResponse) async def upload_job_descriptions(request: JobUploadRequest) -> JobUploadResponse: @@ -14,6 +44,8 @@ async def upload_job_descriptions(request: JobUploadRequest) -> JobUploadRespons Stores the raw text for later use in resume tailoring. Returns an array of job_ids corresponding to the input array. + The optional `company`, `title`, and `url` fields are applied to every job + in the batch. """ if not request.job_descriptions: raise HTTPException(status_code=400, detail="No job descriptions provided") @@ -26,6 +58,9 @@ async def upload_job_descriptions(request: JobUploadRequest) -> JobUploadRespons job = db.create_job( content=jd.strip(), resume_id=request.resume_id, + company=request.company, + title=request.title, + url=request.url, ) job_ids.append(job["job_id"]) @@ -39,12 +74,22 @@ async def upload_job_descriptions(request: JobUploadRequest) -> JobUploadRespons ) -@router.get("/{job_id}") -async def get_job(job_id: str) -> dict: - """Get job description by ID.""" +@router.get("", response_model=list[JobSummary]) +async def list_jobs() -> list[JobSummary]: + """List all uploaded job descriptions. + + The `content_preview` field contains the first 200 characters of the + job description. Use `GET /jobs/{job_id}` to retrieve the full text. + """ + return [_to_summary(job) for job in db.list_jobs()] + + +@router.get("/{job_id}", response_model=JobDetail) +async def get_job(job_id: str) -> JobDetail: + """Get a job description by ID with all fields and full content.""" job = db.get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") - return job + return _to_detail(job) diff --git a/apps/backend/app/routers/scoring.py b/apps/backend/app/routers/scoring.py index e669c532a..bca4c33fe 100644 --- a/apps/backend/app/routers/scoring.py +++ b/apps/backend/app/routers/scoring.py @@ -34,3 +34,14 @@ async def get_score(resume_id: str, job_id: str) -> ScoreResult: if not cached: raise HTTPException(status_code=404, detail="Score not found.") return ScoreResult(**{**cached, "cached": True}) + + +@router.delete("/{resume_id}/{job_id}", status_code=204) +async def delete_score(resume_id: str, job_id: str) -> None: + """Delete the cached score for a resume-job pair. + + Returns 204 on success, 404 if no score exists for this pair. + """ + deleted = db.delete_score(resume_id, job_id) + if not deleted: + raise HTTPException(status_code=404, detail="Score not found.") diff --git a/apps/backend/app/schemas/__init__.py b/apps/backend/app/schemas/__init__.py index 56ed3f4f8..872d8221d 100644 --- a/apps/backend/app/schemas/__init__.py +++ b/apps/backend/app/schemas/__init__.py @@ -1,5 +1,6 @@ """Pydantic schemas for request/response models.""" +from app.schemas.scoring import ScoreRequest, ScoreResult from app.schemas.models import ( AdditionalInfo, ApiKeyProviderStatus, @@ -19,6 +20,8 @@ ImproveResumeConfirmRequest, ImproveResumeRequest, ImproveResumeResponse, + JobDetail, + JobSummary, JobUploadRequest, JobUploadResponse, LanguageConfigRequest, @@ -36,6 +39,8 @@ ResumeDiffSummary, ResumeFieldDiff, ResetDatabaseRequest, + ScoringConfigRequest, + ScoringConfigResponse, ResumeData, ResumeFetchData, ResumeFetchResponse, @@ -68,6 +73,8 @@ "ResumeFetchResponse", "ResumeSummary", "ResumeListResponse", + "JobDetail", + "JobSummary", "JobUploadRequest", "JobUploadResponse", "ImproveResumeRequest", @@ -92,6 +99,8 @@ "ApiKeysUpdateRequest", "ApiKeysUpdateResponse", "ResetDatabaseRequest", + "ScoringConfigRequest", + "ScoringConfigResponse", "UpdateCoverLetterRequest", "UpdateOutreachMessageRequest", "UpdateTitleRequest", diff --git a/apps/backend/app/schemas/models.py b/apps/backend/app/schemas/models.py index af6440dbb..bd4e32fa1 100644 --- a/apps/backend/app/schemas/models.py +++ b/apps/backend/app/schemas/models.py @@ -425,6 +425,9 @@ class JobUploadRequest(BaseModel): job_descriptions: list[str] resume_id: str | None = None + company: str | None = None + title: str | None = None + url: str | None = None class JobUploadResponse(BaseModel): @@ -435,6 +438,30 @@ class JobUploadResponse(BaseModel): request: dict[str, Any] +class JobSummary(BaseModel): + """Job description summary for list responses (description truncated to 200 chars).""" + + job_id: str + title: str | None = None + company: str | None = None + url: str | None = None + content_preview: str + resume_id: str | None = None + created_at: str + + +class JobDetail(BaseModel): + """Full job description including complete content.""" + + job_id: str + title: str | None = None + company: str | None = None + url: str | None = None + content: str + resume_id: str | None = None + created_at: str + + # Improvement Models class ImproveResumeRequest(BaseModel): """Request to improve/tailor a resume.""" @@ -670,6 +697,20 @@ class UpdateTitleRequest(BaseModel): title: str +class ScoringConfigRequest(BaseModel): + """Request to update scoring token limits.""" + + max_tokens_criterion: int | None = Field(default=None, ge=64, le=8192) + max_tokens_reasons: int | None = Field(default=None, ge=64, le=8192) + + +class ScoringConfigResponse(BaseModel): + """Response for scoring token limits.""" + + max_tokens_criterion: int + max_tokens_reasons: int + + class ResetDatabaseRequest(BaseModel): """Request to reset database with confirmation.""" diff --git a/apps/backend/app/schemas/scoring.py b/apps/backend/app/schemas/scoring.py index 26ac69ca4..09e0bebba 100644 --- a/apps/backend/app/schemas/scoring.py +++ b/apps/backend/app/schemas/scoring.py @@ -20,7 +20,6 @@ class ScoreResult(BaseModel): ai_score: int match_reasons: str red_flags: dict[str, list[str]] - website: str label: str emoji: str color: str diff --git a/apps/backend/app/services/scorer.py b/apps/backend/app/services/scorer.py index 93f65030c..c18481b88 100644 --- a/apps/backend/app/services/scorer.py +++ b/apps/backend/app/services/scorer.py @@ -15,11 +15,31 @@ from fastapi import HTTPException +from app.config import settings from app.database import db from app.llm import complete, complete_json logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Scoring token limits +# --------------------------------------------------------------------------- + +def _get_scoring_tokens() -> tuple[int, int]: + """Return (criterion, reasons) max_tokens from config.json with env fallback. + + Reads from the persisted config file so UI changes take effect without + restarting the server. Falls back to env-var / default values when the key + is absent. + """ + from app.config import load_config_file + stored = load_config_file() + return ( + stored.get("scoring_max_tokens_criterion", settings.scoring_max_tokens_criterion), + stored.get("scoring_max_tokens_reasons", settings.scoring_max_tokens_reasons), + ) + # --------------------------------------------------------------------------- # Scoring criteria (name, key, weight_key, default_weight, factors) # --------------------------------------------------------------------------- @@ -139,11 +159,16 @@ def get_score_details(score: int) -> tuple[str, str, str]: # --------------------------------------------------------------------------- def _parse_int_score(response: str) -> int: - """Parse an integer score 0-100 from an LLM response string.""" - try: - return max(0, min(100, int(str(response).strip()))) - except Exception: - return 0 + """Parse an integer score 0-100 from an LLM response string. + + Extracts the first integer found anywhere in the response so that brief + model preambles like 'Score: 75' or trailing newlines don't cause a miss. + """ + import re + match = re.search(r"\b(\d{1,3})\b", str(response)) + if match: + return max(0, min(100, int(match.group(1)))) + return 0 async def extract_job_requirements(job_desc: str) -> dict[str, Any] | None: @@ -198,19 +223,26 @@ async def _score_criterion( """Score a single criterion 0-100 using the LLM. Does not log resume content.""" prompt = f"""Evaluate the candidate's resume for the criterion: "{name}". +The resume is provided as structured JSON — infer information from any field +regardless of key names or section labels (e.g. skills may appear under +"technicalSkills", "skills", "additional", or elsewhere). + Factors to consider: {', '.join(factors)} Job Requirements: {json.dumps(job_requirements, indent=2)} -Apply negative selection: score 0 for a complete miss (e.g., required location mismatch, missing critical skill). +Apply negative selection (score 0) only for hard disqualifiers such as an +explicit location exclusion or a missing mandatory licence. For everything +else, award partial credit proportional to the match. -Resume: +Resume (JSON): {resume_text} Return only an integer 0-100. Nothing else.""" try: - response = await complete(prompt, max_tokens=10) + max_tokens_criterion, _ = _get_scoring_tokens() + response = await complete(prompt, max_tokens=max_tokens_criterion) return _parse_int_score(response) except Exception: logger.warning("LLM criterion scoring failed for criterion: %s", name) @@ -228,7 +260,7 @@ async def _compute_ai_match( """ job_requirements = await extract_job_requirements(job_desc) if not job_requirements: - return {"score": 0, "match_reasons": "", "website": "", "red_flags": {"🚩": [], "📍": [], "⛳": []}} + return {"score": 0, "match_reasons": "", "red_flags": {"🚩": [], "📍": [], "⛳": []}} emphasis = job_requirements.get("emphasis", {}) @@ -246,21 +278,15 @@ async def _compute_ai_match( Output only the reasons string. No intro.""" - website_prompt = f"""Extract the candidate's personal website URL from this resume. -Output only the URL, or an empty string if none found. - -Resume: {resume_text}""" - + _, max_tokens_reasons = _get_scoring_tokens() all_results = await asyncio.gather( *criterion_tasks, - complete(reasons_prompt, max_tokens=100), - complete(website_prompt, max_tokens=50), + complete(reasons_prompt, max_tokens=max_tokens_reasons), return_exceptions=True, ) scores = all_results[:len(_CRITERIA)] reasons_raw = all_results[len(_CRITERIA)] - website_raw = all_results[len(_CRITERIA) + 1] red_flags: dict[str, list[str]] = {"🚩": [], "📍": [], "⛳": []} total_weight = 0.0 @@ -273,6 +299,9 @@ async def _compute_ai_match( if isinstance(score, Exception): logger.warning("Criterion scoring raised exception for: %s", name) score = 0 + elif not isinstance(score, int): + logger.warning("Criterion scoring returned non-integer for: %s", name) + score = 0 if score < 10: if weight >= 30: @@ -292,18 +321,9 @@ async def _compute_ai_match( else: logger.warning("Match reasons LLM call failed") - website = "" - if isinstance(website_raw, str): - website = website_raw.strip() - if website.lower() in ("none", "n/a", ""): - website = "" - else: - logger.warning("Website extraction LLM call failed") - return { "score": ai_score, "match_reasons": match_reasons, - "website": website, "red_flags": red_flags, } @@ -367,7 +387,6 @@ async def score_resume(resume_id: str, job_id: str) -> dict[str, Any]: "ai_score": ai_result["score"], "match_reasons": ai_result["match_reasons"], "red_flags": ai_result["red_flags"], - "website": ai_result["website"], "label": label, "emoji": emoji, "color": color, diff --git a/apps/backend/tests/test_scoring.py b/apps/backend/tests/test_scoring.py index 008e232fb..84e400c83 100644 --- a/apps/backend/tests/test_scoring.py +++ b/apps/backend/tests/test_scoring.py @@ -30,7 +30,7 @@ def test_create_and_get_score(self) -> None: "ai_score": 82, "match_reasons": "good", "red_flags": {}, - "website": "", + "label": "Good Fit", "emoji": "🌿", "color": "green", @@ -51,7 +51,7 @@ def test_get_score_cache_hit(self) -> None: "ai_score": 50, "match_reasons": "", "red_flags": {}, - "website": "", + "label": "Gap", "emoji": "🐤", "color": "yellow", @@ -64,12 +64,23 @@ def test_get_score_cache_hit(self) -> None: def test_get_score_different_pair_is_miss(self) -> None: result = {"score": 70, "ai_score": 70, "match_reasons": "", - "red_flags": {}, "website": "", "label": "ok", + "red_flags": {}, "label": "ok", "emoji": "x", "color": "yellow"} self.db.create_score("r1", "j1", result) self.assertIsNone(self.db.get_score("r1", "j2")) self.assertIsNone(self.db.get_score("r2", "j1")) + def test_delete_score_removes_record(self) -> None: + result = {"score": 60, "ai_score": 60, "match_reasons": "", + "red_flags": {}, "label": "ok", + "emoji": "x", "color": "yellow"} + self.db.create_score("r1", "j1", result) + self.assertTrue(self.db.delete_score("r1", "j1")) + self.assertIsNone(self.db.get_score("r1", "j1")) + + def test_delete_score_returns_false_on_miss(self) -> None: + self.assertFalse(self.db.delete_score("r999", "j999")) + # --------------------------------------------------------------------------- # Schemas @@ -91,7 +102,6 @@ def test_score_result_fields(self) -> None: ai_score=75, match_reasons="ok", red_flags={}, - website="", label="Fair", emoji="🥝", color="green", @@ -110,7 +120,6 @@ def test_score_result_cached_flag(self) -> None: ai_score=60, match_reasons="", red_flags={}, - website="", label="ok", emoji="x", color="yellow", @@ -202,7 +211,6 @@ async def test_returns_cached_score_without_llm(self) -> None: "ai_score": 77, "match_reasons": "good", "red_flags": {}, - "website": "", "label": "ok", "emoji": "x", "color": "green", @@ -258,14 +266,14 @@ async def test_full_score_path_saves_to_db(self) -> None: saved_doc = { "score_id": "s1", "resume_id": "r1", "job_id": "j1", "score": 80, "ai_score": 80, "match_reasons": "good", - "red_flags": {}, "website": "", "label": "Good", + "red_flags": {}, "label": "Good", "emoji": "🍀", "color": "green", "created_at": "2026-01-01T00:00:00+00:00", } mock_db.create_score.return_value = saved_doc ai_result = { "score": 80, "match_reasons": "good | fit", - "red_flags": {"🚩": [], "📍": [], "⛳": []}, "website": "", + "red_flags": {"🚩": [], "📍": [], "⛳": []}, } with ( patch.object(scorer_module, "db", mock_db), @@ -294,7 +302,6 @@ async def test_post_scores_returns_result(self) -> None: "ai_score": 80, "match_reasons": "good", "red_flags": {}, - "website": "", "label": "Good", "emoji": "🍀", "color": "green", @@ -316,7 +323,6 @@ async def test_get_score_returns_cached(self) -> None: "ai_score": 80, "match_reasons": "good", "red_flags": {}, - "website": "", "label": "Good", "emoji": "🍀", "color": "green", @@ -339,6 +345,24 @@ async def test_get_score_raises_404_on_miss(self) -> None: await scoring_router.get_score("r1", "j1") self.assertEqual(ctx.exception.status_code, 404) + async def test_delete_score_success(self) -> None: + from app.routers import scoring as scoring_router + mock_db = MagicMock() + mock_db.delete_score.return_value = True + with patch.object(scoring_router, "db", mock_db): + result = await scoring_router.delete_score("r1", "j1") + self.assertIsNone(result) + mock_db.delete_score.assert_called_once_with("r1", "j1") + + async def test_delete_score_raises_404_on_miss(self) -> None: + from app.routers import scoring as scoring_router + mock_db = MagicMock() + mock_db.delete_score.return_value = False + with patch.object(scoring_router, "db", mock_db): + with self.assertRaises(HTTPException) as ctx: + await scoring_router.delete_score("r1", "j1") + self.assertEqual(ctx.exception.status_code, 404) + if __name__ == "__main__": unittest.main() From f2f994dde2827c0255a767c280e7b4984f4c6709 Mon Sep 17 00:00:00 2001 From: Davyd Holovii Date: Thu, 19 Mar 2026 14:47:23 -0400 Subject: [PATCH 03/14] feat(frontend): add scoring settings controls and cached score panel - Add Settings UI and API wiring for scoring token limits\n- Support optional job metadata fields on Tailor upload\n- Surface cached match score details in resume builder --- apps/frontend/app/(default)/settings/page.tsx | 120 +++++++++++++++++- apps/frontend/app/(default)/tailor/page.tsx | 68 +++++++++- .../components/builder/resume-builder.tsx | 57 +++++++++ apps/frontend/lib/api/config.ts | 39 ++++++ apps/frontend/lib/api/resume.ts | 34 ++++- 5 files changed, 314 insertions(+), 4 deletions(-) diff --git a/apps/frontend/app/(default)/settings/page.tsx b/apps/frontend/app/(default)/settings/page.tsx index 4350f1bc1..5b48f48a1 100644 --- a/apps/frontend/app/(default)/settings/page.tsx +++ b/apps/frontend/app/(default)/settings/page.tsx @@ -11,6 +11,8 @@ import { updateFeatureConfig, fetchPromptConfig, updatePromptConfig, + fetchScoringConfig, + updateScoringConfig, clearAllApiKeys, resetDatabase, PROVIDER_INFO, @@ -124,6 +126,12 @@ export default function SettingsPage() { const [promptOptions, setPromptOptions] = useState([]); const [defaultPromptId, setDefaultPromptId] = useState('keywords'); + // Scoring token limits state + const [scoringCriterion, setScoringCriterion] = useState(1024); + const [scoringReasons, setScoringReasons] = useState(512); + const [scoringStatus, setScoringStatus] = useState('idle'); + const [scoringError, setScoringError] = useState(null); + // Danger Zone state const [showClearApiKeysDialog, setShowClearApiKeysDialog] = useState(false); const [showResetDatabaseDialog, setShowResetDatabaseDialog] = useState(false); @@ -235,10 +243,11 @@ export default function SettingsPage() { async function loadConfig() { try { - const [llmConfig, featureConfig, promptConfig] = await Promise.all([ + const [llmConfig, featureConfig, promptConfig, scoringConfig] = await Promise.all([ fetchLlmConfig().catch(() => null), fetchFeatureConfig().catch(() => null), fetchPromptConfig().catch(() => null), + fetchScoringConfig().catch(() => null), ]); if (cancelled) return; @@ -270,6 +279,11 @@ export default function SettingsPage() { setDefaultPromptId(promptConfig.default_prompt_id || 'keywords'); } + if (scoringConfig) { + setScoringCriterion(scoringConfig.max_tokens_criterion); + setScoringReasons(scoringConfig.max_tokens_reasons); + } + setStatus('idle'); } catch (err) { console.error('Failed to load settings', err); @@ -415,6 +429,26 @@ export default function SettingsPage() { } }; + // Save scoring token limits + const handleScoringConfigSave = async () => { + setScoringStatus('saving'); + setScoringError(null); + try { + const updated = await updateScoringConfig({ + max_tokens_criterion: scoringCriterion, + max_tokens_reasons: scoringReasons, + }); + setScoringCriterion(updated.max_tokens_criterion); + setScoringReasons(updated.max_tokens_reasons); + setScoringStatus('saved'); + setTimeout(() => setScoringStatus('idle'), 2000); + } catch (err) { + console.error('Failed to save scoring config', err); + setScoringError((err as Error).message || t('settings.errors.unableToSaveConfiguration')); + setScoringStatus('error'); + } + }; + // Handle Clear API Keys const handleClearApiKeys = async () => { setIsResetting(true); @@ -1013,6 +1047,90 @@ export default function SettingsPage() { + {/* Scoring Token Limits */} +
+
+ +

+ Scoring Token Limits +

+
+ +

+ Maximum tokens the LLM may output for each scoring call. Reasoning models (e.g. + gpt-5.x) consume tokens on internal thinking before producing visible output — raise + these values if you see "Empty response from LLM" errors. Range: 64–8192. +

+ +
+
+ +

+ Per-criterion score (×7 parallel calls) +

+ setScoringCriterion(Math.max(64, Math.min(8192, Number(e.target.value))))} + className="font-mono" + onKeyDown={(e) => { if (e.key === 'Enter') e.stopPropagation(); }} + /> +
+ +
+ +

+ 3–4 telegraphic reason phrases +

+ setScoringReasons(Math.max(64, Math.min(8192, Number(e.target.value))))} + className="font-mono" + onKeyDown={(e) => { if (e.key === 'Enter') e.stopPropagation(); }} + /> +
+ + +
+ +
+ + {scoringError && ( +

{scoringError}

+ )} +
+
+ {/* Danger Zone */}
diff --git a/apps/frontend/app/(default)/tailor/page.tsx b/apps/frontend/app/(default)/tailor/page.tsx index 2f44b37ec..848ad04cd 100644 --- a/apps/frontend/app/(default)/tailor/page.tsx +++ b/apps/frontend/app/(default)/tailor/page.tsx @@ -16,7 +16,8 @@ import { import { fetchPromptConfig, type PromptOption } from '@/lib/api/config'; import { Dropdown } from '@/components/ui/dropdown'; import { useStatusCache } from '@/lib/context/status-cache'; -import { Loader2, ArrowLeft, AlertTriangle, Settings } from 'lucide-react'; +import { Loader2, ArrowLeft, AlertTriangle, Settings, ChevronDown } from 'lucide-react'; +import { Input } from '@/components/ui/input'; import { useTranslations } from '@/lib/i18n'; import { DiffPreviewModal } from '@/components/tailor/diff-preview-modal'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -24,6 +25,10 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; export default function TailorPage() { const { t } = useTranslations(); const [jobDescription, setJobDescription] = useState(''); + const [jobCompany, setJobCompany] = useState(''); + const [jobTitle, setJobTitle] = useState(''); + const [jobUrl, setJobUrl] = useState(''); + const [showJobDetails, setShowJobDetails] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [masterResumeId, setMasterResumeId] = useState(null); @@ -150,7 +155,11 @@ export default function TailorPage() { try { // 1. Upload Job Description // The API expects an array of strings - const jobId = await uploadJobDescriptions([description], resumeId); + const jobId = await uploadJobDescriptions([description], resumeId, { + company: jobCompany.trim() || undefined, + title: jobTitle.trim() || undefined, + url: jobUrl.trim() || undefined, + }); incrementJobs(); // Update cached counter // 2. Preview Resume @@ -398,6 +407,61 @@ export default function TailorPage() {
+ {/* Optional job details expandable */} +
+ + {showJobDetails && ( +
+
+ + setJobCompany(e.target.value)} + disabled={isLoading} + /> +
+
+ + setJobTitle(e.target.value)} + disabled={isLoading} + /> +
+
+ + setJobUrl(e.target.value)} + disabled={isLoading} + /> +
+
+ )} +
+ {error && (
! {error} diff --git a/apps/frontend/components/builder/resume-builder.tsx b/apps/frontend/components/builder/resume-builder.tsx index 586610f23..9a8690779 100644 --- a/apps/frontend/components/builder/resume-builder.tsx +++ b/apps/frontend/components/builder/resume-builder.tsx @@ -39,6 +39,8 @@ import { generateCoverLetter, generateOutreachMessage, fetchJobDescription, + fetchScore, + type ScoreResult, } from '@/lib/api/resume'; import { JDComparisonView } from './jd-comparison-view'; import { RegenerateWizard } from './regenerate-wizard'; @@ -155,6 +157,10 @@ const ResumeBuilderContent = () => { // JD comparison state const [jobDescription, setJobDescription] = useState(null); + const [jobId, setJobId] = useState(null); + + // Score state + const [score, setScore] = useState(null); // AI Regenerate wizard const regenerateWizard = useRegenerateWizard({ @@ -370,17 +376,20 @@ const ResumeBuilderContent = () => { const data = await fetchJobDescription(resumeId); if (!cancelled) { setJobDescription(data.content); + setJobId(data.job_id); } } catch (err) { // JD might not be available for older resumes if (!cancelled) { console.warn('Could not fetch job description:', err); setJobDescription(null); + setJobId(null); } } } else { // Clear job description when switching to non-tailored resume setJobDescription(null); + setJobId(null); } }; @@ -390,6 +399,19 @@ const ResumeBuilderContent = () => { }; }, [isTailoredResume, resumeId]); + // Fetch cached score when resume+job are known + useEffect(() => { + let cancelled = false; + if (!resumeId || !jobId) { + setScore(null); + return; + } + fetchScore(resumeId, jobId) + .then((result) => { if (!cancelled) setScore(result); }) + .catch(() => { if (!cancelled) setScore(null); }); + return () => { cancelled = true; }; + }, [resumeId, jobId]); + const handleUpdate = useCallback((newData: ResumeData) => { setResumeData(newData); setHasUnsavedChanges(true); @@ -753,6 +775,41 @@ const ResumeBuilderContent = () => { {activeTab === 'resume' && ( <> + {score && ( +
+
+ + Match Score + + {score.label} +
+
+
+ {score.emoji} + + {score.score} + + / 100 +
+ {score.match_reasons && ( +

+ {score.match_reasons} +

+ )} + {Object.entries(score.red_flags).some(([, v]) => v.length > 0) && ( +
+ {Object.entries(score.red_flags).map(([flag, items]) => + items.length > 0 ? ( +

+ {flag} {items.join(', ')} +

+ ) : null + )} +
+ )} +
+
+ )} )} diff --git a/apps/frontend/lib/api/config.ts b/apps/frontend/lib/api/config.ts index 330ba0385..f9ad0a8b6 100644 --- a/apps/frontend/lib/api/config.ts +++ b/apps/frontend/lib/api/config.ts @@ -350,6 +350,45 @@ export async function clearAllApiKeys(): Promise { } } +// Scoring token limit configuration types +export interface ScoringConfig { + max_tokens_criterion: number; + max_tokens_reasons: number; +} + +export interface ScoringConfigUpdate { + max_tokens_criterion?: number; + max_tokens_reasons?: number; +} + +// Fetch scoring token limit configuration +export async function fetchScoringConfig(): Promise { + const res = await apiFetch('/config/scoring', { credentials: 'include' }); + + if (!res.ok) { + throw new Error(`Failed to load scoring config (status ${res.status}).`); + } + + return res.json(); +} + +// Update scoring token limit configuration +export async function updateScoringConfig(config: ScoringConfigUpdate): Promise { + const res = await apiFetch('/config/scoring', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(config), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || `Failed to update scoring config (status ${res.status}).`); + } + + return res.json(); +} + // Reset database export async function resetDatabase(): Promise { const res = await apiFetch('/config/reset', { diff --git a/apps/frontend/lib/api/resume.ts b/apps/frontend/lib/api/resume.ts index 35ec0995f..0a18c31f3 100644 --- a/apps/frontend/lib/api/resume.ts +++ b/apps/frontend/lib/api/resume.ts @@ -137,11 +137,15 @@ async function postImprove( /** Uploads job descriptions and returns a job_id */ export async function uploadJobDescriptions( descriptions: string[], - resumeId: string + resumeId: string, + meta?: { company?: string; title?: string; url?: string } ): Promise { const res = await apiPost('/jobs/upload', { job_descriptions: descriptions, resume_id: resumeId, + ...(meta?.company ? { company: meta.company } : {}), + ...(meta?.title ? { title: meta.title } : {}), + ...(meta?.url ? { url: meta.url } : {}), }); if (!res.ok) throw new Error(`Upload failed with status ${res.status}`); const data = await res.json(); @@ -374,3 +378,31 @@ export async function fetchJobDescription( } return res.json(); } + +export interface ScoreResult { + score_id: string; + resume_id: string; + job_id: string; + score: number; + ai_score: number; + match_reasons: string; + red_flags: Record; + label: string; + emoji: string; + color: string; + cached: boolean; + created_at: string; +} + +/** Fetches a cached score for a resume-job pair. Returns null if not scored yet. */ +export async function fetchScore(resumeId: string, jobId: string): Promise { + const res = await apiFetch( + `/scores/${encodeURIComponent(resumeId)}/${encodeURIComponent(jobId)}` + ); + if (res.status === 404) return null; + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Failed to fetch score (status ${res.status}): ${text}`); + } + return res.json(); +} From 8af13d80c95125cecfc0545af26d8ade753f6286 Mon Sep 17 00:00:00 2001 From: Davyd Holovii Date: Thu, 19 Mar 2026 14:47:29 -0400 Subject: [PATCH 04/14] docs(scoring): add scoring API usage and token-limit guidance - Document scoring flow, cache behavior, and tuning knobs in README\n- Add curl and .http examples for job upload/list/get endpoints --- README.md | 31 +++++++++++- commands.md | 70 ++++++++++++++++++++++++++ http-api/job-descriptions.http | 92 ++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 commands.md create mode 100644 http-api/job-descriptions.http diff --git a/README.md b/README.md index 5183cd858..5547354c5 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,36 @@ Generate tailored cover letters and email templates based on the job description ![Cover Letter](assets/cover_letter.png) -### Resume Scoring (In development feature) +### Resume Scoring -We are working on a resume scoring feature that will analyze your resume against the job description and provide a match score along with suggestions for improvement. +Resume Matcher scores your resume against a job description across 7 weighted criteria using AI, giving you an actionable match percentage and highlighting gaps before you apply. + +**How it works:** + +1. Upload your resume and a job description +2. Call `POST /api/v1/scores` with the `resume_id` and `job_id` +3. Receive a weighted score (0–100), a label (e.g. *Good Fit*, *Strong Contender*), red-flag signals, and 3–4 telegraphic match reasons + +**Scoring criteria** (weights are inferred from the job description by the LLM): + +| Criterion | Default weight | +|---|---| +| Technical Skills | 50 | +| Years of Experience | 20 | +| Soft Skills | 9 | +| Location | 50 | +| Education Level | 10 | +| Certifications | 5 | +| Language Proficiency | 5 | + +Results are cached in the database — repeated requests for the same resume/job pair return instantly. Delete a cached score with `DELETE /api/v1/scores/{resume_id}/{job_id}` to force a re-evaluation. + +**Token limits** — reasoning models (e.g. `gpt-5.x`) consume tokens on internal chain-of-thought before producing visible output. If you see *Empty response from LLM* errors, raise the token limits on the **Settings → Scoring Token Limits** page, or set the environment variables below: + +```env +SCORING_MAX_TOKENS_CRITERION=1024 # per-criterion call (×7 parallel) +SCORING_MAX_TOKENS_REASONS=512 # match-reason phrases +``` ![Resume Scoring and Keyword Highlight](assets/keyword_highlighter.png) diff --git a/commands.md b/commands.md new file mode 100644 index 000000000..eb1adb639 --- /dev/null +++ b/commands.md @@ -0,0 +1,70 @@ +# Upload job description + +```bash +curl -X POST \ + 'http://127.0.0.1:3000/api/v1/jobs/upload' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d @- <<'EOF' +{ + "job_descriptions": [ + "Airbnb was born in 2007 when two hosts welcomed three guests to their San Francisco home, and has since grown to over 5 million hosts who have welcomed over 2 billion guest arrivals in almost every country across the globe. Every day, hosts offer unique stays and experiences that make it possible for guests to connect with communities in a more authentic way. + +The Community You Will Join: + +Airbnb's mission is to create a world where anyone can belong anywhere. Essential to this mission is the ability to have authentic conversations with members of a global community. The Messaging Foundations team builds the platform that powers these conversations. As a Senior Software Engineer on the team, you will build innovative products, architect scalable solutions, collaborate across organizations, and help shape our roadmap - ensuring that our global community of guests and hosts can communicate seamlessly, wherever they are. + +The Difference You Will Make: + +You will build and operate the reliable, performant, and scalable infrastructure that powers all messaging experiences across Airbnb. You will build a platform that enables product teams across Airbnb to innovate new conversational experiences faster. You will support and deliver AI-powered features as part of the messaging intelligence platform, driving improvements in trip quality and reducing host effort through contextual assistance. + +A Typical Day: + +Lead multiple projects that improve the messaging experience +Mentor, guide, advocate and support the career growth of individual contributors +Write and review technical designs that solve large, open-ended foundational technical problems without clearly-known solutions +Collaborate with other engineers and cross-functional partners across Messaging and other organizations across the company to align on long-term technical solutions +Build knowledge of leading edge practices and trends +Drive key technical deliverables for the larger Communications organization +Your Expertise: + +5+ years of relevant engineering hands-on work experience +Bachelor's, Master's or PhD in CS or related field +Exceptional architecture abilities and experience with architectural patterns of large, high-scale applications +Shipped several large scale projects with multiple dependencies across teams +Has technical leadership and strong communication skills with ability to lead other experienced engineers +Your Location: + +This position is US - Remote Eligible. The role may include occasional work at an Airbnb office or attendance at offsites, as agreed to with your manager. While the position is Remote Eligible, you must live in a state where Airbnb, Inc. has a registered entity. Click here for the up-to-date list of excluded states. This list is continuously evolving, so please check back with us if the state you live in is on the exclusion list . If your position is employed by another Airbnb entity, your recruiter will inform you what states you are eligible to work from. + +Our Commitment To Inclusion & Belonging: + +Airbnb is committed to working with the broadest talent pool possible. We believe diverse ideas foster innovation and engagement, and allow us to attract creatively-led people, and to develop the best products, services and solutions. All qualified individuals are encouraged to apply. + +We strive to also provide a disability inclusive application and interview process. If you are a candidate with a disability and require reasonable accommodation in order to submit an application, please contact us at: reasonableaccommodations@airbnb.com. Please include your full name, the role you're applying for and the accommodation necessary to assist you with the recruiting process. + +We ask that you only reach out to us if you are a candidate whose disability prevents you from being able to complete our online application. + +How We'll Take Care of You: + +Our job titles may span more than one career level. The actual base pay is dependent upon many factors, such as: training, transferable skills, work experience, business needs and market demands. The base pay range is subject to change and may be modified in the future. This role may also be eligible for bonus, equity, benefits, and Employee Travel Credits. + +Pay Range +$185,000—$220,000 USD" + ], + "resume_id": "fe0c10c0-439a-477b-a24f-d8586555c267" +} +EOF +``` + +Resume Id: fe0c10c0-439a-477b-a24f-d8586555c267 +Job Id: feb52850-87c5-4c24-a960-6109f56ee2e3 + +# Job matching score + +```bash +curl -X POST "http://127.0.0.1:3000/api/v1/scores" \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{"resume_id":"fe0c10c0-439a-477b-a24f-d8586555c267", "job_id":"feb52850-87c5-4c24-a960-6109f56ee2e3"}' +``` diff --git a/http-api/job-descriptions.http b/http-api/job-descriptions.http new file mode 100644 index 000000000..8315196c0 --- /dev/null +++ b/http-api/job-descriptions.http @@ -0,0 +1,92 @@ +### Variables +@baseUrl = http://localhost:8000/api/v1 +@jobId = {{uploadJob.response.body.job_id[0]}} + +# ============================================================================= +# POST /v1/jobs/upload — Upload one or more job descriptions +# ============================================================================= + +### Upload a single job description (minimal) +# @name uploadJob +POST {{baseUrl}}/jobs/upload +Content-Type: application/json + +{ + "job_descriptions": [ + "We are looking for a Senior Python Backend Engineer to join our platform team. You will design and build scalable REST APIs using FastAPI, work with PostgreSQL and Redis, and collaborate closely with frontend engineers. Requirements: 5+ years Python, experience with async frameworks, Docker, and CI/CD pipelines. Nice to have: Kubernetes, OpenTelemetry." + ] +} + +### + +### Upload a single job description linked to a resume +POST {{baseUrl}}/jobs/upload +Content-Type: application/json + +{ + "job_descriptions": [ + "Seeking a Full-Stack Engineer with React and Node.js experience. You will own features end-to-end, from database schema design to pixel-perfect UI. Stack: TypeScript, React 19, Next.js, PostgreSQL, AWS. 3+ years of experience required." + ], + "resume_id": "your-resume-id-here" +} + +### + +### Upload multiple job descriptions in one request +POST {{baseUrl}}/jobs/upload +Content-Type: application/json + +{ + "job_descriptions": [ + "Machine Learning Engineer — build and productionise NLP models. Required: Python, PyTorch or TensorFlow, MLflow, experience with LLMs and RAG pipelines. Remote, full-time.", + "Data Engineer — design and maintain ELT pipelines on GCP. Required: SQL, dbt, BigQuery, Airflow or Prefect. 4+ years experience.", + "DevOps Engineer — own our Kubernetes platform on AWS EKS. Required: Terraform, Helm, ArgoCD, Prometheus/Grafana. Strong Linux background." + ], + "resume_id": "your-resume-id-here" +} + +# ============================================================================= +# GET /v1/jobs — List all uploaded job descriptions +# ============================================================================= + +### List all jobs +GET {{baseUrl}}/jobs + +# ============================================================================= +# GET /v1/jobs/{job_id} — Retrieve a job description by ID +# ============================================================================= + +### Get a job description by ID (chained from the upload above) +GET {{baseUrl}}/jobs/{{jobId}} + +### + +### Get a job description by a known ID +GET {{baseUrl}}/jobs/your-job-id-here + +# ============================================================================= +# Error cases +# ============================================================================= + +### 400 — empty job_descriptions array +POST {{baseUrl}}/jobs/upload +Content-Type: application/json + +{ + "job_descriptions": [] +} + +### + +### 400 — blank string in job_descriptions +POST {{baseUrl}}/jobs/upload +Content-Type: application/json + +{ + "job_descriptions": [" "] +} + +### + +### 404 — job not found +GET {{baseUrl}}/jobs/nonexistent-job-id From dcb55cfd118c809124f43c124644ee381d8871d6 Mon Sep 17 00:00:00 2001 From: Davyd Holovii Date: Thu, 19 Mar 2026 14:47:39 -0400 Subject: [PATCH 05/14] refactor(scoring): drop emoji fields from score API contract - Remove emoji from backend schema and score persistence\n- Use semantic red-flag keys (critical/major/minor)\n- Update frontend score types, UI rendering, and backend tests --- apps/backend/app/database.py | 1 - apps/backend/app/schemas/scoring.py | 1 - apps/backend/app/services/scorer.py | 111 +++++++++--------- apps/backend/tests/test_scoring.py | 31 ++--- .../components/builder/resume-builder.tsx | 1 - apps/frontend/lib/api/resume.ts | 1 - 6 files changed, 69 insertions(+), 77 deletions(-) diff --git a/apps/backend/app/database.py b/apps/backend/app/database.py index f0e3616a1..7357f124b 100644 --- a/apps/backend/app/database.py +++ b/apps/backend/app/database.py @@ -304,7 +304,6 @@ def create_score( "match_reasons": result.get("match_reasons", ""), "red_flags": result.get("red_flags", {}), "label": result.get("label", ""), - "emoji": result.get("emoji", ""), "color": result.get("color", ""), "created_at": now, } diff --git a/apps/backend/app/schemas/scoring.py b/apps/backend/app/schemas/scoring.py index 09e0bebba..a717a1620 100644 --- a/apps/backend/app/schemas/scoring.py +++ b/apps/backend/app/schemas/scoring.py @@ -21,7 +21,6 @@ class ScoreResult(BaseModel): match_reasons: str red_flags: dict[str, list[str]] label: str - emoji: str color: str cached: bool created_at: str diff --git a/apps/backend/app/services/scorer.py b/apps/backend/app/services/scorer.py index c18481b88..90fed809a 100644 --- a/apps/backend/app/services/scorer.py +++ b/apps/backend/app/services/scorer.py @@ -101,57 +101,57 @@ def _get_scoring_tokens() -> tuple[int, int]: # Score label lookup # --------------------------------------------------------------------------- -_SCORE_RANGES: list[tuple[int, int, str, str, str]] = [ - (100, 101, "🦄", "rainbow", "Legendary Unicorn"), - (99, 100, "🏆", "gold", "Dream Candidate"), - (98, 99, "🥇", "magenta", "Exceptional Fit"), - (97, 98, "🥈", "magenta", "Outstanding Candidate"), - (96, 97, "🥉", "magenta", "Superb Applicant"), - (95, 96, "🌟", "magenta", "Excellent Choice"), - (94, 95, "💫", "blue", "Top Prospect"), - (93, 94, "🌠", "blue", "Strong Contender"), - (92, 93, "✨", "blue", "Impressive Talent"), - (91, 92, "🌊", "cyan", "Highly Qualified"), - (90, 91, "💎", "cyan", "Great Potential"), - (88, 90, "💎", "cyan", "Very Promising"), - (86, 88, "🍀", "green", "Solid Candidate"), - (84, 86, "🌿", "green", "Good Fit"), - (82, 84, "🌴", "green", "Suitable Match"), - (80, 82, "🌱", "green", "Potential Hire"), - (78, 80, "🥑", "green", "Possible Fit"), - (76, 78, "🥝", "green", "Fair Prospect"), - (74, 76, "🥦", "green", "Moderate Match"), - (72, 74, "🌻", "yellow", "Average Candidate"), - (70, 72, "🌼", "yellow", "Partial Fit"), - (68, 70, "🌟", "yellow", "Limited Potential"), - (66, 68, "🍋", "yellow", "Weak Match"), - (64, 66, "🍌", "yellow", "Minimal Alignment"), - (62, 64, "🧀", "yellow", "Low Compatibility"), - (60, 62, "🌽", "yellow", "Needs Improvement"), - (58, 60, "🍯", "yellow", "Considerable Gap"), - (56, 58, "🍍", "yellow", "Poor Fit"), - (54, 56, "🍈", "yellow", "Significant Mismatch"), - (52, 54, "🍏", "yellow", "Major Differences"), - (50, 52, "🐤", "yellow", "Substantial Gap"), - (45, 50, "🍊", "red", "Unqualified Candidate"), - (40, 45, "🥕", "red", "Mismatched Skills"), - (35, 40, "🦊", "red", "Inadequate Fit"), - (30, 35, "🍎", "red", "Unsuitable Applicant"), - (25, 30, "🍓", "red", "Incompatible Match"), - (20, 25, "🍒", "red", "Irrelevant Background"), - (15, 20, "🍅", "red", "Completely Misaligned"), - (10, 15, "🌶️", "red", "Wrong Field"), - (5, 10, "🎱", "black", "Possibly Unsuitable"), - (0, 5, "🕷️", "black", "No Match"), +_SCORE_RANGES: list[tuple[int, int, str, str]] = [ + (100, 101, "rainbow", "Legendary Unicorn"), + (99, 100, "gold", "Dream Candidate"), + (98, 99, "magenta", "Exceptional Fit"), + (97, 98, "magenta", "Outstanding Candidate"), + (96, 97, "magenta", "Superb Applicant"), + (95, 96, "magenta", "Excellent Choice"), + (94, 95, "blue", "Top Prospect"), + (93, 94, "blue", "Strong Contender"), + (92, 93, "blue", "Impressive Talent"), + (91, 92, "cyan", "Highly Qualified"), + (90, 91, "cyan", "Great Potential"), + (88, 90, "cyan", "Very Promising"), + (86, 88, "green", "Solid Candidate"), + (84, 86, "green", "Good Fit"), + (82, 84, "green", "Suitable Match"), + (80, 82, "green", "Potential Hire"), + (78, 80, "green", "Possible Fit"), + (76, 78, "green", "Fair Prospect"), + (74, 76, "green", "Moderate Match"), + (72, 74, "yellow", "Average Candidate"), + (70, 72, "yellow", "Partial Fit"), + (68, 70, "yellow", "Limited Potential"), + (66, 68, "yellow", "Weak Match"), + (64, 66, "yellow", "Minimal Alignment"), + (62, 64, "yellow", "Low Compatibility"), + (60, 62, "yellow", "Needs Improvement"), + (58, 60, "yellow", "Considerable Gap"), + (56, 58, "yellow", "Poor Fit"), + (54, 56, "yellow", "Significant Mismatch"), + (52, 54, "yellow", "Major Differences"), + (50, 52, "yellow", "Substantial Gap"), + (45, 50, "red", "Unqualified Candidate"), + (40, 45, "red", "Mismatched Skills"), + (35, 40, "red", "Inadequate Fit"), + (30, 35, "red", "Unsuitable Applicant"), + (25, 30, "red", "Incompatible Match"), + (20, 25, "red", "Irrelevant Background"), + (15, 20, "red", "Completely Misaligned"), + (10, 15, "red", "Wrong Field"), + (5, 10, "black", "Possibly Unsuitable"), + (0, 5, "black", "No Match"), ] -def get_score_details(score: int) -> tuple[str, str, str]: - """Map a 0-100 score to (emoji, color, label).""" - for lo, hi, emoji, color, label in _SCORE_RANGES: +def get_score_details(score: int) -> tuple[str, str]: + """Map a 0-100 score to (color, label).""" + for lo, hi, color, label in _SCORE_RANGES: if lo <= score < hi: - return emoji, color, label - return "💀", "red", "Unable to score" + return color, label + return "red", "Unable to score" # --------------------------------------------------------------------------- @@ -260,7 +260,11 @@ async def _compute_ai_match( """ job_requirements = await extract_job_requirements(job_desc) if not job_requirements: - return {"score": 0, "match_reasons": "", "red_flags": {"🚩": [], "📍": [], "⛳": []}} + return { + "score": 0, + "match_reasons": "", + "red_flags": {"critical": [], "major": [], "minor": []}, + } emphasis = job_requirements.get("emphasis", {}) @@ -288,7 +292,7 @@ async def _compute_ai_match( scores = all_results[:len(_CRITERIA)] reasons_raw = all_results[len(_CRITERIA)] - red_flags: dict[str, list[str]] = {"🚩": [], "📍": [], "⛳": []} + red_flags: dict[str, list[str]] = {"critical": [], "major": [], "minor": []} total_weight = 0.0 total_score = 0.0 @@ -305,11 +309,11 @@ async def _compute_ai_match( if score < 10: if weight >= 30: - red_flags["🚩"].append(name) + red_flags["critical"].append(name) elif weight >= 20: - red_flags["📍"].append(name) + red_flags["major"].append(name) else: - red_flags["⛳"].append(name) + red_flags["minor"].append(name) total_score += (score * weight) / 100 @@ -380,7 +384,7 @@ async def score_resume(resume_id: str, job_id: str) -> dict[str, Any]: ) final_score = max(0, min(100, ai_result["score"])) - emoji, color, label = get_score_details(final_score) + color, label = get_score_details(final_score) result = { "score": final_score, @@ -388,7 +392,6 @@ async def score_resume(resume_id: str, job_id: str) -> dict[str, Any]: "match_reasons": ai_result["match_reasons"], "red_flags": ai_result["red_flags"], "label": label, - "emoji": emoji, "color": color, } diff --git a/apps/backend/tests/test_scoring.py b/apps/backend/tests/test_scoring.py index 84e400c83..b66385cb4 100644 --- a/apps/backend/tests/test_scoring.py +++ b/apps/backend/tests/test_scoring.py @@ -30,9 +30,7 @@ def test_create_and_get_score(self) -> None: "ai_score": 82, "match_reasons": "good", "red_flags": {}, - "label": "Good Fit", - "emoji": "🌿", "color": "green", } doc = self.db.create_score("r1", "j1", result) @@ -51,9 +49,7 @@ def test_get_score_cache_hit(self) -> None: "ai_score": 50, "match_reasons": "", "red_flags": {}, - "label": "Gap", - "emoji": "🐤", "color": "yellow", } self.db.create_score("r1", "j1", result) @@ -65,7 +61,7 @@ def test_get_score_cache_hit(self) -> None: def test_get_score_different_pair_is_miss(self) -> None: result = {"score": 70, "ai_score": 70, "match_reasons": "", "red_flags": {}, "label": "ok", - "emoji": "x", "color": "yellow"} + "color": "yellow"} self.db.create_score("r1", "j1", result) self.assertIsNone(self.db.get_score("r1", "j2")) self.assertIsNone(self.db.get_score("r2", "j1")) @@ -73,7 +69,7 @@ def test_get_score_different_pair_is_miss(self) -> None: def test_delete_score_removes_record(self) -> None: result = {"score": 60, "ai_score": 60, "match_reasons": "", "red_flags": {}, "label": "ok", - "emoji": "x", "color": "yellow"} + "color": "yellow"} self.db.create_score("r1", "j1", result) self.assertTrue(self.db.delete_score("r1", "j1")) self.assertIsNone(self.db.get_score("r1", "j1")) @@ -103,7 +99,6 @@ def test_score_result_fields(self) -> None: match_reasons="ok", red_flags={}, label="Fair", - emoji="🥝", color="green", cached=False, created_at="2026-01-01T00:00:00+00:00", @@ -121,7 +116,6 @@ def test_score_result_cached_flag(self) -> None: match_reasons="", red_flags={}, label="ok", - emoji="x", color="yellow", cached=True, created_at="2026-01-01T00:00:00+00:00", @@ -137,32 +131,32 @@ def test_score_result_cached_flag(self) -> None: class TestGetScoreDetails(unittest.TestCase): def test_perfect_score(self) -> None: from app.services.scorer import get_score_details - emoji, color, label = get_score_details(100) + color, label = get_score_details(100) self.assertEqual(label, "Legendary Unicorn") def test_zero_score(self) -> None: from app.services.scorer import get_score_details - emoji, color, label = get_score_details(0) + color, label = get_score_details(0) self.assertEqual(color, "black") def test_low_score(self) -> None: from app.services.scorer import get_score_details - emoji, color, label = get_score_details(3) + color, label = get_score_details(3) self.assertEqual(color, "black") def test_boundary_82(self) -> None: from app.services.scorer import get_score_details - _, _, label = get_score_details(82) + _, label = get_score_details(82) self.assertEqual(label, "Suitable Match") def test_mid_range(self) -> None: from app.services.scorer import get_score_details - _, color, _ = get_score_details(85) + color, _ = get_score_details(85) self.assertEqual(color, "green") def test_score_clamped_above_100_returns_legendary(self) -> None: from app.services.scorer import get_score_details - _, _, label = get_score_details(100) + _, label = get_score_details(100) self.assertEqual(label, "Legendary Unicorn") @@ -180,6 +174,8 @@ async def test_returns_parsed_dict(self) -> None: } with patch.object(scorer_module, "complete_json", AsyncMock(return_value=mock_result)): result = await scorer_module.extract_job_requirements("Job desc text") + if result is None: + self.fail("Expected parsed job requirements, got None") self.assertEqual(result["required_skills"], ["Python"]) async def test_returns_none_on_llm_failure(self) -> None: @@ -212,7 +208,6 @@ async def test_returns_cached_score_without_llm(self) -> None: "match_reasons": "good", "red_flags": {}, "label": "ok", - "emoji": "x", "color": "green", "cached": True, "created_at": "2026-01-01T00:00:00+00:00", @@ -267,13 +262,13 @@ async def test_full_score_path_saves_to_db(self) -> None: "score_id": "s1", "resume_id": "r1", "job_id": "j1", "score": 80, "ai_score": 80, "match_reasons": "good", "red_flags": {}, "label": "Good", - "emoji": "🍀", "color": "green", "created_at": "2026-01-01T00:00:00+00:00", + "color": "green", "created_at": "2026-01-01T00:00:00+00:00", } mock_db.create_score.return_value = saved_doc ai_result = { "score": 80, "match_reasons": "good | fit", - "red_flags": {"🚩": [], "📍": [], "⛳": []}, + "red_flags": {"critical": [], "major": [], "minor": []}, } with ( patch.object(scorer_module, "db", mock_db), @@ -303,7 +298,6 @@ async def test_post_scores_returns_result(self) -> None: "match_reasons": "good", "red_flags": {}, "label": "Good", - "emoji": "🍀", "color": "green", "cached": False, "created_at": "2026-01-01T00:00:00+00:00", @@ -324,7 +318,6 @@ async def test_get_score_returns_cached(self) -> None: "match_reasons": "good", "red_flags": {}, "label": "Good", - "emoji": "🍀", "color": "green", "cached": True, "created_at": "2026-01-01T00:00:00+00:00", diff --git a/apps/frontend/components/builder/resume-builder.tsx b/apps/frontend/components/builder/resume-builder.tsx index 9a8690779..2d9a93972 100644 --- a/apps/frontend/components/builder/resume-builder.tsx +++ b/apps/frontend/components/builder/resume-builder.tsx @@ -785,7 +785,6 @@ const ResumeBuilderContent = () => {
- {score.emoji} {score.score} diff --git a/apps/frontend/lib/api/resume.ts b/apps/frontend/lib/api/resume.ts index 0a18c31f3..1d2552ade 100644 --- a/apps/frontend/lib/api/resume.ts +++ b/apps/frontend/lib/api/resume.ts @@ -388,7 +388,6 @@ export interface ScoreResult { match_reasons: string; red_flags: Record; label: string; - emoji: string; color: string; cached: boolean; created_at: string; From 78e5023a2cabd4fc16b7f0efc2cf6255f5625ab8 Mon Sep 17 00:00:00 2001 From: Davyd Holovii Date: Thu, 19 Mar 2026 14:55:12 -0400 Subject: [PATCH 06/14] fix(frontend): show cached match score on resume preview page Move score display from the builder editor to the resume viewer page so tailored resumes show existing scoring context where users review output. Keep builder focused on editing and leave JD comparison behavior unchanged. --- .../app/(default)/resumes/[id]/page.tsx | 66 +++++++++++++++++++ .../components/builder/resume-builder.tsx | 56 ---------------- 2 files changed, 66 insertions(+), 56 deletions(-) diff --git a/apps/frontend/app/(default)/resumes/[id]/page.tsx b/apps/frontend/app/(default)/resumes/[id]/page.tsx index e2b6b6a79..fed1ddb40 100644 --- a/apps/frontend/app/(default)/resumes/[id]/page.tsx +++ b/apps/frontend/app/(default)/resumes/[id]/page.tsx @@ -12,6 +12,9 @@ import { deleteResume, retryProcessing, renameResume, + fetchJobDescription, + fetchScore, + type ScoreResult, } from '@/lib/api/resume'; import { useStatusCache } from '@/lib/context/status-cache'; import { ArrowLeft, Edit, Download, Loader2, AlertCircle, Sparkles, Pencil } from 'lucide-react'; @@ -43,6 +46,7 @@ export default function ResumeViewerPage() { const [resumeTitle, setResumeTitle] = useState(null); const [isEditingTitle, setIsEditingTitle] = useState(false); const [editingTitleValue, setEditingTitleValue] = useState(''); + const [score, setScore] = useState(null); const resumeId = params?.id as string; @@ -98,6 +102,34 @@ export default function ResumeViewerPage() { setIsMasterResume(localStorage.getItem('master_resume_id') === resumeId); }, [resumeId, t]); + useEffect(() => { + let cancelled = false; + + const loadScore = async () => { + if (!resumeId) { + setScore(null); + return; + } + + try { + const job = await fetchJobDescription(resumeId); + const cachedScore = await fetchScore(resumeId, job.job_id); + if (!cancelled) { + setScore(cachedScore); + } + } catch { + if (!cancelled) { + setScore(null); + } + } + }; + + loadScore(); + return () => { + cancelled = true; + }; + }, [resumeId]); + const handleRetryProcessing = async () => { if (!resumeId) return; setIsRetrying(true); @@ -342,6 +374,40 @@ export default function ResumeViewerPage() {
)} + {score && ( +
+
+ Match Score + {score.label} +
+
+
+ {score.score} + / 100 +
+ {score.match_reasons && ( +

+ {score.match_reasons} +

+ )} + {Object.entries(score.red_flags).some(([, items]) => items.length > 0) && ( +
+ {Object.entries(score.red_flags).map(([level, items]) => { + if (items.length === 0) return null; + const label = + level === 'critical' ? 'Critical' : level === 'major' ? 'Major' : 'Minor'; + return ( +

+ {label}: {items.join(', ')} +

+ ); + })} +
+ )} +
+
+ )} + {/* Resume Viewer */}
diff --git a/apps/frontend/components/builder/resume-builder.tsx b/apps/frontend/components/builder/resume-builder.tsx index 2d9a93972..586610f23 100644 --- a/apps/frontend/components/builder/resume-builder.tsx +++ b/apps/frontend/components/builder/resume-builder.tsx @@ -39,8 +39,6 @@ import { generateCoverLetter, generateOutreachMessage, fetchJobDescription, - fetchScore, - type ScoreResult, } from '@/lib/api/resume'; import { JDComparisonView } from './jd-comparison-view'; import { RegenerateWizard } from './regenerate-wizard'; @@ -157,10 +155,6 @@ const ResumeBuilderContent = () => { // JD comparison state const [jobDescription, setJobDescription] = useState(null); - const [jobId, setJobId] = useState(null); - - // Score state - const [score, setScore] = useState(null); // AI Regenerate wizard const regenerateWizard = useRegenerateWizard({ @@ -376,20 +370,17 @@ const ResumeBuilderContent = () => { const data = await fetchJobDescription(resumeId); if (!cancelled) { setJobDescription(data.content); - setJobId(data.job_id); } } catch (err) { // JD might not be available for older resumes if (!cancelled) { console.warn('Could not fetch job description:', err); setJobDescription(null); - setJobId(null); } } } else { // Clear job description when switching to non-tailored resume setJobDescription(null); - setJobId(null); } }; @@ -399,19 +390,6 @@ const ResumeBuilderContent = () => { }; }, [isTailoredResume, resumeId]); - // Fetch cached score when resume+job are known - useEffect(() => { - let cancelled = false; - if (!resumeId || !jobId) { - setScore(null); - return; - } - fetchScore(resumeId, jobId) - .then((result) => { if (!cancelled) setScore(result); }) - .catch(() => { if (!cancelled) setScore(null); }); - return () => { cancelled = true; }; - }, [resumeId, jobId]); - const handleUpdate = useCallback((newData: ResumeData) => { setResumeData(newData); setHasUnsavedChanges(true); @@ -775,40 +753,6 @@ const ResumeBuilderContent = () => { {activeTab === 'resume' && ( <> - {score && ( -
-
- - Match Score - - {score.label} -
-
-
- - {score.score} - - / 100 -
- {score.match_reasons && ( -

- {score.match_reasons} -

- )} - {Object.entries(score.red_flags).some(([, v]) => v.length > 0) && ( -
- {Object.entries(score.red_flags).map(([flag, items]) => - items.length > 0 ? ( -

- {flag} {items.join(', ')} -

- ) : null - )} -
- )} -
-
- )} )} From a5d100bf06f5f0d85e17b603d18acb17668c2ebb Mon Sep 17 00:00:00 2001 From: Davyd Holovii Date: Thu, 19 Mar 2026 15:24:33 -0400 Subject: [PATCH 07/14] feat: job metadata fields, PATCH endpoint, and score card below resume Backend: - Add PATCH /jobs/{job_id} to update company, title, url on a job - Add GET /scores/{resume_id} to fetch latest score without knowing job_id - Add db.list_scores_by_resume() for the new scores endpoint - Add JobUpdateRequest schema Frontend: - Tailor page: optional Job Details expandable (company, title, url) passed to uploadJobDescriptions on submit - Resume viewer: score card moved below resume, width matches resume (max-w-[250mm]); job title/company/url shown in card header with link to job posting; fetches job details via new fetchJob() helper Co-Authored-By: Claude Sonnet 4.6 --- apps/backend/app/database.py | 6 + apps/backend/app/routers/jobs.py | 13 ++- apps/backend/app/routers/scoring.py | 12 ++ apps/backend/app/schemas/__init__.py | 2 + apps/backend/app/schemas/models.py | 8 ++ .../app/(default)/resumes/[id]/page.tsx | 103 +++++++++++------- apps/frontend/lib/api/resume.ts | 24 ++++ 7 files changed, 129 insertions(+), 39 deletions(-) diff --git a/apps/backend/app/database.py b/apps/backend/app/database.py index 7357f124b..27485f8fa 100644 --- a/apps/backend/app/database.py +++ b/apps/backend/app/database.py @@ -310,6 +310,12 @@ def create_score( self.scores.insert(doc) return doc + def list_scores_by_resume(self, resume_id: str) -> list[dict[str, Any]]: + """Return all cached scores for a resume, sorted newest first.""" + Score = Query() + results = self.scores.search(Score.resume_id == resume_id) + return sorted(results, key=lambda s: s.get("created_at", ""), reverse=True) + def get_score(self, resume_id: str, job_id: str) -> dict[str, Any] | None: """Return the cached score for a resume-job pair, or None on miss.""" Score = Query() diff --git a/apps/backend/app/routers/jobs.py b/apps/backend/app/routers/jobs.py index 327b3905c..b952bead1 100644 --- a/apps/backend/app/routers/jobs.py +++ b/apps/backend/app/routers/jobs.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException from app.database import db -from app.schemas import JobDetail, JobSummary, JobUploadRequest, JobUploadResponse +from app.schemas import JobDetail, JobSummary, JobUpdateRequest, JobUploadRequest, JobUploadResponse router = APIRouter(prefix="/jobs", tags=["Jobs"]) @@ -93,3 +93,14 @@ async def get_job(job_id: str) -> JobDetail: raise HTTPException(status_code=404, detail="Job not found") return _to_detail(job) + + +@router.patch("/{job_id}", response_model=JobDetail) +async def update_job(job_id: str, request: JobUpdateRequest) -> JobDetail: + """Update optional metadata (company, title, url) on a job description.""" + if not db.get_job(job_id): + raise HTTPException(status_code=404, detail="Job not found") + + updates = {k: v for k, v in request.model_dump().items() if v is not None} + job = db.update_job(job_id, updates) + return _to_detail(job) diff --git a/apps/backend/app/routers/scoring.py b/apps/backend/app/routers/scoring.py index bca4c33fe..4ab56c8db 100644 --- a/apps/backend/app/routers/scoring.py +++ b/apps/backend/app/routers/scoring.py @@ -24,6 +24,18 @@ async def create_score(request: ScoreRequest) -> ScoreResult: return ScoreResult(**result) +@router.get("/{resume_id}", response_model=ScoreResult | None) +async def get_latest_score_for_resume(resume_id: str) -> ScoreResult | None: + """Return the most recent cached score for a resume, regardless of job. + + Returns null if no score has been computed for this resume yet. + """ + results = db.list_scores_by_resume(resume_id) + if not results: + return None + return ScoreResult(**{**results[0], "cached": True}) + + @router.get("/{resume_id}/{job_id}", response_model=ScoreResult) async def get_score(resume_id: str, job_id: str) -> ScoreResult: """Retrieve a cached score for a resume-job pair. diff --git a/apps/backend/app/schemas/__init__.py b/apps/backend/app/schemas/__init__.py index 872d8221d..ea997af63 100644 --- a/apps/backend/app/schemas/__init__.py +++ b/apps/backend/app/schemas/__init__.py @@ -22,6 +22,7 @@ ImproveResumeResponse, JobDetail, JobSummary, + JobUpdateRequest, JobUploadRequest, JobUploadResponse, LanguageConfigRequest, @@ -75,6 +76,7 @@ "ResumeListResponse", "JobDetail", "JobSummary", + "JobUpdateRequest", "JobUploadRequest", "JobUploadResponse", "ImproveResumeRequest", diff --git a/apps/backend/app/schemas/models.py b/apps/backend/app/schemas/models.py index bd4e32fa1..947e1bff0 100644 --- a/apps/backend/app/schemas/models.py +++ b/apps/backend/app/schemas/models.py @@ -462,6 +462,14 @@ class JobDetail(BaseModel): created_at: str +class JobUpdateRequest(BaseModel): + """Request to update optional metadata on a job description.""" + + company: str | None = None + title: str | None = None + url: str | None = None + + # Improvement Models class ImproveResumeRequest(BaseModel): """Request to improve/tailor a resume.""" diff --git a/apps/frontend/app/(default)/resumes/[id]/page.tsx b/apps/frontend/app/(default)/resumes/[id]/page.tsx index fed1ddb40..9093dd41b 100644 --- a/apps/frontend/app/(default)/resumes/[id]/page.tsx +++ b/apps/frontend/app/(default)/resumes/[id]/page.tsx @@ -12,9 +12,10 @@ import { deleteResume, retryProcessing, renameResume, - fetchJobDescription, - fetchScore, + fetchLatestScore, + fetchJob, type ScoreResult, + type JobDetail, } from '@/lib/api/resume'; import { useStatusCache } from '@/lib/context/status-cache'; import { ArrowLeft, Edit, Download, Loader2, AlertCircle, Sparkles, Pencil } from 'lucide-react'; @@ -47,6 +48,7 @@ export default function ResumeViewerPage() { const [isEditingTitle, setIsEditingTitle] = useState(false); const [editingTitleValue, setEditingTitleValue] = useState(''); const [score, setScore] = useState(null); + const [jobDetail, setJobDetail] = useState(null); const resumeId = params?.id as string; @@ -112,10 +114,13 @@ export default function ResumeViewerPage() { } try { - const job = await fetchJobDescription(resumeId); - const cachedScore = await fetchScore(resumeId, job.job_id); + const cachedScore = await fetchLatestScore(resumeId); if (!cancelled) { setScore(cachedScore); + if (cachedScore?.job_id) { + const job = await fetchJob(cachedScore.job_id); + if (!cancelled) setJobDetail(job); + } } } catch { if (!cancelled) { @@ -374,40 +379,6 @@ export default function ResumeViewerPage() {
)} - {score && ( -
-
- Match Score - {score.label} -
-
-
- {score.score} - / 100 -
- {score.match_reasons && ( -

- {score.match_reasons} -

- )} - {Object.entries(score.red_flags).some(([, items]) => items.length > 0) && ( -
- {Object.entries(score.red_flags).map(([level, items]) => { - if (items.length === 0) return null; - const label = - level === 'critical' ? 'Critical' : level === 'major' ? 'Major' : 'Minor'; - return ( -

- {label}: {items.join(', ')} -

- ); - })} -
- )} -
-
- )} - {/* Resume Viewer */}
@@ -435,6 +406,62 @@ export default function ResumeViewerPage() {
+ {score && ( +
+
+
+ Match Score + {score.label} +
+ {(jobDetail?.company || jobDetail?.title || jobDetail?.url) && ( +
+ {jobDetail.title && ( + {jobDetail.title} + )} + {jobDetail.company && ( + @ {jobDetail.company} + )} + {jobDetail.url && ( + + View posting ↗ + + )} +
+ )} +
+
+ {score.score} + / 100 +
+ {score.match_reasons && ( +

+ {score.match_reasons} +

+ )} + {Object.entries(score.red_flags).some(([, items]) => items.length > 0) && ( +
+ {Object.entries(score.red_flags).map(([level, items]) => { + if (items.length === 0) return null; + const label = + level === 'critical' ? 'Critical' : level === 'major' ? 'Major' : 'Minor'; + return ( +

+ {label}: {items.join(', ')} +

+ ); + })} +
+ )} +
+
+
+ )} +