Skip to content
Open

sync #853

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e820b4b
feat: add resume scoring feature with LiteLLM integration and TinyDB …
MiragePresent Mar 19, 2026
708c1af
feat(backend): add scoring config endpoints and job metadata APIs
MiragePresent Mar 19, 2026
f2f994d
feat(frontend): add scoring settings controls and cached score panel
MiragePresent Mar 19, 2026
8af13d8
docs(scoring): add scoring API usage and token-limit guidance
MiragePresent Mar 19, 2026
dcb55cf
refactor(scoring): drop emoji fields from score API contract
MiragePresent Mar 19, 2026
78e5023
fix(frontend): show cached match score on resume preview page
MiragePresent Mar 19, 2026
a5d100b
feat: job metadata fields, PATCH endpoint, and score card below resume
MiragePresent Mar 19, 2026
5989434
merge: upstream main into srbhr-main, keep fork scoring docs
MiragePresent Apr 17, 2026
a3f5055
Merge branch 'srbhr-main'
MiragePresent Apr 17, 2026
f5f3c7e
fix(backend): keep OpenRouter api_base /v1 path
MiragePresent Apr 17, 2026
67f1680
fix(timeout): remove request caps from scoring paths
MiragePresent Apr 17, 2026
02c38d3
fix(timeout): set API timeout ceilings to 8 minutes
MiragePresent Apr 17, 2026
c5deaf3
fix(deploy): build from local source to avoid stale runtime API
MiragePresent Apr 17, 2026
2c0c877
fix(frontend): remove invalid next experimental key for builds
MiragePresent Apr 17, 2026
9b28598
merge: upstream main into fork, port scoring to SQLAlchemy
MiragePresent Jun 17, 2026
dda031a
feat(scoring): add per-call candidate context to scoring endpoint
MiragePresent Jun 17, 2026
e46c809
fix(llm): improve JSON extraction robustness and truncation detection
MiragePresent Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,34 @@ Generate tailored cover letters based on the job description and your resume.

### Resume Scoring & Keyword Highlighting

Analyze your resume against the job description with a match score, keyword highlighting, and 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)

Expand Down
139 changes: 139 additions & 0 deletions ROADMAP-scoring.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions apps/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: scoring_max_tokens_* settings lack bounds validation on the Settings fallback path

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/config.py, line 152:

<comment>`scoring_max_tokens_*` settings lack bounds validation on the `Settings` fallback path</comment>

<file context>
@@ -146,6 +146,12 @@ def normalize_log_llm_level(cls, v: Any) -> str:
+    # 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
+
</file context>

scoring_max_tokens_reasons: int = 512

# Server Configuration
host: str = "0.0.0.0"
port: int = 8000
Expand Down
122 changes: 119 additions & 3 deletions apps/backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from app.config import settings
from app.db_engine import init_models_sync, make_async_engine, make_sync_engine
from app.models import ApiKey, Application, Improvement, Job, Resume
from app.models import ApiKey, Application, Improvement, Job, Resume, Score

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -361,20 +361,35 @@ async def set_master_resume(self, resume_id: str) -> bool:

# -- Job operations -----------------------------------------------------

async def create_job(self, content: str, resume_id: str | None = None) -> dict[str, Any]:
async 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 = _now()
metadata: dict[str, Any] = {}
if company is not None:
metadata["company"] = company
if title is not None:
metadata["title"] = title
if url is not None:
metadata["url"] = url
async with self._session() as session:
session.add(
Job(job_id=job_id, content=content, resume_id=resume_id, created_at=now, metadata_json={})
Job(job_id=job_id, content=content, resume_id=resume_id, created_at=now, metadata_json=metadata)
)
await session.commit()
return {
"job_id": job_id,
"content": content,
"resume_id": resume_id,
"created_at": now,
**metadata,
}

async def get_job(self, job_id: str) -> dict[str, Any] | None:
Expand All @@ -383,6 +398,12 @@ async def get_job(self, job_id: str) -> dict[str, Any] | None:
row = await session.get(Job, job_id)
return self._job_to_dict(row) if row else None

async def list_jobs(self) -> list[dict[str, Any]]:
"""List all job descriptions."""
async with self._session() as session:
result = await session.execute(select(Job).order_by(Job.created_at))
return [self._job_to_dict(row) for row in result.scalars().all()]

async def update_job(
self, job_id: str, updates: dict[str, Any]
) -> dict[str, Any] | None:
Expand Down Expand Up @@ -726,6 +747,100 @@ def replace_api_keys(self, ciphertexts: dict[str, str]) -> None:
)
session.commit()

# -- Score operations (resume-job match cache) -------------------------

@staticmethod
def _score_to_dict(row: Score) -> dict[str, Any]:
return {
"score_id": row.score_id,
"resume_id": row.resume_id,
"job_id": row.job_id,
"score": row.score,
"ai_score": row.ai_score,
"match_reasons": row.match_reasons,
"red_flags": row.red_flags,
"label": row.label,
"color": row.color,
"created_at": row.created_at,
}

async 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 = _now()
async with self._session() as session:
session.add(
Score(
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", {}),
label=result.get("label", ""),
color=result.get("color", ""),
created_at=now,
)
)
await session.commit()
return {
"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", {}),
"label": result.get("label", ""),
"color": result.get("color", ""),
"created_at": now,
}

async def list_scores_by_resume(self, resume_id: str) -> list[dict[str, Any]]:
"""Return all cached scores for a resume, sorted newest first."""
async with self._session() as session:
result = await session.execute(
select(Score)
.where(Score.resume_id == resume_id)
.order_by(Score.created_at.desc())
)
return [self._score_to_dict(row) for row in result.scalars().all()]

async 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."""
async with self._session() as session:
result = await session.execute(
select(Score).where(
Score.resume_id == resume_id, Score.job_id == job_id
)
)
row = result.scalars().first()
return self._score_to_dict(row) if row else None

async 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.
"""
async with self._session() as session:
result = await session.execute(
select(Score).where(
Score.resume_id == resume_id, Score.job_id == job_id
)
)
row = result.scalars().first()
if row is None:
return False
await session.delete(row)
await session.commit()
return True

# -- Stats / maintenance ------------------------------------------------

async def get_stats(self) -> dict[str, Any]:
Expand Down Expand Up @@ -756,6 +871,7 @@ async def reset_database(self) -> None:
"""
async with self._session() as session:
await session.execute(delete(Application))
await session.execute(delete(Score))
await session.execute(delete(Improvement))
await session.execute(delete(Job))
await session.execute(delete(Resume))
Expand Down
Loading