chore: import upstream snapshot with attribution
Publish Docker Image / publish (push) Has been cancelled
Publish Docker Image / publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
# CLAUDE.md - Resume Matcher
|
||||
|
||||
> **Context file for Claude Code.** Full documentation at [docs/agent/README.md](../docs/agent/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
Resume Matcher is an AI-powered application for tailoring resumes to job descriptions, with a Kanban Application Tracker for managing the job-application pipeline.
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| **Backend** | FastAPI + Python 3.13+, LiteLLM (multi-provider AI) |
|
||||
| **Frontend** | Next.js 16 + React 19, Tailwind CSS v4 |
|
||||
| **Database** | SQLite (SQLAlchemy 2.0 async / aiosqlite) |
|
||||
| **PDF** | Headless Chromium via Playwright |
|
||||
|
||||
---
|
||||
|
||||
## First Steps
|
||||
|
||||
Before exploring code, read [docs/agent/README.md](../docs/agent/README.md) for project orientation.
|
||||
|
||||
---
|
||||
|
||||
## Non-Negotiable Rules
|
||||
|
||||
1. **All frontend UI changes** MUST follow [Swiss International Style](../docs/portable/swiss-design-system/README.md) — see [tokens](../docs/portable/swiss-design-system/tokens.md), [components](../docs/portable/swiss-design-system/components.md), [anti-patterns](../docs/portable/swiss-design-system/anti-patterns.md)
|
||||
2. **All Python functions** MUST have type hints
|
||||
3. **Run `npm run lint`** before committing frontend changes
|
||||
4. **Run `npm run format`** (Prettier) before committing
|
||||
5. **Log detailed errors server-side**, return generic messages to clients
|
||||
6. **Do NOT modify** `.github/workflows/` files without explicit request
|
||||
|
||||
---
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
# Backend (from repo root)
|
||||
cd apps/backend
|
||||
uv sync --extra dev # Install Python deps (incl. test deps)
|
||||
uv run uvicorn app.main:app --reload --port 8000 # FastAPI on :8000
|
||||
uv run pytest # Run backend tests (~444; LLM evals excluded)
|
||||
|
||||
# Frontend (from repo root, in a separate terminal)
|
||||
cd apps/frontend
|
||||
npm install # Install Node.js dependencies
|
||||
npm run dev # Next.js on :3000
|
||||
npm run test # Run frontend tests (vitest)
|
||||
|
||||
# Quality checks (from apps/frontend)
|
||||
npm run lint # Lint frontend
|
||||
npm run format # Format with Prettier
|
||||
|
||||
# Build (from apps/frontend)
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/
|
||||
├── backend/ # FastAPI + Python
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # Entry point
|
||||
│ │ ├── config.py # Environment settings
|
||||
│ │ ├── database.py # Async SQLAlchemy/SQLite facade
|
||||
│ │ ├── models.py # SQLAlchemy ORM models (Resume/Job/Improvement/Application/ApiKey)
|
||||
│ │ ├── db_engine.py # Async + sync SQLite engines (WAL/FK pragmas)
|
||||
│ │ ├── crypto.py # Fernet encrypt/decrypt for API keys at rest
|
||||
│ │ ├── llm.py # LiteLLM wrapper
|
||||
│ │ ├── routers/ # API endpoints (incl. applications.py = tracker)
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── schemas/ # Pydantic models (incl. applications.py)
|
||||
│ │ ├── prompts/ # LLM prompt templates
|
||||
│ │ └── scripts/ # One-time TinyDB→SQLite migration (runs on startup)
|
||||
│ └── data/ # resume_matcher.db (SQLite) + encrypted API keys + .secret_key
|
||||
│
|
||||
└── frontend/ # Next.js + React
|
||||
├── app/ # Pages (dashboard, builder, tailor, tracker, print)
|
||||
├── components/ # UI components (incl. tracker/)
|
||||
├── lib/ # Utilities, API client (incl. api/tracker.ts)
|
||||
├── hooks/ # Custom React hooks
|
||||
└── messages/ # i18n translations (en, es, zh, ja, pt)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation by Task
|
||||
|
||||
### For Backend Changes
|
||||
1. [Backend guide](../docs/agent/architecture/backend-guide.md) - Architecture, modules, services
|
||||
2. [API contracts](../docs/agent/apis/front-end-apis.md) - API specifications
|
||||
3. [LLM integration](../docs/agent/llm-integration.md) - Multi-provider AI support
|
||||
|
||||
### For Frontend Changes
|
||||
1. [Frontend workflow](../docs/agent/architecture/frontend-workflow.md) - User flow, components
|
||||
2. [Swiss design system pack](../docs/portable/swiss-design-system/README.md) - **REQUIRED** Swiss International Style (portable pack)
|
||||
3. [Next.js performance pack](../docs/portable/nextjs-performance/README.md) - **REQUIRED** Next.js 15 perf patterns (portable pack)
|
||||
4. [Coding standards](../docs/agent/coding-standards.md) - Frontend conventions
|
||||
|
||||
### For Testing
|
||||
1. [Testing strategy](../docs/agent/testing-strategy.md) - Current-state assessment, framework, phased plan, how to run + how we verify (anti-theater)
|
||||
|
||||
### For Template/PDF Changes
|
||||
1. [PDF template guide](../docs/agent/design/pdf-template-guide.md) - PDF rendering
|
||||
2. [Template system](../docs/agent/design/template-system.md) - Resume templates
|
||||
3. [Resume templates](../docs/agent/features/resume-templates.md) - Template types & controls
|
||||
|
||||
### For Features
|
||||
| Feature | Documentation |
|
||||
|---------|---------------|
|
||||
| Application tracker | [application-tracker.md](../docs/agent/features/application-tracker.md) |
|
||||
| Custom sections | [custom-sections.md](../docs/agent/features/custom-sections.md) |
|
||||
| Resume templates | [resume-templates.md](../docs/agent/features/resume-templates.md) |
|
||||
| i18n | [i18n.md](../docs/agent/features/i18n.md) |
|
||||
| AI enrichment | [enrichment.md](../docs/agent/features/enrichment.md) |
|
||||
| JD matching | [jd-match.md](../docs/agent/features/jd-match.md) |
|
||||
|
||||
---
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Backend Error Handling
|
||||
```python
|
||||
except Exception as e:
|
||||
logger.error(f"Operation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail="Operation failed. Please try again.")
|
||||
```
|
||||
|
||||
### Frontend Textarea Fix
|
||||
All textareas need Enter key handling:
|
||||
```tsx
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
};
|
||||
```
|
||||
|
||||
### Mutable Defaults (Python)
|
||||
Always use `copy.deepcopy()` for mutable defaults:
|
||||
```python
|
||||
import copy
|
||||
data = copy.deepcopy(DEFAULT_DATA) # Correct
|
||||
# data = DEFAULT_DATA # Wrong - shared state bug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Both apps have real test suites, and **tests are in scope** (deliberate testing initiative — full plan in [docs/agent/testing-strategy.md](../docs/agent/testing-strategy.md)).
|
||||
|
||||
| Suite | Stack | Run |
|
||||
|-------|-------|-----|
|
||||
| Backend | pytest + pytest-asyncio + httpx + respx | `cd apps/backend && uv run pytest` |
|
||||
| Frontend | vitest + Testing Library (jsdom) | `cd apps/frontend && npm run test` |
|
||||
|
||||
- **Backend layers:** `tests/unit` (pure logic), `tests/service` (mocked LLM), `tests/integration` (real routers via httpx ASGI), `tests/evals` (prompt-quality scorers + a gated LLM-judge — excluded by default; run with `uv run pytest -m eval`).
|
||||
- **Local push gate (not CI):** a `pre-push` hook (`.githooks/pre-push`) runs the backend suite + a locale-parity check and **blocks red pushes**. Activate once per clone: `git config core.hooksPath .githooks`. We deliberately avoid a GitHub Actions PR gate (high external-PR volume) — see [`.githooks/README.md`](../.githooks/README.md).
|
||||
- Keep tests **deterministic and anti-theater**: a test must fail when its target breaks, and the default suites make no real network/LLM calls.
|
||||
|
||||
---
|
||||
|
||||
## Design System Quick Reference
|
||||
|
||||
| Element | Value |
|
||||
|---------|-------|
|
||||
| Canvas background | `#F0F0E8` |
|
||||
| Ink (text) | `#000000` |
|
||||
| Hyper Blue (links) | `#1D4ED8` |
|
||||
| Signal Green (success) | `#15803D` |
|
||||
| Alert Orange (warning) | `#F97316` |
|
||||
| Alert Red (error) | `#DC2626` |
|
||||
| Headers font | `font-serif` |
|
||||
| Body font | `font-sans` |
|
||||
| Metadata font | `font-mono` |
|
||||
| Borders | `rounded-none`, 1px black, hard shadows |
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Before completing a task:
|
||||
|
||||
- [ ] Code compiles without errors
|
||||
- [ ] Backend tests pass (`uv run pytest`); frontend tests pass (`npm run test`)
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] UI changes follow Swiss International Style
|
||||
- [ ] Python functions have type hints
|
||||
- [ ] Schema/prompt changes documented
|
||||
- [ ] New behavior covered by a deterministic test (it must fail if the behavior breaks)
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Do NOT modify without explicit request:
|
||||
- `.github/workflows/` files
|
||||
- CI/CD configuration
|
||||
- Docker build behavior
|
||||
- Existing tests (removal/disabling)
|
||||
|
||||
---
|
||||
|
||||
> **Full agent documentation**: [docs/agent/README.md](../docs/agent/README.md)
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker-compose ps:*)",
|
||||
"Bash(docker-compose logs:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(uv run python:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(cd:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(export PATH=\"/usr/local/bin:/opt/homebrew/bin:$PATH\")",
|
||||
"Bash(wc:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(head:*)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(python -m py_compile:*)",
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(gh pr diff:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\")",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm --version)",
|
||||
"Bash(source:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Node
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/out
|
||||
|
||||
# Python
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/*.egg-info
|
||||
**/.venv
|
||||
**/venv
|
||||
|
||||
# Environment files (use UI to configure in Docker)
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Documentation (not needed in image)
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
assets/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Test files
|
||||
**/tests/
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
|
||||
# Data directory (mounted as volume)
|
||||
apps/backend/data/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker files themselves
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Config file with API keys - NEVER COMMIT
|
||||
config.json
|
||||
**/config.json
|
||||
@@ -0,0 +1,57 @@
|
||||
# Local git hooks (`.githooks/`)
|
||||
|
||||
Version-controlled git hooks for Resume-Matcher. We **do not** run a
|
||||
PR-triggered GitHub Actions test workflow (the repo gets a high volume of
|
||||
external contributor PRs, and CI would run on every one of them). Instead, the
|
||||
maintainer's local clone gates pushes with a `pre-push` hook — a "local CI" that
|
||||
keeps `main`/`dev` green without touching contributor PRs.
|
||||
|
||||
## What runs
|
||||
|
||||
`pre-push` runs before every `git push` and **blocks the push if anything is red**:
|
||||
|
||||
1. **Backend test suite** — `uv run pytest` in `apps/backend` (~8s). Deterministic;
|
||||
the LLM-as-judge evals are excluded by default (`addopts -m "not eval"`), so
|
||||
it makes **no network/LLM calls**.
|
||||
2. **Frontend locale parity** — `scripts/check_locale_parity.py` verifies every
|
||||
`apps/frontend/messages/*.json` has the same key structure as `en.json`.
|
||||
Pure Python (no Node/npm/nvm). This guards the exact i18n mismatch that once
|
||||
broke `next build` and only surfaced post-merge in the Docker job.
|
||||
3. **Frontend test suite** — `vitest run` in `apps/frontend`, but only when Node
|
||||
and the local vitest binary are present (git hooks may run without nvm's
|
||||
`node` on `PATH`); otherwise skipped with a warning. A full `tsc`/`next build`
|
||||
is intentionally not run here.
|
||||
|
||||
All checks always run, so you see **all** failures at once.
|
||||
|
||||
## Activate (once per clone)
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
That's it — hooks are now active for this clone. (It's a local git setting; it
|
||||
does not affect anyone else who clones the repo, by design.)
|
||||
|
||||
## Everyday use
|
||||
|
||||
- Commit freely — the gate runs at **push**, not on every commit.
|
||||
- If the gate fails, the push is aborted and the failures are printed. Fix them and push again.
|
||||
|
||||
## Escape hatches
|
||||
|
||||
```bash
|
||||
git push --no-verify # bypass the gate once (docs-only / WIP branches)
|
||||
git config --unset core.hooksPath # disable the hooks entirely
|
||||
```
|
||||
|
||||
## Run the checks manually
|
||||
|
||||
```bash
|
||||
cd apps/backend && uv run pytest # backend suite
|
||||
python3 scripts/check_locale_parity.py # locale parity (from repo root)
|
||||
cd apps/frontend && npm run test # frontend suite (vitest)
|
||||
```
|
||||
|
||||
See [`docs/agent/testing-strategy.md`](../docs/agent/testing-strategy.md) for the
|
||||
full testing strategy this gate enforces.
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local pre-push gate for Resume-Matcher — the "local CI" that replaces a
|
||||
# PR-triggered GitHub Actions workflow (we deliberately avoid CI on PRs because
|
||||
# the repo gets a high volume of external contributor PRs).
|
||||
#
|
||||
# Runs BEFORE every `git push` and BLOCKS the push if anything is red:
|
||||
# 1. backend test suite (uv run pytest — LLM/eval tests excluded by default)
|
||||
# 2. frontend locale parity (pure-Python check; guards the i18n build break)
|
||||
# 3. frontend test suite (vitest — runs when Node is available, else skipped)
|
||||
#
|
||||
# Activate once per clone: git config core.hooksPath .githooks
|
||||
# Bypass intentionally: git push --no-verify (e.g. docs-only / WIP branch)
|
||||
# Disable entirely: git config --unset core.hooksPath
|
||||
#
|
||||
# Notes:
|
||||
# - Both checks always run so you see ALL failures, not just the first.
|
||||
# - The backend suite is ~8s and makes NO network/LLM calls (evals are opt-in).
|
||||
# - The frontend vitest suite runs only when Node is on PATH (git hooks may run
|
||||
# without nvm's node); a full `tsc`/`next build` is intentionally NOT run here.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
status=0
|
||||
|
||||
printf '\n── pre-push gate ────────────────────────────────────────\n'
|
||||
|
||||
# 1) Backend test suite ----------------------------------------------------
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
echo "▶ backend : uv run pytest"
|
||||
if ! ( cd "$REPO_ROOT/apps/backend" && uv run pytest -q -p no:cacheprovider ); then
|
||||
echo " ✗ backend tests failed" >&2
|
||||
status=1
|
||||
fi
|
||||
else
|
||||
echo " ✗ 'uv' not found — cannot run backend tests (install uv, or push with --no-verify)" >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# 2) Frontend locale parity (no Node required) -----------------------------
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
echo "▶ frontend : locale parity (messages/*.json vs en.json)"
|
||||
if ! python3 "$REPO_ROOT/scripts/check_locale_parity.py"; then
|
||||
status=1
|
||||
fi
|
||||
else
|
||||
echo " ⚠ python3 not found — skipping locale parity check" >&2
|
||||
fi
|
||||
|
||||
# 3) Frontend test suite (vitest) — only when Node + the local binary are present
|
||||
# (git hooks may run without nvm's node on PATH; skip rather than hard-fail).
|
||||
VITEST="$REPO_ROOT/apps/frontend/node_modules/.bin/vitest"
|
||||
if command -v node >/dev/null 2>&1 && [ -x "$VITEST" ]; then
|
||||
echo "▶ frontend : vitest run"
|
||||
if ! ( cd "$REPO_ROOT/apps/frontend" && "$VITEST" run ); then
|
||||
echo " ✗ frontend tests failed" >&2
|
||||
status=1
|
||||
fi
|
||||
else
|
||||
echo " ⚠ node or vitest not available — skipping frontend tests" >&2
|
||||
fi
|
||||
|
||||
printf '─────────────────────────────────────────────────────────\n'
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "✗ pre-push gate FAILED — push aborted. Fix the above, or bypass with: git push --no-verify" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ pre-push gate passed — pushing."
|
||||
exit 0
|
||||
@@ -0,0 +1,132 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[srbh077@gmail.com](mailto:srbh077@gmail.com).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,207 @@
|
||||
# Contributing to Resume-Matcher on GitHub
|
||||
|
||||
Thank you for taking the time to contribute to [Resume-Matcher](https://github.com/srbhr/Resume-Matcher).
|
||||
|
||||
We want you to have a great experience making your first contribution.
|
||||
|
||||
This contribution could be anything from a small fix to a typo in our
|
||||
documentation or a full feature.
|
||||
|
||||
Tell us what you enjoy working on and we would love to help!
|
||||
|
||||
If you would like to contribute, but don't know where to start, check the
|
||||
issues that are labeled
|
||||
`good first issue`
|
||||
or
|
||||
`help wanted`.
|
||||
|
||||
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make are greatly appreciated.
|
||||
|
||||
The development branch is `main`. This is the branch where all pull requests should be made.
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Please try to create bug reports that are:
|
||||
|
||||
- Reproducible. Include steps to reproduce the problem.
|
||||
- Specific. Include as much detail as possible: which version, what environment, etc.
|
||||
- Unique. Do not duplicate existing opened issues.
|
||||
- Scoped to a Single Bug. One bug per report.
|
||||
|
||||
## Testing
|
||||
|
||||
Please test your changes before submitting the PR.
|
||||
|
||||
## Good First Issues
|
||||
|
||||
We have a list of `help wanted` and `good first issue` that contains small features and bugs with a relatively limited scope. Nevertheless, this is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||
|
||||
## Development
|
||||
|
||||
Follow these steps to set up the environment and run the application.
|
||||
|
||||
## How to install
|
||||
|
||||
1. Fork the repository [here](https://github.com/srbhr/Resume-Matcher/fork).
|
||||
|
||||
2. Clone the forked repository.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<YOUR-USERNAME>/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
```
|
||||
|
||||
3. Create a Python Virtual Environment:
|
||||
|
||||
- Using [virtualenv](https://learnpython.com/blog/how-to-use-virtualenv-python/):
|
||||
|
||||
_Note_: Check how to install virtualenv on your system here [link](https://learnpython.com/blog/how-to-use-virtualenv-python/).
|
||||
|
||||
```bash
|
||||
virtualenv env
|
||||
```
|
||||
|
||||
**OR**
|
||||
|
||||
- Create a Python Virtual Environment:
|
||||
|
||||
```bash
|
||||
python -m venv env
|
||||
```
|
||||
|
||||
4. Activate the Virtual Environment.
|
||||
|
||||
- On Windows.
|
||||
|
||||
```bash
|
||||
env\Scripts\activate
|
||||
```
|
||||
|
||||
- On macOS and Linux.
|
||||
|
||||
```bash
|
||||
source env/bin/activate
|
||||
```
|
||||
|
||||
**OPTIONAL (For pyenv users)**
|
||||
|
||||
Run the application with pyenv (Refer to this [article](https://realpython.com/intro-to-pyenv/#installing-pyenv))
|
||||
|
||||
- Build dependencies (on ubuntu)
|
||||
```
|
||||
sudo apt-get install -y make build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev libffi-dev liblzma-dev python openssl
|
||||
```
|
||||
```
|
||||
|
||||
sudo apt-get install build-essential zlib1g-dev libffi-dev libssl-dev libbz2-dev libreadline-dev libsqlite3-dev liblzma-dev libncurses-dev
|
||||
|
||||
sudo apt-get install python-tk python3-tk tk-dev
|
||||
|
||||
sudo apt-get install build-essential zlib1g-dev libffi-dev libssl-dev libbz2-dev libreadline-dev libsqlite3-dev liblzma-dev
|
||||
|
||||
```
|
||||
|
||||
- pyenv installer
|
||||
```
|
||||
curl https://pyenv.run | bash
|
||||
```
|
||||
- Install desired python version
|
||||
```
|
||||
pyenv install -v 3.11.0
|
||||
```
|
||||
|
||||
- pyenv with virtual enviroment
|
||||
```
|
||||
pyenv virtualenv 3.11.0 venv
|
||||
```
|
||||
|
||||
- Activate virtualenv with pyenv
|
||||
```
|
||||
pyenv activate venv
|
||||
```
|
||||
|
||||
5. Install Dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
6. Prepare Data:
|
||||
|
||||
- Resumes: Place your resumes in PDF format in the `Data/Resumes` folder. Remove any existing contents in this folder.
|
||||
- Job Descriptions: Place your job descriptions in PDF format in the `Data/JobDescription` folder. Remove any existing contents in this folder.
|
||||
|
||||
7. Parse Resumes to JSON:
|
||||
|
||||
```python
|
||||
python run_first.py
|
||||
```
|
||||
|
||||
8. Run the Application:
|
||||
|
||||
```python
|
||||
streamlit run streamlit_app.py
|
||||
```
|
||||
|
||||
**Note**: For local versions, you do not need to run "streamlit_second.py" as it is specifically for deploying to Streamlit servers.
|
||||
|
||||
**Additional Note**: The Vector Similarity part is precomputed to optimize performance due to the resource-intensive nature of sentence encoders that require significant GPU and RAM resources. If you are interested in leveraging this feature in a Google Colab environment for free, refer to the upcoming blog (link to be provided) for further guidance.
|
||||
|
||||
<br/>
|
||||
|
||||
### Docker
|
||||
|
||||
1. Build the image and start application
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
2. Open `localhost:80` on your browser
|
||||
|
||||
<br/>
|
||||
|
||||
### Running the Web Application
|
||||
|
||||
The full stack Next.js (React and FastAPI) web application allows users to interact with the Resume Matcher tool interactively via a web browser.
|
||||
|
||||
To run the full stack web application (frontend client and backend api servers), follow the instructions over on the [webapp README](/webapp/README.md) file.
|
||||
|
||||
## Code Formatting
|
||||
|
||||
This project uses [Black](https://black.readthedocs.io/en/stable/) for code formatting. We believe this helps to keep the code base consistent and reduces the cognitive load when reading code.
|
||||
|
||||
Before submitting your pull request, please make sure your changes are in accordance with the Black style guide. You can format your code by running the following command in your terminal:
|
||||
|
||||
```sh
|
||||
black .
|
||||
```
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
We also use [pre-commit](https://pre-commit.com/) to automatically check for common issues before commits are submitted. This includes checks for code formatting with Black.
|
||||
|
||||
If you haven't already, please install the pre-commit hooks by running the following command in your terminal:
|
||||
|
||||
```sh
|
||||
pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
Now, the pre-commit hooks will automatically run every time you commit your changes. If any of the hooks fail, the commit will be aborted.
|
||||
|
||||
## Join Us, Contribute!
|
||||
|
||||
Pull Requests & Issues are not just welcomed, they're celebrated! Let's create together.
|
||||
|
||||
🎉 Join our lively [Discord](https://dsc.gg/resume-matcher) community and discuss away!
|
||||
|
||||
💡 Spot a problem? Create an issue!
|
||||
|
||||
👩💻 Dive in and help resolve existing [issues](https://github.com/srbhr/Resume-Matcher/issues).
|
||||
|
||||
🔔 Share your thoughts in our [Discussions & Announcements](https://github.com/srbhr/Resume-Matcher/discussions).
|
||||
|
||||
🚀 Explore and improve our [Landing Page](https://github.com/srbhr/website-for-resume-matcher). PRs always welcome!
|
||||
|
||||
📚 Contribute to the [Resume Matcher Docs](https://github.com/srbhr/Resume-Matcher-Docs) and help people get started with using the software.
|
||||
@@ -0,0 +1,2 @@
|
||||
github: srbhr
|
||||
custom: ["https://github.com/sponsors/srbhr"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Issue Title
|
||||
<!-- Provide a concise and descriptive title for the issue -->
|
||||
|
||||
## Type
|
||||
<!-- Check the relevant options by putting an "x" in the brackets -->
|
||||
|
||||
- [ ] Big
|
||||
- [ ] Feature Request
|
||||
- [ ] Info
|
||||
- [ ] Bug
|
||||
- [ ] Documentation
|
||||
- [ ] Other (please specify):
|
||||
|
||||
## Description
|
||||
<!-- Describe the issue in detail. What problem are you experiencing or what feature would you like to see added? -->
|
||||
|
||||
## Expected Behavior
|
||||
<!-- Describe what you expected to happen when encountering the issue -->
|
||||
|
||||
## Current Behavior
|
||||
<!-- Describe what is currently happening due to the issue -->
|
||||
|
||||
## Steps to Reproduce
|
||||
<!-- If applicable, provide a step-by-step guide to reproducing the issue -->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Screenshots / Code Snippets (if applicable)
|
||||
<!-- Include any relevant screenshots or code snippets that can help understand the issue better -->
|
||||
|
||||
## Environment
|
||||
<!-- Provide details about your environment -->
|
||||
- Operating System:
|
||||
- Browser (if applicable):
|
||||
- Version/Commit ID (if applicable):
|
||||
|
||||
## Possible Solution (if you have any in mind)
|
||||
<!-- If you have an idea about how to fix the issue, please share it here -->
|
||||
|
||||
## Additional Information
|
||||
<!-- Add any other information about the issue that you think might be helpful -->
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: When I click the button, nothing happens...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Please provide details about your OS, Browser version, or Device.
|
||||
value: |
|
||||
- OS: [e.g. macOS / Windows / iOS]
|
||||
- Browser / Version: [e.g. Chrome 100]
|
||||
- Device: [e.g. iPhone 14]
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,15 @@
|
||||
# This prevents users from clicking "Open new issue" without picking a template first.
|
||||
blank_issues_enabled: false
|
||||
|
||||
contact_links:
|
||||
- name: 💬 Ask a Question (Discussions)
|
||||
url: https://github.com/srbhr/Resume-Matcher/discussions
|
||||
about: Please ask usage questions or discuss ideas here before opening a formal issue.
|
||||
|
||||
- name: 🔐 Report a Security Vulnerability
|
||||
url: https://github.com/srbhr/Resume-Matcher/security/advisories/new
|
||||
about: Please do not open public issues for security vulnerabilities.
|
||||
|
||||
- name: 📖 Read the Documentation
|
||||
url: https://github.com/srbhr/Resume-Matcher/blob/master/README.md
|
||||
about: Check the docs to see if your answer is already there.
|
||||
@@ -0,0 +1,12 @@
|
||||
name: Custom / Other
|
||||
description: Describe this issue template's purpose here.
|
||||
title: ""
|
||||
labels: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please describe your issue.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a new feature!
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
placeholder: I am frustrated when...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,50 @@
|
||||
# Pull Request Title
|
||||
<!-- Provide a concise and descriptive title for the pull request -->
|
||||
|
||||
## Related Issue
|
||||
<!-- If this pull request is related to an issue, please link it here using the "#" symbol followed by the issue number (e.g., #123) -->
|
||||
|
||||
## Description
|
||||
<!-- Describe the changes made in this pull request. What problem does it solve or what feature does it add/modify? -->
|
||||
copilot:summary
|
||||
|
||||
## Type
|
||||
<!-- Check the relevant options by putting an "x" in the brackets -->
|
||||
|
||||
- [ ] Bug Fix
|
||||
- [ ] Feature Enhancement
|
||||
- [ ] Documentation Update
|
||||
- [ ] Code Refactoring
|
||||
- [ ] Other (please specify):
|
||||
|
||||
## Proposed Changes
|
||||
<!-- List the specific changes made in this pull request -->
|
||||
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Screenshots / Code Snippets (if applicable)
|
||||
<!-- Include any relevant screenshots or code snippets that help visualize the changes made -->
|
||||
|
||||
## How to Test
|
||||
<!-- Provide step-by-step instructions or a checklist for testing the changes in this pull request -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Checklist
|
||||
<!-- Put an "x" in the brackets for the items that apply to this pull request -->
|
||||
|
||||
- [ ] The code compiles successfully without any errors or warnings
|
||||
- [ ] The changes have been tested and verified
|
||||
- [ ] The documentation has been updated (if applicable)
|
||||
- [ ] The changes follow the project's coding guidelines and best practices
|
||||
- [ ] The commit messages are descriptive and follow the project's guidelines
|
||||
- [ ] All tests (if applicable) pass successfully
|
||||
- [ ] This pull request has been linked to the related issue (if applicable)
|
||||
|
||||
## Additional Information
|
||||
<!-- Add any other information about the pull request that you think might be helpful -->
|
||||
copilot:walkthrough
|
||||
@@ -0,0 +1,9 @@
|
||||
# Responsible Disclosure
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Resume-Matcher strives to stay ahead of security vulnerabilities but would love to get the community's help in making us aware of the ones we miss.
|
||||
|
||||
Please contact a maintainer to report security vulnerabilities and exploits.
|
||||
|
||||
We will acknowledge legitimate reports and address them according to their severity.
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HAS_DOCKERHUB: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ env.HAS_DOCKERHUB != '' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/srbhr/resume-matcher
|
||||
${{ env.HAS_DOCKERHUB != '' && 'srbhr/resume-matcher' || '' }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}},value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ github.ref_name }},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# .gitignore (in your-project-root/apps/backend/)
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
activate_this.py
|
||||
|
||||
# Python Bytecode and Caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
uv.lock
|
||||
|
||||
# SQLite databases
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Encryption secret for API keys at rest - NEVER COMMIT
|
||||
**/data/.secret_key
|
||||
|
||||
# Test artifacts
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
|
||||
# Local instance or config files specific to backend
|
||||
instance/
|
||||
.env # Backend specific environment variables
|
||||
|
||||
# Config file with API keys - NEVER COMMIT
|
||||
**/config.json
|
||||
apps/backend/data/config.json
|
||||
|
||||
# cursor related files
|
||||
.cursor/
|
||||
.cursorrules
|
||||
|
||||
# Claude Code — track only CLAUDE.md and settings.json, ignore everything else
|
||||
# (skills, subagents, thoughts, prompts, sessions are all personal workflow)
|
||||
.claude/*
|
||||
!.claude/CLAUDE.md
|
||||
!.claude/settings.json
|
||||
skills-lock.json
|
||||
|
||||
# AI assistant tool folders (personal workflow, not tracked)
|
||||
.agents/
|
||||
.github/agents/
|
||||
.github/skills/
|
||||
.github/prompts/
|
||||
.github/copilot-instructions.md
|
||||
.kilocode/
|
||||
.gemini/
|
||||
.codex/
|
||||
.firecrawl/
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
CODEX.md
|
||||
|
||||
.DS_Store
|
||||
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.idea/
|
||||
|
||||
# Config file with API keys - NEVER COMMIT
|
||||
config.json
|
||||
**/config.json
|
||||
|
||||
# Agentic E2E monitor — runtime evidence bundles and the local-only skill
|
||||
/artifacts/
|
||||
/.claude/skills/monitor-e2e/
|
||||
|
||||
# Superpowers brainstorming visual-companion scratch (local only)
|
||||
/.superpowers/
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Design Context — Resume Matcher
|
||||
|
||||
This file is the source of truth for design decisions. All `impeccable:*` skills (audit, critique, polish, animate, etc.) read this before making any visual changes.
|
||||
|
||||
> Last updated: 2026-04-09
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
Resume Matcher serves three overlapping audiences, all of whom land on the same UI:
|
||||
|
||||
- **Anxious job seekers** — individuals tailoring resumes during active job hunts, often in the evening or late at night. They are the largest group. They want fast, calming, low-friction help — not AI hype, not clever features they have to learn. The interface should reduce their stress, not amplify it.
|
||||
- **Tech-savvy DIY users** — developers, designers, and technical PMs who run things locally with Ollama. They notice and reward craft. They will silently judge a generic SaaS aesthetic and respect a distinctive one. They forgive complexity if it feels intentional.
|
||||
- **Students and early-career applicants** — first-time job seekers who need confidence and clarity. The interface should feel encouraging without being patronizing or playful. It should make them feel like adults using a serious tool.
|
||||
|
||||
**Common job-to-be-done**: take an existing resume + a job description and produce a tailored resume + cover letter that the user can ship as a PDF. Speed and trustworthiness matter more than power-user features.
|
||||
|
||||
**Use context**: usually a desktop or laptop browser. Often used in 30–60 minute focused sessions. Frequently in the evening. Rarely on mobile (resume editing is a desktop task), but the marketing/landing surfaces still need to read on mobile.
|
||||
|
||||
---
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Three words: **Confident · Honest · Crafted**
|
||||
|
||||
- **Confident** — quiet confidence, not loud. The interface doesn't need to shout that it's powerful. A confident UI does its job and trusts the user to notice the care.
|
||||
- **Honest** — no inflated copy, no AI hype, no decorative ornament that doesn't earn its place. Status messages are direct. Errors say what's wrong. Buttons say what they do.
|
||||
- **Crafted** — every pixel feels intentional. Typography, spacing, alignment, and color choices all reward closer inspection. The kind of interface that makes a designer say "someone cared about this."
|
||||
|
||||
The product should feel like a **well-designed printed object** translated to the browser — a museum exhibit caption, a fabric label on a well-made jacket, a printed monograph — rather than a SaaS web app.
|
||||
|
||||
---
|
||||
|
||||
## Aesthetic Direction
|
||||
|
||||
### Foundation: Swiss International Style
|
||||
|
||||
The existing visual identity is documented in [`docs/portable/swiss-design-system/`](docs/portable/swiss-design-system/README.md). That pack is the canonical reference for tokens, components, layouts, anti-patterns, and the AI prompt template. Read it before making any visual change.
|
||||
|
||||
**Non-negotiable from that pack**:
|
||||
- `rounded-none` everywhere
|
||||
- Hard offset shadows only — never blurred
|
||||
- Three-font hierarchy (serif headers, sans body, mono labels uppercase)
|
||||
- Canvas (`#F0F0E8`) as the page background — never pure white
|
||||
- Hyper Blue (`#1D4ED8`) used sparingly for one primary action per region
|
||||
|
||||
### Pull toward brutalist/raw
|
||||
|
||||
The current implementation is conservative within the Swiss frame. Push it harder:
|
||||
|
||||
- Bigger type contrast — a section header should look 3× as important as body, not 1.2×
|
||||
- More poster-like moments on landing/marketing surfaces (oversized hero text, asymmetric grids, intentional whitespace as a graphic element)
|
||||
- Status squares and labels should feel like industrial signage, not UI chrome
|
||||
|
||||
### Pull toward refined minimal
|
||||
|
||||
The current implementation also has some accumulated SaaS habits to strip. Pull the other direction:
|
||||
|
||||
- Restrict the accent palette ruthlessly. Hyper Blue should feel rare. Most screens should be black ink on Canvas, with one moment of color.
|
||||
- Strip the half-finished dark mode (see Theme below).
|
||||
- Replace decorative icons with mono functional ones, or with nothing.
|
||||
- Prefer typography hierarchy over decorative containers. Not everything needs a card.
|
||||
|
||||
### Theme
|
||||
|
||||
**Light theme only.** The dark mode mapping in `globals.css` is half-finished and dilutes the brand. It should be removed in the audit cleanup. Swiss style works best on the warm Canvas background; that's the brand.
|
||||
|
||||
The `.dark` block in `globals.css` will be flagged for removal.
|
||||
|
||||
### References (for the right feel)
|
||||
|
||||
- A printed monograph from a design publisher (Lars Müller, Phaidon)
|
||||
- A 1970s technical manual cover
|
||||
- A museum exhibit caption card
|
||||
- An off-white linen book jacket
|
||||
|
||||
### Anti-references (what this should NOT look like)
|
||||
|
||||
- Generic SaaS resume builders (Resume.io, Zety, etc. — soft pastel palettes, rounded everything, decorative icons, "AI ✨" badges)
|
||||
- Vercel/Linear/Stripe template-y look (elegant but no longer distinctive — every YC company looks like this)
|
||||
- Glassmorphism, gradient text, neon-on-dark, pastel cyan/purple AI palettes
|
||||
- Hero-metric template ("3M+ resumes optimized · ⭐ 4.9 stars · 50+ countries")
|
||||
- Card carousels, decorative sparklines, identical icon-headed cards
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
Five rules that should guide every design decision in this codebase. When in doubt, read these.
|
||||
|
||||
### 1. Hard edges, soft warmth
|
||||
|
||||
The geometry is brutally hard (`rounded-none`, hard shadows, 1–2px black borders). The background is warm (Canvas `#F0F0E8`, not pure white). This combination is the brand: confident structure, no clinical chill. Never ship pure white surfaces. Never ship rounded corners.
|
||||
|
||||
### 2. One primary action per region
|
||||
|
||||
Hyper Blue is rare on purpose. Each logical screen region (a card, a panel, a page) should have at most one primary blue action. Everything else is outline, ghost, or text-only. If a screen has two blue buttons, one of them is wrong.
|
||||
|
||||
### 3. Type does the heavy lifting
|
||||
|
||||
Hierarchy comes from weight and size, not from color or boxes. A section header should be 2.5–3× the body size with bold weight. A label should be small monospace uppercase with tracked-wider letter-spacing. The type system carries the design — decoration is forbidden.
|
||||
|
||||
### 4. Asymmetric, anti-centered layouts
|
||||
|
||||
Left-aligned by default. Whitespace varies for hierarchy (more space above an important header, less above a continuation paragraph). Symmetric center-aligned content is the lazy default — push toward asymmetric compositions where the eye is pulled deliberately.
|
||||
|
||||
### 5. No AI decorative tells
|
||||
|
||||
The full anti-pattern list is in [`docs/portable/swiss-design-system/anti-patterns.md`](docs/portable/swiss-design-system/anti-patterns.md). The non-negotiable bans:
|
||||
|
||||
- **No gradient text** ever (`background-clip: text` + gradient)
|
||||
- **No side-stripe borders** (`border-left: Npx solid color` for accent stripes)
|
||||
- **No glassmorphism** (decorative blur, glow borders)
|
||||
- **No gradients of any kind** in surface backgrounds
|
||||
- **No decorative icons** — only functional, mono-color
|
||||
- **No bounce/elastic easing** — exponential decel only
|
||||
- **No animating layout properties** — transform/opacity only
|
||||
- **No nested cards**, no card carousels, no hero-metric templates
|
||||
- **No bare emoji** in product UI, no `✨` badges
|
||||
|
||||
If any of these appear, that's a bug.
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Stack**: Next.js 16 (App Router), React 19, Tailwind CSS v4, TypeScript 5
|
||||
- **Forbidden font families** (per impeccable + brand): Inter, Roboto, Open Sans, system defaults, **Space Grotesk** (currently in use — flagged for replacement), DM Sans/Serif, Plus Jakarta Sans, Outfit, Fraunces, Newsreader, Playfair Display, Cormorant, IBM Plex *, Instrument *
|
||||
- **Accessibility target**: **WCAG 2.2 AA**. 4.5:1 text contrast minimum, full keyboard navigation, visible focus indicators, ARIA on interactive elements, semantic landmarks.
|
||||
- **Performance budgets**: Next.js 16 App Router conventions; no barrel imports from `lucide-react`; heavy components (TipTap, dnd-kit) lazy-loaded; First Load JS under 250KB per route.
|
||||
- **i18n**: en, es, zh, ja currently supported — text length varies, layouts must accommodate longer translations.
|
||||
- **PDF rendering**: print stylesheet exists in `globals.css`; resume preview must render identically to the printed PDF.
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Resume Matcher Docker Image
|
||||
# Multi-stage build for optimized image size
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Build Frontend
|
||||
# ============================================
|
||||
FROM node:22-bookworm AS frontend-builder
|
||||
|
||||
# Build argument for API URL (allows customization at build time)
|
||||
# Default routes requests through Next.js rewrites on the same origin.
|
||||
ARG NEXT_PUBLIC_API_URL=/
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY apps/frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy frontend source
|
||||
COPY apps/frontend/ ./
|
||||
|
||||
# Build the frontend
|
||||
RUN npm run build
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Final Image
|
||||
# ============================================
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
# Playwright dependencies
|
||||
libnss3 \
|
||||
libnspr4 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libasound2 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libatspi2.0-0 \
|
||||
libgtk-3-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy Node.js runtime from frontend builder for reproducible runtime behavior.
|
||||
COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node
|
||||
|
||||
# ============================================
|
||||
# Backend Setup
|
||||
# ============================================
|
||||
COPY apps/backend/pyproject.toml /app/backend/
|
||||
COPY apps/backend/app /app/backend/app
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install .
|
||||
|
||||
# ============================================
|
||||
# Frontend Setup
|
||||
# ============================================
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy standalone frontend runtime from builder stage
|
||||
COPY --from=frontend-builder /app/frontend/.next/standalone ./
|
||||
COPY --from=frontend-builder /app/frontend/.next/static ./.next/static
|
||||
COPY --from=frontend-builder /app/frontend/public ./public
|
||||
|
||||
# ============================================
|
||||
# Startup Script
|
||||
# ============================================
|
||||
COPY docker/start.sh /app/start.sh
|
||||
# Convert CRLF to LF (fixes Windows line ending issues) and make executable
|
||||
RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh
|
||||
|
||||
# ============================================
|
||||
# Data Directory & Volume
|
||||
# ============================================
|
||||
RUN mkdir -p /app/backend/data
|
||||
|
||||
# Create a non-root user for security
|
||||
RUN useradd -m -u 1000 appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
# Install Playwright Chromium as appuser (so browsers are in correct location)
|
||||
RUN python -m playwright install chromium
|
||||
|
||||
# Expose the public port (backend remains internal on 8000)
|
||||
EXPOSE 3000
|
||||
|
||||
# Volume for persistent data
|
||||
VOLUME ["/app/backend/data"]
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Health check on internal backend port only (independent of host port mapping).
|
||||
HEALTHCHECK --interval=10s --timeout=10s --start-period=30s --retries=5 \
|
||||
CMD curl -f http://127.0.0.1:8000/api/v1/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["/app/start.sh"]
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://www.resumematcher.fyi)
|
||||
|
||||
# Resume Matcher
|
||||
|
||||
[English](README.md) | **Español** | [简体中文](README.zh-CN.md) | [日本語](README.ja.md)
|
||||
|
||||
[𝙹𝚘𝚒𝚗 𝙳𝚒𝚜𝚌𝚘𝚛𝚍](https://dsc.gg/resume-matcher) ✦ [𝚆𝚎𝚋𝚜𝚒𝚝𝚎](https://resumematcher.fyi) ✦ [𝙷𝚘𝚠 𝚝𝚘 𝙸𝚗𝚜𝚝𝚊𝚕𝚕](https://resumematcher.fyi/docs/installation) ✦ [𝙲𝚘𝚗𝚝𝚛𝚒𝚋𝚞𝚝𝚘𝚛𝚜](#contributors) ✦ [𝚂𝚙𝚘𝚗𝚜𝚘𝚛](#sponsors) ✦ [𝚃𝚠𝚒𝚝𝚝𝚎𝚛/𝚇](https://twitter.com/srbhrai) ✦ [𝙻𝚒𝚗𝚔𝚎𝚍𝙸𝚗](https://www.linkedin.com/company/resume-matcher/) ✦ [𝙲𝚛𝚎𝚊𝚝𝚘𝚛](https://srbhr.com)
|
||||
|
||||
**Deja de ser rechazado automáticamente por los bots ATS.** Resume Matcher es la plataforma impulsada por IA que aplica ingeniería inversa a los algoritmos de contratación para mostrarte exactamente cómo adaptar tu currículum. Obtén las palabras clave, el formato y los conocimientos que realmente te ayudarán a superar el primer filtro y llegar a manos humanas.
|
||||
|
||||
Esperamos convertir esto en **el VS Code para crear currículums**.
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
  
|
||||
|
||||
[](https://dsc.gg/resume-matcher) [](https://resumematcher.fyi) [](https://www.linkedin.com/company/resume-matcher/)
|
||||
|
||||
<a href="https://trendshift.io/repositories/565" target="_blank"><img src="https://trendshift.io/api/badge/repositories/565" alt="srbhr%2FResume-Matcher | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> El proyecto necesita tu ayuda y apoyo. Si puedes donar una pequeña cantidad, me ayudarás a seguir desarrollando y mejorando Resume Matcher.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/sponsors/srbhr) [](https://www.buymeacoffee.com/srbhr)
|
||||
|
||||
**¿Patrocinas en nombre de una empresa?** Coloca tu logo ante más de 27k desarrolladores → **[conviértete en patrocinador ↓](#sponsors)**
|
||||
|
||||
</div>
|
||||
|
||||
## Primeros pasos
|
||||
|
||||
Resume Matcher funciona creando un currículum maestro que puedes usar para adaptar cada postulación. Instrucciones de instalación aquí: [Cómo instalar](#how-to-install)
|
||||
|
||||
### Cómo funciona
|
||||
|
||||
1. **Sube** tu currículum maestro (PDF o DOCX)
|
||||
2. **Pega** la descripción del puesto al que apuntas
|
||||
3. **Revisa** mejoras y contenido adaptado generado por IA
|
||||
4. **Genera** carta de presentación y plantillas de email para la postulación
|
||||
5. **Personaliza** el diseño y las secciones a tu estilo
|
||||
6. **Exporta** como PDF profesional con tu plantilla preferida
|
||||
|
||||
### Mantente conectado
|
||||
|
||||
[](https://dsc.gg/resume-matcher)
|
||||
|
||||
Únete a nuestro [Discord](https://dsc.gg/resume-matcher) para discusiones, solicitudes de funcionalidades y soporte de la comunidad.
|
||||
|
||||
[](https://www.linkedin.com/company/resume-matcher/)
|
||||
|
||||
Síguenos en [LinkedIn](https://www.linkedin.com/company/resume-matcher/) para actualizaciones.
|
||||
|
||||

|
||||
|
||||
Dale una estrella al repositorio para apoyar el desarrollo y recibir notificaciones de nuevas versiones.
|
||||
|
||||
<a id="sponsors"></a>
|
||||
|
||||
## Patrocinadores
|
||||
|
||||

|
||||
|
||||
Resume Matcher es libre y de código abierto, y se mantiene gracias a sus patrocinadores y colaboradores. Si te resulta útil, considera apoyar su desarrollo.
|
||||
|
||||
### Empresas que respaldan Resume Matcher
|
||||
|
||||
Patrocina con un nivel de empresa y **tu logo + enlace + descripción aparecerán aquí** — ante una comunidad de **más de 27k estrellas y 4.9k forks**, destacada en [Trendshift](https://trendshift.io/repositories/565) y el [Programa Vercel OSS](https://vercel.com/oss).
|
||||
|
||||
| Patrocinador | Descripción |
|
||||
|---------|-------------|
|
||||
| [APIDECK](https://apideck.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Una API para conectar tu aplicación con más de 200 plataformas SaaS (contabilidad, HRIS, CRM, almacenamiento de archivos). Crea integraciones una vez, no 50. 🌐 [apideck.com](https://apideck.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Vercel](https://vercel.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Resume Matcher es parte del programa Vercel OSS // Summer 2025 🌐 [vercel.com](https://vercel.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Cubic.dev](https://cubic.dev?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Cubic ofrece revisiones de PR para Resume Matcher 🌐 [cubic.dev](https://cubic.dev?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Kilo Code](https://kilo.ai?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Kilo Code proporciona revisiones de código de IA y créditos de codificación a Resume Matcher 🌐 [kilo.ai](https://kilo.ai?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [ZanReal](https://zanreal.com/?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | ZanReal es una empresa de desarrollo impulsada por IA que crea soluciones cloud escalables, desde la estrategia y la UX hasta DevOps, ayudando a los equipos a lanzar más rápido y convertir ideas en producción. 🌐 [zanreal.com](https://zanreal.com/?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| **✦ Tu empresa aquí** | Llega a más de 27k desarrolladores y 4.9k forks. **[Conviértete en patrocinador →](https://github.com/sponsors/srbhr)** |
|
||||
|
||||
Por favor lee nuestra [Sponsorship Guide](https://resumematcher.fyi/docs/sponsoring) para detalles de cómo tu patrocinio ayuda al proyecto. Recibirás un agradecimiento especial en el ReadME y en nuestro sitio web.
|
||||
|
||||
<a id="support-the-development-by-donating"></a>
|
||||
|
||||
### Apoya como particular
|
||||
|
||||

|
||||
|
||||
Cada aporte mantiene Resume Matcher gratis y financia nuevas funciones — y recibirás un agradecimiento en el ReadME y en nuestro sitio web.
|
||||
|
||||
| Plataforma | Enlace |
|
||||
|-----------|--------|
|
||||
| GitHub | [](https://github.com/sponsors/srbhr) |
|
||||
| Buy Me a Coffee | [](https://www.buymeacoffee.com/srbhr) |
|
||||
|
||||
## Nota del Creador
|
||||
|
||||
Gracias por visitar Resume Matcher. Si quieres conectar, colaborar o simplemente saludar, ¡no dudes en contactarme!
|
||||
~ **Saurabh Rai** ✨
|
||||
|
||||
Puedes seguirme en:
|
||||
|
||||
- Website: [https://srbhr.com](https://srbhr.com)
|
||||
- Linkedin: [https://www.linkedin.com/in/srbhr/](https://www.linkedin.com/in/srbhr/)
|
||||
- Twitter: [https://twitter.com/srbhrai](https://twitter.com/srbhrai)
|
||||
- GitHub: [https://github.com/srbhr](https://github.com/srbhr)
|
||||
|
||||
## Funciones clave
|
||||
|
||||

|
||||
|
||||
### Funciones principales
|
||||
|
||||
**Currículum maestro**: crea un currículum maestro completo a partir de tu currículum actual.
|
||||
|
||||

|
||||
|
||||
### Constructor de currículum
|
||||
|
||||

|
||||
|
||||
Pega una descripción del puesto y obtén un currículum adaptado con ayuda de IA.
|
||||
|
||||
Puedes:
|
||||
|
||||
- Modificar el contenido sugerido
|
||||
- Añadir/quitar secciones
|
||||
- Reordenar secciones con arrastrar y soltar
|
||||
- Elegir entre múltiples plantillas
|
||||
|
||||
### Generador de carta de presentación y email
|
||||
|
||||
Genera cartas de presentación y plantillas de email adaptadas según la descripción del puesto y tu currículum.
|
||||
|
||||

|
||||
|
||||
### Puntuación del currículum (función en desarrollo)
|
||||
|
||||
Estamos trabajando en una función de puntuación que analiza tu currículum frente a la descripción del puesto y ofrece un puntaje de coincidencia con sugerencias de mejora.
|
||||
|
||||

|
||||
|
||||
### Exportación a PDF
|
||||
|
||||
Exporta tu currículum adaptado y tu carta de presentación en PDF.
|
||||
|
||||
### Plantillas
|
||||
|
||||
| Nombre de plantilla | Vista previa | Descripción |
|
||||
|---------------------|-------------|-------------|
|
||||
| **Clásica (una columna)** |  | Diseño tradicional y limpio, adecuado para la mayoría de industrias. [Ver PDF](assets/pdf-templates/single-column.pdf) |
|
||||
| **Moderna (una columna)** |  | Diseño contemporáneo enfocado en legibilidad y estética. [Ver PDF](assets/pdf-templates/modern-single-column.pdf) |
|
||||
| **Clásica (dos columnas)** |  | Estructura que separa secciones para mayor claridad. [Ver PDF](assets/pdf-templates/two-column.pdf) |
|
||||
| **Moderna (dos columnas)** |  | Diseño elegante que usa dos columnas para mejor organización. [Ver PDF](assets/pdf-templates/modern-two-column.pdf) |
|
||||
|
||||
### Internacionalización
|
||||
|
||||
- **UI multilingüe**: interfaz disponible en inglés, español, chino y japonés
|
||||
- **Contenido multilingüe**: genera currículums y cartas de presentación en tu idioma preferido
|
||||
|
||||
### Roadmap
|
||||
|
||||
Si tienes alguna sugerencia o solicitud de características, no dudes en abrir un *issue* en GitHub o discutirlo en nuestro servidor de [Discord](https://dsc.gg/resume-matcher).
|
||||
|
||||
- Resaltado visual de palabras clave
|
||||
- AI Canvas para crear contenido de currículum impactante y basado en métricas
|
||||
- Optimización para múltiples descripciones de trabajo
|
||||
|
||||
<a id="how-to-install"></a>
|
||||
|
||||
## Cómo instalar
|
||||
|
||||

|
||||
|
||||
Para instrucciones detalladas de configuración, consulta **[SETUP.es.md](SETUP.es.md)**. También está disponible en [English](SETUP.md), [简体中文](SETUP.zh-CN.md) y [日本語](SETUP.ja.md).
|
||||
|
||||
### Requisitos previos
|
||||
|
||||
| Herramienta | Versión | Instalación |
|
||||
|------------|---------|-------------|
|
||||
| Python | 3.13+ | [python.org](https://python.org) |
|
||||
| Node.js | 22+ | [nodejs.org](https://nodejs.org) |
|
||||
| uv | Última | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) |
|
||||
|
||||
### Inicio rápido
|
||||
|
||||
La forma más rápida (MacOS, WSL y Ubuntu):
|
||||
|
||||
```bash
|
||||
# Clona el repositorio
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
|
||||
# Backend (Terminal 1)
|
||||
cd apps/backend
|
||||
cp .env.example .env # Configura tu proveedor de IA
|
||||
uv sync # Instala dependencias
|
||||
uv run app
|
||||
|
||||
# Frontend (Terminal 2)
|
||||
cd apps/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Abre **<http://localhost:3000>** y configura tu proveedor de IA en Settings.
|
||||
|
||||
### Proveedores de IA compatibles
|
||||
|
||||
| Proveedor | Local/Nube | Notas |
|
||||
|----------|------------|-------|
|
||||
| **Ollama** | Local | Gratis, se ejecuta en tu máquina |
|
||||
| **OpenAI** | Nube | GPT-4o, GPT-4o-mini |
|
||||
| **Anthropic** | Nube | Claude 3.5 Sonnet |
|
||||
| **Google Gemini** | Nube | Gemini 1.5 Flash/Pro |
|
||||
| **OpenRouter** | Nube | Acceso a múltiples modelos |
|
||||
| **DeepSeek** | Nube | DeepSeek Chat |
|
||||
|
||||
### Despliegue con Docker
|
||||
|
||||
```bash
|
||||
docker pull srbhr/resume-matcher:latest
|
||||
|
||||
docker run srbhr/resume-matcher:latest
|
||||
```
|
||||
|
||||
<!-- Nota: La documentación de Docker está pendiente. Por ahora, usa docker-compose.yml como referencia -->
|
||||
|
||||
> **¿Usas Ollama con Docker?** Usa `http://host.docker.internal:11434` como URL de Ollama en lugar de `localhost`.
|
||||
|
||||
### Stack tecnológico
|
||||
|
||||
| Componente | Tecnología |
|
||||
|-----------|------------|
|
||||
| Backend | FastAPI, Python 3.13+, LiteLLM |
|
||||
| Frontend | Next.js 15, React 19, TypeScript |
|
||||
| Base de datos | TinyDB (almacenamiento en archivo JSON) |
|
||||
| Estilos | Tailwind CSS 4, Swiss International Style |
|
||||
| PDF | Chromium headless vía Playwright |
|
||||
|
||||
## Únete y contribuye
|
||||
|
||||

|
||||
|
||||
¡Damos la bienvenida a las contribuciones de todos! Ya seas un desarrollador, diseñador o simplemente alguien que quiere ayudar. Todos los colaboradores están listados en la [página "Acerca de"](https://resumematcher.fyi/about) en nuestro sitio web y en el Readme de GitHub.
|
||||
|
||||
Echa un vistazo al roadmap si te gustaría trabajar en las características que están planeadas para el futuro. Si tienes alguna sugerencia o solicitud de características, no dudes en abrir un *issue* en GitHub y discutirlo en nuestro servidor de [Discord](https://dsc.gg/resume-matcher).
|
||||
|
||||
<a id="contributors"></a>
|
||||
|
||||
## Colaboradores
|
||||
|
||||

|
||||
|
||||
<a href="https://github.com/srbhr/Resume-Matcher/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=srbhr/Resume-Matcher" />
|
||||
</a>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Historial de Estrellas</kbd></summary>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=srbhr/resume-matcher&theme=dark&type=Date">
|
||||
<img width="100%" src="https://api.star-history.com/svg?repos=srbhr/resume-matcher&theme=dark&type=Date">
|
||||
</picture>
|
||||
</details>
|
||||
|
||||
## Resume Matcher es parte del [Vercel Open Source Program](https://vercel.com/oss)
|
||||
|
||||

|
||||
+282
@@ -0,0 +1,282 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://www.resumematcher.fyi)
|
||||
|
||||
# Resume Matcher
|
||||
|
||||
[English](README.md) | [Español](README.es.md) | [简体中文](README.zh-CN.md) | **日本語**
|
||||
|
||||
[𝙹𝚘𝚒𝚗 𝙳𝚒𝚜𝚌𝚘𝚛𝚍](https://dsc.gg/resume-matcher) ✦ [𝚆𝚎𝚋𝚜𝚒𝚝𝚎](https://resumematcher.fyi) ✦ [𝙷𝚘𝚠 𝚝𝚘 𝙸𝚗𝚜𝚝𝚊𝚕𝚕](https://resumematcher.fyi/docs/installation) ✦ [𝙲𝚘𝚗𝚝𝚛𝚒𝚋𝚞𝚝𝚘𝚛𝚜](#contributors) ✦ [𝚂𝚙𝚘𝚗𝚜𝚘𝚛](#sponsors) ✦ [𝚃𝚠𝚒𝚝𝚝𝚎𝚛/𝚇](https://twitter.com/srbhrai) ✦ [𝙻𝚒𝚗𝚔𝚎𝚍𝙸𝚗](https://www.linkedin.com/company/resume-matcher/) ✦ [𝙲𝚛𝚎𝚊𝚝𝚘𝚛](https://srbhr.com)
|
||||
|
||||
求人ごとに最適化した履歴書を、AI の提案で作成できます。Ollama を使ってローカルで動かすことも、API 経由でお気に入りの LLM プロバイダに接続することも可能です。
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
  
|
||||
|
||||
[](https://dsc.gg/resume-matcher) [](https://resumematcher.fyi) [](https://www.linkedin.com/company/resume-matcher/)
|
||||
|
||||
<a href="https://trendshift.io/repositories/565" target="_blank"><img src="https://trendshift.io/api/badge/repositories/565" alt="srbhr%2FResume-Matcher | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> 本プロジェクトには皆さまの支援が必要です。少額でもご寄付いただけると、Resume Matcher の開発と改善を続ける力になります。
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/sponsors/srbhr) [](https://www.buymeacoffee.com/srbhr)
|
||||
|
||||
**企業としてのスポンサーをご検討ですか?** あなたのロゴを 27k 人超の開発者に届けましょう → **[スポンサーになる ↓](#sponsors)**
|
||||
|
||||
</div>
|
||||
|
||||
## はじめに
|
||||
|
||||
Resume Matcher は、まず「マスター履歴書」を作り、それを各求人応募向けに調整する形で動作します。インストール手順は:[インストール方法](#how-to-install)
|
||||
|
||||
### 仕組み
|
||||
|
||||
1. **アップロード**:マスター履歴書(PDF / DOCX)
|
||||
2. **貼り付け**:応募先の求人票(Job Description)
|
||||
3. **確認**:AI が生成した改善案と最適化内容
|
||||
4. **生成**:求人向けのカバーレターとメール文面
|
||||
5. **調整**:レイアウトやセクションを好みに合わせてカスタマイズ
|
||||
6. **書き出し**:好みのテンプレートで PDF を出力
|
||||
|
||||
### コミュニティ
|
||||
|
||||
[](https://dsc.gg/resume-matcher)
|
||||
|
||||
ディスカッション、要望、サポートは [Discord](https://dsc.gg/resume-matcher) へ。
|
||||
|
||||
[](https://www.linkedin.com/company/resume-matcher/)
|
||||
|
||||
最新情報は [LinkedIn](https://www.linkedin.com/company/resume-matcher/) でも発信しています。
|
||||
|
||||

|
||||
|
||||
Star を付けていただけると開発の励みになります(リリース通知も受け取れます)。
|
||||
|
||||
<a id="sponsors"></a>
|
||||
|
||||
## スポンサー
|
||||
|
||||

|
||||
|
||||
Resume Matcher は無料かつオープンソースで、スポンサーと支援者の皆さまによって支えられています。役立つと感じたら、開発の支援をご検討ください。
|
||||
|
||||
### Resume Matcher を支える企業
|
||||
|
||||
企業ティアでスポンサーになると、**あなたのロゴ・リンク・紹介文がここに掲載されます** —— **27k 超の Star と 4.9k の Fork** を持つコミュニティに向けて。[Trendshift](https://trendshift.io/repositories/565) や [Vercel OSS プログラム](https://vercel.com/oss) にも掲載されています。
|
||||
|
||||
| Sponsor | Description |
|
||||
|---------|-------------|
|
||||
| [APIDECK](https://apideck.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | アプリを200以上のSaaSプラットフォーム(会計、HRIS、CRM、ファイルストレージ)に接続する単一のAPI。50回ではなく、1回の構築で統合を実現します。 🌐 [apideck.com](https://apideck.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Vercel](https://vercel.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Resume Matcher は Vercel OSS // Summer 2025 プログラムの一部です 🌐 [vercel.com](https://vercel.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Cubic.dev](https://cubic.dev?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Cubic は Resume Matcher に PR レビューを提供しています 🌐 [cubic.dev](https://cubic.dev?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Kilo Code](https://kilo.ai?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Kilo Code は Resume Matcher に AI コードレビューとコーディングクレジットを提供しています 🌐 [kilo.ai](https://kilo.ai?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [ZanReal](https://zanreal.com/?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | ZanReal は AI 駆動の開発企業で、戦略・UX から DevOps まで、スケーラブルなクラウドソリューションを構築し、チームがより速くリリースしアイデアを本番へと導く支援をしています。 🌐 [zanreal.com](https://zanreal.com/?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| **✦ あなたの企業のロゴをここに** | 27k 超の開発者と 4.9k の Fork にリーチ。**[スポンサーになる →](https://github.com/sponsors/srbhr)** |
|
||||
|
||||
スポンサーシップがプロジェクトにどのように役立つかについての詳細は、[Sponsorship Guide](https://resumematcher.fyi/docs/sponsoring) をご覧ください。ReadME およびウェブサイトにて特別に感謝の意を表します。
|
||||
|
||||
<a id="support-the-development-by-donating"></a>
|
||||
|
||||
### 個人として支援する
|
||||
|
||||

|
||||
|
||||
少額の支援でも Resume Matcher を無料に保ち、新機能の開発を支えます —— ReadME とウェブサイトにて感謝の意をお伝えします。
|
||||
|
||||
| プラットフォーム | リンク |
|
||||
|------------------|--------|
|
||||
| GitHub | [](https://github.com/sponsors/srbhr) |
|
||||
| Buy Me a Coffee | [](https://www.buymeacoffee.com/srbhr) |
|
||||
|
||||
## 制作者ノート
|
||||
|
||||
Resume Matcher をご覧いただきありがとうございます。つながりやコラボレーション、あるいは挨拶だけでも、お気軽にご連絡ください!
|
||||
~ **Saurabh Rai** ✨
|
||||
|
||||
以下でフォローできます:
|
||||
|
||||
- Website: [https://srbhr.com](https://srbhr.com)
|
||||
- Linkedin: [https://www.linkedin.com/in/srbhr/](https://www.linkedin.com/in/srbhr/)
|
||||
- Twitter: [https://twitter.com/srbhrai](https://twitter.com/srbhrai)
|
||||
- GitHub: [https://github.com/srbhr](https://github.com/srbhr)
|
||||
|
||||
## 主な機能
|
||||
|
||||

|
||||
|
||||
### コア機能
|
||||
|
||||
**マスター履歴書(Master Resume)**:既存の履歴書から、再利用できる包括的なマスター履歴書を作成します。
|
||||
|
||||

|
||||
|
||||
### 履歴書ビルダー
|
||||
|
||||

|
||||
|
||||
求人票を貼り付けると、その職種に合わせた AI 提案の履歴書を生成します。
|
||||
|
||||
できること:
|
||||
|
||||
- 提案内容の編集
|
||||
- セクションの追加/削除
|
||||
- ドラッグ&ドロップで順序変更
|
||||
- 複数テンプレートから選択
|
||||
|
||||
### カバーレター&メール生成
|
||||
|
||||
求人票と履歴書に基づき、カスタマイズされたカバーレターとメール文面を生成します。
|
||||
|
||||

|
||||
|
||||
### 履歴書スコアリング(開発中)
|
||||
|
||||
履歴書と求人票を比較して、マッチスコアと改善提案を出す機能を開発中です。
|
||||
|
||||

|
||||
|
||||
### PDF 出力
|
||||
|
||||
最適化した履歴書とカバーレターを PDF として出力できます。
|
||||
|
||||
### テンプレート
|
||||
|
||||
| テンプレート名 | プレビュー | 説明 |
|
||||
|---------------|-----------|------|
|
||||
| **クラシック(1 カラム)** |  | 伝統的でクリーンなレイアウト。多くの業種に適しています。[PDF を見る](assets/pdf-templates/single-column.pdf) |
|
||||
| **モダン(1 カラム)** |  | 可読性と美しさを重視した現代的なデザイン。[PDF を見る](assets/pdf-templates/modern-single-column.pdf) |
|
||||
| **クラシック(2 カラム)** |  | セクションを分けて見やすく整理します。[PDF を見る](assets/pdf-templates/two-column.pdf) |
|
||||
| **モダン(2 カラム)** |  | 2 カラムを活用して情報をより整理します。[PDF を見る](assets/pdf-templates/modern-two-column.pdf) |
|
||||
|
||||
### 国際化
|
||||
|
||||
- **多言語 UI**:英語・スペイン語・中国語・日本語に対応
|
||||
- **多言語コンテンツ**:希望言語で履歴書とカバーレターを生成
|
||||
|
||||
### ロードマップ
|
||||
|
||||
提案や機能要望があれば、GitHub に Issue を立てるか、[Discord](https://dsc.gg/resume-matcher) でご相談ください。
|
||||
|
||||
- キーワードの視覚的ハイライト
|
||||
- 定量的でインパクトのある内容を作る AI Canvas
|
||||
- 複数求人票の同時最適化
|
||||
|
||||
<a id="how-to-install"></a>
|
||||
|
||||
## インストール方法
|
||||
|
||||

|
||||
|
||||
詳細なセットアップ手順は **[SETUP.ja.md](SETUP.ja.md)** を参照してください([English](SETUP.md) / [Español](SETUP.es.md) / [简体中文](SETUP.zh-CN.md) も利用できます)。
|
||||
|
||||
### 前提条件
|
||||
|
||||
| ツール | バージョン | インストール |
|
||||
|--------|------------|--------------|
|
||||
| Python | 3.13+ | [python.org](https://python.org) |
|
||||
| Node.js | 22+ | [nodejs.org](https://nodejs.org) |
|
||||
| uv | 最新 | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) |
|
||||
|
||||
### クイックスタート
|
||||
|
||||
MacOS / WSL / Ubuntu で最も手早い手順:
|
||||
|
||||
```bash
|
||||
# リポジトリをクローン
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
|
||||
# バックエンド(ターミナル 1)
|
||||
cd apps/backend
|
||||
cp .env.example .env # AI プロバイダを設定
|
||||
uv sync # 依存関係をインストール
|
||||
uv run app
|
||||
|
||||
# フロントエンド(ターミナル 2)
|
||||
cd apps/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**<http://localhost:3000>** を開き、Settings で AI プロバイダを設定してください。
|
||||
|
||||
### 対応 AI プロバイダ
|
||||
|
||||
| プロバイダ | ローカル/クラウド | 備考 |
|
||||
|------------|-------------------|------|
|
||||
| **Ollama** | ローカル | 無料。手元のマシンで動作 |
|
||||
| **OpenAI** | クラウド | GPT-4o、GPT-4o-mini |
|
||||
| **Anthropic** | クラウド | Claude 3.5 Sonnet |
|
||||
| **Google Gemini** | クラウド | Gemini 1.5 Flash/Pro |
|
||||
| **OpenRouter** | クラウド | 複数モデルへアクセス |
|
||||
| **DeepSeek** | クラウド | DeepSeek Chat |
|
||||
|
||||
### Docker デプロイ
|
||||
|
||||
```bash
|
||||
docker pull srbhr/resume-matcher:latest
|
||||
|
||||
docker run srbhr/resume-matcher:latest
|
||||
```
|
||||
|
||||
<!-- 注:Docker ドキュメントは準備中です。現在は docker-compose.yml を参照してください -->
|
||||
|
||||
> **Docker で Ollama を使う場合**:Ollama の URL は `localhost` ではなく `http://host.docker.internal:11434` を指定します。
|
||||
|
||||
### 技術スタック
|
||||
|
||||
| コンポーネント | 技術 |
|
||||
|----------------|------|
|
||||
| バックエンド | FastAPI、Python 3.13+、LiteLLM |
|
||||
| フロントエンド | Next.js 15、React 19、TypeScript |
|
||||
| データベース | TinyDB(JSON ファイル保存) |
|
||||
| スタイリング | Tailwind CSS 4、Swiss International Style |
|
||||
| PDF | Playwright による Headless Chromium |
|
||||
|
||||
## 参加・コントリビュート
|
||||
|
||||

|
||||
|
||||
どなたでもコントリビュート歓迎です。開発者・デザイナー・ユーザーを問わず、協力してくれる方を募集しています。コントリビューター一覧は、公式サイトの [about ページ](https://resumematcher.fyi/about) と GitHub README に掲載されています。
|
||||
|
||||
ロードマップも参考にしてください。提案や機能要望があれば、GitHub で Issue を作成し、[Discord](https://dsc.gg/resume-matcher) でも議論できます。
|
||||
|
||||
<a id="contributors"></a>
|
||||
|
||||
## コントリビューター
|
||||
|
||||

|
||||
|
||||
<a href="https://github.com/srbhr/Resume-Matcher/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=srbhr/Resume-Matcher" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Star の推移</kbd></summary>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=srbhr/resume-matcher&theme=dark&type=Date">
|
||||
<img width="100%" src="https://api.star-history.com/svg?repos=srbhr/resume-matcher&theme=dark&type=Date">
|
||||
</picture>
|
||||
</details>
|
||||
|
||||
## Resume Matcher は [Vercel Open Source Program](https://vercel.com/oss) の一部です
|
||||
|
||||

|
||||
@@ -0,0 +1,301 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://www.resumematcher.fyi)
|
||||
|
||||
# Resume Matcher
|
||||
|
||||
[𝙹𝚘𝚒𝚗 𝙳𝚒𝚜𝚌𝚘𝚛𝚍](https://dsc.gg/resume-matcher) ✦ [𝚆𝚎𝚋𝚜𝚒𝚝𝚎](https://resumematcher.fyi) ✦ [𝙷𝚘𝚠 𝚝𝚘 𝙸𝚗𝚜𝚝𝚊𝚕𝚕](https://resumematcher.fyi/docs/installation) ✦ [𝙲𝚘𝚗𝚝𝚛𝚒𝚋𝚞𝚝𝚘𝚛𝚜](#contributors) ✦ [𝚂𝚙𝚘𝚗𝚜𝚘𝚛](#sponsors) ✦ [𝚃𝚠𝚒𝚝𝚝𝚎𝚛/𝚇](https://twitter.com/srbhrai) ✦ [𝙻𝚒𝚗𝚔𝚎𝚍𝙸𝚗](https://www.linkedin.com/company/resume-matcher/) ✦ [𝙲𝚛𝚎𝚊𝚝𝚘𝚛](https://srbhr.com)
|
||||
|
||||
**English** | [Español](README.es.md) | [简体中文](README.zh-CN.md) | [日本語](README.ja.md)
|
||||
|
||||
The AI harness to build tailored resumes for each job application with Claude, ChatGPT, DeepSeek, Kimi, GLM, Gemma, and other LLMs. Supports both local and remote LLMs.
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
  
|
||||
|
||||
[](https://dsc.gg/resume-matcher) [](https://resumematcher.fyi) [](https://www.linkedin.com/company/resume-matcher/)
|
||||
|
||||
<a href="https://trendshift.io/repositories/565" target="_blank"><img src="https://trendshift.io/api/badge/repositories/565" alt="srbhr%2FResume-Matcher | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> The project needs your help and support. If you can donate a small amount, that will help me to continue developing and improving Resume Matcher.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/sponsors/srbhr) [](https://www.buymeacoffee.com/srbhr)
|
||||
|
||||
**Sponsoring for a company?** Put your logo in front of 27k+ developers → **[become a sponsor ↓](#sponsors)**
|
||||
|
||||
</div>
|
||||
|
||||
## Getting Started
|
||||
|
||||
Resume Matcher works by creating a master resume that you can use to tailor for each job application. Installation instructions here: [How to Install](#how-to-install)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Upload** your master resume (PDF or DOCX)
|
||||
2. **Paste** a job description you're targeting
|
||||
3. **Review** AI-generated improvements and tailored content
|
||||
4. **Cover Letter** and optional interview preparation for the job application
|
||||
5. **Customize** the layout and sections to fit your style
|
||||
6. **Export** as a professional PDF with your preferred template
|
||||
|
||||
### Stay Connected
|
||||
|
||||
[](https://dsc.gg/resume-matcher)
|
||||
|
||||
Join our [Discord](https://dsc.gg/resume-matcher) for discussions, feature requests, and community support.
|
||||
|
||||
[](https://www.linkedin.com/company/resume-matcher/)
|
||||
|
||||
Follow us on [LinkedIn](https://www.linkedin.com/company/resume-matcher/) for updates.
|
||||
|
||||

|
||||
|
||||
Star the repo to support development and get notified of new releases.
|
||||
|
||||
## Sponsors
|
||||
|
||||

|
||||
|
||||
Resume Matcher is free and open-source, kept alive by its sponsors and backers. If it helps you, please consider supporting its development.
|
||||
|
||||
### Companies backing Resume Matcher
|
||||
|
||||
Sponsor at a company tier and **your logo + link + blurb lands here** — in front of a community of **27k+ stars and 4.9k forks**, featured on [Trendshift](https://trendshift.io/repositories/565) and the [Vercel OSS Program](https://vercel.com/oss).
|
||||
|
||||
| Sponsor | Description |
|
||||
|---------|-------------|
|
||||
| [Apideck](https://apideck.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | One API to connect your app to 200+ SaaS platforms (accounting, HRIS, CRM, file storage). Build integrations once, not 50 times. 🌐 [apideck.com](https://apideck.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Vercel](https://vercel.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Resume Matcher is a part of Vercel OSS // Summer 2025 Program 🌐 [vercel.com](https://vercel.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Cubic.dev](https://cubic.dev?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Cubic provides PR reviews for Resume Matcher 🌐 [cubic.dev](https://cubic.dev?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Kilo Code](https://kilo.ai?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Kilo Code provides AI code reviews and coding credits to Resume Matcher 🌐 [kilo.ai](https://kilo.ai?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [ZanReal](https://zanreal.com/?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | ZanReal is an AI-driven development company building scalable cloud solutions, from strategy and UX to DevOps, helping teams ship faster and turn ideas into production. 🌐 [zanreal.com](https://zanreal.com/?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| **✦ Your company here** | Reach 27k+ developers and 4.9k forks. **[Become a sponsor →](https://github.com/sponsors/srbhr)** |
|
||||
|
||||
Read the [Sponsorship Guide](https://resumematcher.fyi/docs/sponsoring) for tiers and details. Sponsors get a special thank-you in the README and on our website.
|
||||
|
||||
<a id="support-the-development-by-donating"></a>
|
||||
|
||||
### Support as an individual
|
||||
|
||||

|
||||
|
||||
Every bit keeps Resume Matcher free and funds new features — and you'll be thanked in the README and on our website.
|
||||
|
||||
| Platform | Link |
|
||||
|-----------|----------------------------------------|
|
||||
| GitHub | [](https://github.com/sponsors/srbhr) |
|
||||
| Buy Me a Coffee | [](https://www.buymeacoffee.com/srbhr) |
|
||||
|
||||
## Creators' Note
|
||||
|
||||
[](https://srbhr.com)
|
||||
|
||||
Thank you for checking out Resume Matcher. If you want to connect, collaborate, or just say hi, feel free to reach out!
|
||||
~ **Saurabh Rai** ✨
|
||||
|
||||
You can follow me on:
|
||||
|
||||
- Website: [https://srbhr.com](https://srbhr.com)
|
||||
- Linkedin: [https://www.linkedin.com/in/srbhr/](https://www.linkedin.com/in/srbhr/)
|
||||
- Twitter: [https://twitter.com/srbhrai](https://twitter.com/srbhrai)
|
||||
- GitHub: [https://github.com/srbhr](https://github.com/srbhr)
|
||||
|
||||
## Key Features
|
||||
|
||||

|
||||
|
||||
### Core Features
|
||||
|
||||
**Master Resume**: Create a comprehensive master resume to draw from your existing one.
|
||||
|
||||

|
||||
|
||||
### Resume Builder
|
||||
|
||||

|
||||
|
||||
Paste in a job description and get AI-powered resume tailored for that specific role.
|
||||
|
||||
You can:
|
||||
|
||||
- Modify suggested content
|
||||
- Add/remove sections
|
||||
- Rearrange sections via drag-and-drop
|
||||
- Choose from multiple resume templates
|
||||
|
||||
### Cover Letter Generator
|
||||
|
||||
Generate tailored cover letters based on the job description and your resume.
|
||||
|
||||

|
||||
|
||||
### Interview Preparation
|
||||
|
||||
Generate structured, resume-grounded interview prep for saved tailored resumes. Use the Builder's Interview Prep tab on demand, or enable automatic generation in Settings.
|
||||
|
||||
### Resume Scoring & Keyword Highlighting
|
||||
|
||||
Analyze your resume against the job description with a match score, keyword highlighting, and suggestions for improvement.
|
||||
|
||||

|
||||
|
||||
### PDF Export
|
||||
|
||||
Export your tailored resume and cover letter in PDF.
|
||||
|
||||
### Templates
|
||||
|
||||
| Template Name | Preview | Description |
|
||||
|---------------|---------|-------------|
|
||||
| **Classic Single Column** |  | A traditional and clean layout suitable for most industries. [𝐕𝐢𝐞𝐰 𝐏𝐃𝐅](assets/pdf-templates/single-column.pdf) |
|
||||
| **Modern Single Column** |  | A contemporary design with a focus on readability and aesthetics. [𝐕𝐢𝐞𝐰 𝐏𝐃𝐅](assets/pdf-templates/modern-single-column.pdf)|
|
||||
| **Classic Two Column** |  | A structured layout that separates sections for clarity. [𝐕𝐢𝐞𝐰 𝐏𝐃𝐅](assets/pdf-templates/two-column.pdf)|
|
||||
| **Modern Two Column** |  | A sleek design that utilizes two columns for better organization. [𝐕𝐢𝐞𝐰 𝐏𝐃𝐅](assets/pdf-templates/modern-two-column.pdf)|
|
||||
|
||||
### Internationalization
|
||||
|
||||
- **Multi-Language UI**: Interface available in English, Spanish, Chinese, Japanese, and Portuguese (Brazilian)
|
||||
- **Multi-Language Content**: Generate resumes and cover letters in your preferred language
|
||||
|
||||
### Roadmap
|
||||
|
||||
If you have any suggestions or feature requests, please feel free to open an issue on GitHub or discuss it on our [Discord](https://dsc.gg/resume-matcher) server.
|
||||
|
||||
- AI Canvas for crafting impactful, metric-driven resume content
|
||||
- Email template generator for job applications
|
||||
- Multi-job description optimization
|
||||
|
||||
<a id="how-to-install"></a>
|
||||
|
||||
## How to Install
|
||||
|
||||

|
||||
|
||||
For detailed setup instructions, see **[SETUP.md](SETUP.md)** (English) or: [Español](SETUP.es.md), [简体中文](SETUP.zh-CN.md), [日本語](SETUP.ja.md).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Tool | Version | Installation |
|
||||
|------|---------|--------------|
|
||||
| Python | 3.13+ | [python.org](https://python.org) |
|
||||
| Node.js | 22+ | [nodejs.org](https://nodejs.org) |
|
||||
| uv | Latest | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) |
|
||||
|
||||
### Quick Start
|
||||
|
||||
Fastest for MacOS, WSL and Ubuntu users:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
|
||||
# Backend (Terminal 1)
|
||||
cd apps/backend
|
||||
cp .env.example .env # Configure your AI provider
|
||||
uv sync # Install dependencies
|
||||
uv run app
|
||||
|
||||
# Frontend (Terminal 2)
|
||||
cd apps/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open **<http://localhost:3000>** and configure your AI provider in Settings.
|
||||
|
||||
### Supported AI Providers
|
||||
|
||||
| Provider | Local/Cloud | Notes |
|
||||
|----------|-------------|-------|
|
||||
| **Ollama** | Local | Free, runs on your machine |
|
||||
| **OpenAI** | Cloud | GPT-5 Nano, GPT-4o |
|
||||
| **Anthropic** | Cloud | Claude Haiku 4.5 |
|
||||
| **Google Gemini** | Cloud | Gemini 3 Flash |
|
||||
| **OpenRouter** | Cloud | Access to multiple models |
|
||||
| **DeepSeek** | Cloud | DeepSeek Chat |
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
Official Docker images are published for `linux/amd64` and `linux/arm64` on:
|
||||
|
||||
- `ghcr.io/srbhr/resume-matcher`
|
||||
- `srbhr/resume-matcher`
|
||||
|
||||
Run on a single public port (`3000`) with API available at `/api`:
|
||||
|
||||
```bash
|
||||
docker run --name resume-matcher \
|
||||
-p 3000:3000 \
|
||||
-v resume-data:/app/backend/data \
|
||||
ghcr.io/srbhr/resume-matcher:latest
|
||||
```
|
||||
|
||||
Prefer pinning a version in production, for example `ghcr.io/srbhr/resume-matcher:1.2.0` or
|
||||
`ghcr.io/srbhr/resume-matcher:1.2`.
|
||||
|
||||
Endpoints:
|
||||
|
||||
- App: <http://localhost:3000>
|
||||
- API health check: <http://localhost:3000/api/v1/health>
|
||||
- API docs: <http://localhost:3000/docs>
|
||||
|
||||
> **Using Ollama with Docker?** Use `http://host.docker.internal:11434` as the Ollama URL instead of `localhost`.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Backend | FastAPI, Python 3.13+, LiteLLM |
|
||||
| Frontend | Next.js 16, React 19, TypeScript |
|
||||
| Database | TinyDB (JSON file storage) |
|
||||
| Styling | Tailwind CSS 4, Swiss International Style |
|
||||
| PDF | Headless Chromium via Playwright |
|
||||
|
||||
## Join Us and Contribute
|
||||
|
||||

|
||||
|
||||
We welcome contributions from everyone! Whether you're a developer, designer, or just someone who wants to help out. All the contributors are listed in the [about page](https://resumematcher.fyi/about) on our website and on the GitHub Readme here.
|
||||
|
||||
Check out the roadmap if you would like to work on the features that are planned for the future. If you have any suggestions or feature requests, please feel free to open an issue on GitHub and discuss it on our [Discord](https://dsc.gg/resume-matcher) server.
|
||||
|
||||
<a id="contributors"></a>
|
||||
|
||||
## Contributors
|
||||
|
||||

|
||||
|
||||
<a href="https://github.com/srbhr/Resume-Matcher/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=srbhr/Resume-Matcher" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Star History</kbd></summary>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=srbhr/resume-matcher&theme=dark&type=Date">
|
||||
<img width="100%" src="https://api.star-history.com/svg?repos=srbhr/resume-matcher&theme=dark&type=Date">
|
||||
</picture>
|
||||
</details>
|
||||
|
||||
## Resume Matcher is a part of [Vercel Open Source Program](https://vercel.com/oss)
|
||||
|
||||

|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`srbhr/Resume-Matcher`
|
||||
- 原始仓库:https://github.com/srbhr/Resume-Matcher
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://www.resumematcher.fyi)
|
||||
|
||||
# Resume Matcher
|
||||
|
||||
[English](README.md) | [Español](README.es.md) | **简体中文** | [日本語](README.ja.md)
|
||||
|
||||
[𝙹𝚘𝚒𝚗 𝙳𝚒𝚜𝚌𝚘𝚛𝚍](https://dsc.gg/resume-matcher) ✦ [𝚆𝚎𝚋𝚜𝚒𝚝𝚎](https://resumematcher.fyi) ✦ [𝙷𝚘𝚠 𝚝𝚘 𝙸𝚗𝚜𝚝𝚊𝚕𝚕](https://resumematcher.fyi/docs/installation) ✦ [𝙲𝚘𝚗𝚝𝚛𝚒𝚋𝚞𝚝𝚘𝚛𝚜](#contributors) ✦ [𝚂𝚙𝚘𝚗𝚜𝚘𝚛](#sponsors) ✦ [𝚃𝚠𝚒𝚝𝚝𝚎𝚛/𝚇](https://twitter.com/srbhrai) ✦ [𝙻𝚒𝚗𝚔𝚎𝚍𝙸𝚗](https://www.linkedin.com/company/resume-matcher/) ✦ [𝙲𝚛𝚎𝚊𝚝𝚘𝚛](https://srbhr.com)
|
||||
|
||||
为每一次求职投递生成量身定制的简历:AI 给出可执行的优化建议。支持本地使用 Ollama 运行,也可通过 API 连接你常用的 LLM 提供商。
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
  
|
||||
|
||||
[](https://dsc.gg/resume-matcher) [](https://resumematcher.fyi) [](https://www.linkedin.com/company/resume-matcher/)
|
||||
|
||||
<a href="https://trendshift.io/repositories/565" target="_blank"><img src="https://trendshift.io/api/badge/repositories/565" alt="srbhr%2FResume-Matcher | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
> \[!IMPORTANT]
|
||||
>
|
||||
> 本项目需要你的帮助与支持。如果你能捐赠一点点,就能帮助我持续开发和改进 Resume Matcher。
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/sponsors/srbhr) [](https://www.buymeacoffee.com/srbhr)
|
||||
|
||||
**代表公司赞助?** 让你的 Logo 展示在 27k+ 开发者面前 → **[成为赞助商 ↓](#sponsors)**
|
||||
|
||||
</div>
|
||||
|
||||
## 快速开始
|
||||
|
||||
Resume Matcher 的工作方式是先建立一份“主简历”,然后针对每个职位描述进行定制。安装说明见:[如何安装](#how-to-install)
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. **上传**你的主简历(PDF 或 DOCX)
|
||||
2. **粘贴**你要投递的职位描述(JD)
|
||||
3. **审阅**AI 生成的改进建议与定制内容
|
||||
4. **生成**该岗位的求职信与邮件模板
|
||||
5. **自定义**版式与章节,匹配你的风格
|
||||
6. **导出**为你选定模板的专业 PDF
|
||||
|
||||
### 保持联系
|
||||
|
||||
[](https://dsc.gg/resume-matcher)
|
||||
|
||||
加入我们的 [Discord](https://dsc.gg/resume-matcher),参与讨论、功能需求与社区支持。
|
||||
|
||||
[](https://www.linkedin.com/company/resume-matcher/)
|
||||
|
||||
关注我们的 [LinkedIn](https://www.linkedin.com/company/resume-matcher/) 获取更新。
|
||||
|
||||

|
||||
|
||||
给仓库点 Star 来支持开发,并及时获取新版本通知。
|
||||
|
||||
<a id="sponsors"></a>
|
||||
|
||||
## 赞助商
|
||||
|
||||

|
||||
|
||||
Resume Matcher 是免费且开源的,依靠赞助商与支持者维持运转。如果它对你有帮助,欢迎支持它的开发。
|
||||
|
||||
### 支持 Resume Matcher 的公司
|
||||
|
||||
以公司档位赞助,**你的 Logo + 链接 + 简介将展示在这里** —— 面向 **27k+ Star、4.9k Fork** 的社区,并登上 [Trendshift](https://trendshift.io/repositories/565) 与 [Vercel OSS 计划](https://vercel.com/oss)。
|
||||
|
||||
| Sponsor | Description |
|
||||
|---------|-------------|
|
||||
| [APIDECK](https://apideck.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | One API to connect your app to 200+ SaaS platforms (accounting, HRIS, CRM, file storage). Build integrations once, not 50 times. 🌐 [apideck.com](https://apideck.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Vercel](https://vercel.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Resume Matcher 是 Vercel OSS // Summer 2025 计划的一部分 🌐 [vercel.com](https://vercel.com?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Cubic.dev](https://cubic.dev?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Cubic 为 Resume Matcher 提供 PR 审查 🌐 [cubic.dev](https://cubic.dev?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [Kilo Code](https://kilo.ai?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | Kilo Code 为 Resume Matcher 提供 AI 代码审查和编码积分 🌐 [kilo.ai](https://kilo.ai?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| [ZanReal](https://zanreal.com/?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) | ZanReal 是一家以 AI 驱动的开发公司,构建可扩展的云解决方案,从战略、UX 到 DevOps,帮助团队更快交付、把创意变为产品。 🌐 [zanreal.com](https://zanreal.com/?utm_source=resumematcher&utm_medium=github&utm_campaign=sponsors) |
|
||||
| **✦ 你的公司可展示于此** | 触达 27k+ 开发者与 4.9k Fork。**[成为赞助商 →](https://github.com/sponsors/srbhr)** |
|
||||
|
||||
请阅读我们的 [Sponsorship Guide](https://resumematcher.fyi/docs/sponsoring) 了解您的赞助如何帮助本项目。您将在 ReadME 和我们的网站上获得特别鸣谢。
|
||||
|
||||
<a id="support-the-development-by-donating"></a>
|
||||
|
||||
### 以个人身份支持
|
||||
|
||||

|
||||
|
||||
每一份支持都让 Resume Matcher 保持免费,并资助新功能的开发 —— 你也会在 ReadME 和我们的网站上获得鸣谢。
|
||||
|
||||
| 平台 | 链接 |
|
||||
|------|------|
|
||||
| GitHub | [](https://github.com/sponsors/srbhr) |
|
||||
| Buy Me a Coffee | [](https://www.buymeacoffee.com/srbhr) |
|
||||
|
||||
## 创作者留言
|
||||
|
||||
感谢您关注 Resume Matcher。如果您想联系、合作或只是打个招呼,请随时联系我!
|
||||
~ **Saurabh Rai** ✨
|
||||
|
||||
您可以在以下平台关注我:
|
||||
|
||||
- Website: [https://srbhr.com](https://srbhr.com)
|
||||
- Linkedin: [https://www.linkedin.com/in/srbhr/](https://www.linkedin.com/in/srbhr/)
|
||||
- Twitter: [https://twitter.com/srbhrai](https://twitter.com/srbhrai)
|
||||
- GitHub: [https://github.com/srbhr](https://github.com/srbhr)
|
||||
|
||||
## 主要功能
|
||||
|
||||

|
||||
|
||||
### 核心能力
|
||||
|
||||
**主简历(Master Resume)**:基于你现有简历创建一份完整的主简历,后续每次投递都从这份主简历中抽取与定制。
|
||||
|
||||

|
||||
|
||||
### 简历生成器
|
||||
|
||||

|
||||
|
||||
粘贴职位描述后,获得针对该岗位定制的 AI 简历建议。
|
||||
|
||||
你可以:
|
||||
|
||||
- 修改建议内容
|
||||
- 添加/移除章节
|
||||
- 通过拖拽调整章节顺序
|
||||
- 从多种简历模板中选择
|
||||
|
||||
### 求职信与邮件生成器
|
||||
|
||||
基于职位描述与你的简历,生成定制化的求职信与邮件模板。
|
||||
|
||||

|
||||
|
||||
### 简历评分(开发中功能)
|
||||
|
||||
我们正在开发“简历评分”功能:对比你的简历与职位描述,输出匹配分数,并给出改进建议。
|
||||
|
||||

|
||||
|
||||
### PDF 导出
|
||||
|
||||
将定制后的简历与求职信导出为 PDF。
|
||||
|
||||
### 模板
|
||||
|
||||
| 模板名称 | 预览 | 说明 |
|
||||
|---------|------|------|
|
||||
| **经典单栏** |  | 传统且干净的排版,适用于大多数行业。[查看 PDF](assets/pdf-templates/single-column.pdf) |
|
||||
| **现代单栏** |  | 更强调可读性与审美的现代风格。[查看 PDF](assets/pdf-templates/modern-single-column.pdf) |
|
||||
| **经典双栏** |  | 将内容分区展示,更清晰易扫读。[查看 PDF](assets/pdf-templates/two-column.pdf) |
|
||||
| **现代双栏** |  | 利用双栏结构做更强的信息组织。[查看 PDF](assets/pdf-templates/modern-two-column.pdf) |
|
||||
|
||||
### 国际化
|
||||
|
||||
- **多语言 UI**:界面支持英语、西班牙语、中文与日语
|
||||
- **多语言内容**:可按你偏好的语言生成简历与求职信
|
||||
|
||||
### 路线图
|
||||
|
||||
如果你有建议或功能需求,欢迎在 GitHub 提 Issue,或加入我们的 [Discord](https://dsc.gg/resume-matcher) 讨论。
|
||||
|
||||
- 可视化关键词高亮
|
||||
- 用于打造量化、可落地简历内容的 AI 画布(AI Canvas)
|
||||
- 多职位描述联合优化
|
||||
|
||||
<a id="how-to-install"></a>
|
||||
|
||||
## 如何安装
|
||||
|
||||

|
||||
|
||||
更详细的安装与配置说明请查看 **[安装文档](SETUP.zh-CN.md)**(也提供 [English](SETUP.md) / [Español](SETUP.es.md) / [日本語](SETUP.ja.md))。
|
||||
|
||||
### 前置条件
|
||||
|
||||
| 工具 | 版本 | 安装 |
|
||||
|------|------|------|
|
||||
| Python | 3.13+ | [python.org](https://python.org) |
|
||||
| Node.js | 22+ | [nodejs.org](https://nodejs.org) |
|
||||
| uv | 最新版 | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) |
|
||||
|
||||
### 快速开始
|
||||
|
||||
适用于 MacOS、WSL 与 Ubuntu 的最快方式:
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
|
||||
# 后端(终端 1)
|
||||
cd apps/backend
|
||||
cp .env.example .env # 配置你的 AI 提供商
|
||||
uv sync # 安装依赖
|
||||
uv run app
|
||||
|
||||
# 前端(终端 2)
|
||||
cd apps/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
打开 **<http://localhost:3000>**,并在 Settings 中配置你的 AI 提供商。
|
||||
|
||||
### 支持的 AI 提供商
|
||||
|
||||
| 提供商 | 本地/云 | 说明 |
|
||||
|--------|---------|------|
|
||||
| **Ollama** | 本地 | 免费,在你的机器上运行 |
|
||||
| **OpenAI** | 云 | GPT-4o、GPT-4o-mini |
|
||||
| **Anthropic** | 云 | Claude 3.5 Sonnet |
|
||||
| **Google Gemini** | 云 | Gemini 1.5 Flash/Pro |
|
||||
| **OpenRouter** | 云 | 访问多种模型 |
|
||||
| **DeepSeek** | 云 | DeepSeek Chat |
|
||||
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
docker pull srbhr/resume-matcher:latest
|
||||
|
||||
docker run srbhr/resume-matcher:latest
|
||||
```
|
||||
|
||||
<!-- 注意:Docker 文档正在编写中。目前请参考 docker-compose.yml -->
|
||||
|
||||
> **在 Docker 中使用 Ollama?** 将 Ollama URL 配置为 `http://host.docker.internal:11434`(而不是 `localhost`)。
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | FastAPI、Python 3.13+、LiteLLM |
|
||||
| 前端 | Next.js 15、React 19、TypeScript |
|
||||
| 数据库 | TinyDB(JSON 文件存储) |
|
||||
| 样式 | Tailwind CSS 4、Swiss International Style |
|
||||
| PDF | Playwright 驱动的无头 Chromium |
|
||||
|
||||
## 参与贡献
|
||||
|
||||

|
||||
|
||||
我们欢迎所有人的贡献!无论你是开发者、设计师,还是希望帮忙的用户。所有贡献者都会展示在我们官网的 [about 页面](https://resumematcher.fyi/about),也会显示在 GitHub README 中。
|
||||
|
||||
如果你希望参与未来规划的功能,可以先看看路线图。若你有建议或功能需求,欢迎在 GitHub 提 Issue,并在我们的 [Discord](https://dsc.gg/resume-matcher) 讨论。
|
||||
|
||||
<a id="contributors"></a>
|
||||
|
||||
## 贡献者
|
||||
|
||||

|
||||
|
||||
<a href="https://github.com/srbhr/Resume-Matcher/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=srbhr/Resume-Matcher" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><kbd>Star 历史</kbd></summary>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=srbhr/resume-matcher&theme=dark&type=Date">
|
||||
<img width="100%" src="https://api.star-history.com/svg?repos=srbhr/resume-matcher&theme=dark&type=Date">
|
||||
</picture>
|
||||
</details>
|
||||
|
||||
## Resume Matcher 是 [Vercel Open Source Program](https://vercel.com/oss) 的一部分
|
||||
|
||||

|
||||
+505
@@ -0,0 +1,505 @@
|
||||
# Guía de configuración de Resume Matcher
|
||||
|
||||
[English](SETUP.md) | [**Español**](SETUP.es.md) | [简体中文](SETUP.zh-CN.md) | [日本語](SETUP.ja.md)
|
||||
|
||||
¡Bienvenido! Esta guía te acompaña para configurar Resume Matcher en tu máquina local. Tanto si eres desarrollador y quieres contribuir como si solo quieres ejecutarlo localmente, aquí tienes todo lo necesario.
|
||||
|
||||
---
|
||||
|
||||
## Tabla de contenidos
|
||||
|
||||
- [Requisitos previos](#prerequisites)
|
||||
- [Inicio rápido](#quick-start)
|
||||
- [Configuración paso a paso](#step-by-step-setup)
|
||||
- [1. Clonar el repositorio](#1-clone-the-repository)
|
||||
- [2. Configurar el backend](#2-backend-setup)
|
||||
- [3. Configurar el frontend](#3-frontend-setup)
|
||||
- [Configurar tu proveedor de IA](#configuring-your-ai-provider)
|
||||
- [Opción A: Proveedores en la nube](#option-a-cloud-providers)
|
||||
- [Opción B: IA local con Ollama (gratis)](#option-b-local-ai-with-ollama-free)
|
||||
- [Despliegue con Docker](#docker-deployment)
|
||||
- [Acceder a la aplicación](#accessing-the-application)
|
||||
- [Referencia de comandos comunes](#common-commands-reference)
|
||||
- [Solución de problemas](#troubleshooting)
|
||||
- [Estructura del proyecto](#project-structure-overview)
|
||||
- [Obtener ayuda](#getting-help)
|
||||
|
||||
---
|
||||
|
||||
<a id="prerequisites"></a>
|
||||
## Requisitos previos
|
||||
|
||||
Antes de empezar, asegúrate de tener lo siguiente instalado en tu sistema:
|
||||
|
||||
| Herramienta | Versión mínima | Cómo comprobarlo | Instalación |
|
||||
|------------|-----------------|------------------|-------------|
|
||||
| **Python** | 3.13+ | `python --version` | [python.org](https://python.org) |
|
||||
| **Node.js** | 22+ | `node --version` | [nodejs.org](https://nodejs.org) |
|
||||
| **npm** | 10+ | `npm --version` | Viene con Node.js |
|
||||
| **uv** | Última | `uv --version` | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) |
|
||||
| **Git** | Cualquiera | `git --version` | [git-scm.com](https://git-scm.com) |
|
||||
|
||||
### Instalar uv (gestor de paquetes de Python)
|
||||
|
||||
Resume Matcher usa `uv` para una gestión de dependencias de Python rápida y fiable. Instálalo con:
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
|
||||
# O mediante pip
|
||||
pip install uv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="quick-start"></a>
|
||||
## Inicio rápido
|
||||
|
||||
Si ya estás familiarizado con herramientas de desarrollo y quieres arrancar rápido:
|
||||
|
||||
```bash
|
||||
# 1. Clona el repositorio
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
|
||||
# 2. Inicia el backend (Terminal 1)
|
||||
cd apps/backend
|
||||
cp .env.example .env # Crea la configuración a partir de la plantilla
|
||||
uv sync # Instala dependencias de Python
|
||||
uv run app
|
||||
|
||||
# 3. Inicia el frontend (Terminal 2)
|
||||
cd apps/frontend
|
||||
npm install # Instala dependencias de Node.js
|
||||
npm run dev # Arranca el servidor de desarrollo
|
||||
```
|
||||
|
||||
Abre **<http://localhost:3000>** en el navegador y listo.
|
||||
|
||||
> **Nota:** antes de usar la app, necesitas configurar un proveedor de IA. Consulta [Configurar tu proveedor de IA](#configuring-your-ai-provider).
|
||||
|
||||
---
|
||||
|
||||
<a id="step-by-step-setup"></a>
|
||||
## Configuración paso a paso
|
||||
|
||||
<a id="1-clone-the-repository"></a>
|
||||
### 1. Clonar el repositorio
|
||||
|
||||
Primero, trae el código a tu máquina:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
```
|
||||
|
||||
<a id="2-backend-setup"></a>
|
||||
### 2. Configurar el backend
|
||||
|
||||
El backend es una aplicación Python (FastAPI) que gestiona el procesamiento de IA, el parseo del currículum y el almacenamiento de datos.
|
||||
|
||||
#### Ir al directorio del backend
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
```
|
||||
|
||||
#### Crear tu archivo de entorno
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
#### Editar el archivo `.env` con tu editor preferido
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
nano .env
|
||||
|
||||
# O usa el editor que prefieras
|
||||
code .env # VS Code
|
||||
```
|
||||
|
||||
El ajuste más importante es tu proveedor de IA. Aquí tienes una configuración mínima para OpenAI:
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=openai
|
||||
LLM_MODEL=gpt-5-nano-2025-08-07
|
||||
LLM_API_KEY=sk-your-api-key-here
|
||||
|
||||
# Mantén estos valores por defecto para desarrollo local
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
FRONTEND_BASE_URL=http://localhost:3000
|
||||
CORS_ORIGINS=["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
```
|
||||
|
||||
#### Instalar dependencias de Python
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
Esto crea un entorno virtual e instala todos los paquetes requeridos.
|
||||
|
||||
#### Iniciar el servidor del backend
|
||||
|
||||
```bash
|
||||
RELOAD=true uv run app
|
||||
```
|
||||
|
||||
Deberías ver una salida como:
|
||||
|
||||
```
|
||||
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||
INFO: Started reloader process
|
||||
```
|
||||
|
||||
**Deja este terminal ejecutándose** y abre un nuevo terminal para el frontend.
|
||||
|
||||
<a id="3-frontend-setup"></a>
|
||||
### 3. Configurar el frontend
|
||||
|
||||
El frontend es una aplicación Next.js que proporciona la interfaz de usuario.
|
||||
|
||||
#### Ir al directorio del frontend
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
```
|
||||
|
||||
#### (Opcional) Crear un archivo de entorno para el frontend
|
||||
|
||||
Solo es necesario si tu backend se ejecuta en un puerto distinto:
|
||||
|
||||
```bash
|
||||
cp .env.sample .env.local
|
||||
```
|
||||
|
||||
#### Instalar dependencias de Node.js
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### Iniciar el servidor de desarrollo
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Deberías ver:
|
||||
|
||||
```
|
||||
▲ Next.js 16.x.x (Turbopack)
|
||||
- Local: http://localhost:3000
|
||||
```
|
||||
|
||||
Abre **<http://localhost:3000>** en el navegador. Deberías ver el panel de Resume Matcher.
|
||||
|
||||
---
|
||||
|
||||
<a id="configuring-your-ai-provider"></a>
|
||||
## Configurar tu proveedor de IA
|
||||
|
||||
Resume Matcher admite múltiples proveedores de IA. Puedes configurarlo desde la página de Settings en la app o editando el archivo `.env` del backend.
|
||||
|
||||
<a id="option-a-cloud-providers"></a>
|
||||
### Opción A: Proveedores en la nube
|
||||
|
||||
| Proveedor | Configuración | Obtener API key |
|
||||
|----------|---------------|-----------------|
|
||||
| **OpenAI** | `LLM_PROVIDER=openai`<br>`LLM_MODEL=gpt-5-nano-2025-08-07` | [platform.openai.com](https://platform.openai.com/api-keys) |
|
||||
| **Anthropic** | `LLM_PROVIDER=anthropic`<br>`LLM_MODEL=claude-haiku-4-5-20251001` | [console.anthropic.com](https://console.anthropic.com/) |
|
||||
| **Google Gemini** | `LLM_PROVIDER=gemini`<br>`LLM_MODEL=gemini-3-flash-preview` | [aistudio.google.com](https://aistudio.google.com/app/apikey) |
|
||||
| **OpenRouter** | `LLM_PROVIDER=openrouter`<br>`LLM_MODEL=deepseek/deepseek-chat` | [openrouter.ai](https://openrouter.ai/keys) |
|
||||
| **DeepSeek** | `LLM_PROVIDER=deepseek`<br>`LLM_MODEL=deepseek-chat` | [platform.deepseek.com](https://platform.deepseek.com/) |
|
||||
| **OpenAI-Compatible** | `LLM_PROVIDER=openai_compatible`<br>`LLM_MODEL=llama-3.1-8b`<br>`LLM_API_BASE=http://localhost:8080/v1` | — (local) |
|
||||
|
||||
**OpenAI-Compatible** apunta a cualquier servidor local que exponga la API Chat Completions de OpenAI — llama.cpp, vLLM, LM Studio, etc. La API key es opcional.
|
||||
|
||||
Ejemplo de `.env` para Anthropic:
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=anthropic
|
||||
LLM_MODEL=claude-haiku-4-5-20251001
|
||||
LLM_API_KEY=sk-ant-your-key-here
|
||||
```
|
||||
|
||||
<a id="option-b-local-ai-with-ollama-free"></a>
|
||||
### Opción B: IA local con Ollama (gratis)
|
||||
|
||||
¿Quieres ejecutar modelos localmente sin costes de API? Usa Ollama.
|
||||
|
||||
#### Paso 1: Instalar Ollama
|
||||
|
||||
Descárgalo e instálalo desde [ollama.com](https://ollama.com)
|
||||
|
||||
#### Paso 2: Descargar un modelo
|
||||
|
||||
```bash
|
||||
ollama pull gemma3:4b
|
||||
```
|
||||
|
||||
Otras buenas opciones: `mistral`, `codellama`, `neural-chat`
|
||||
|
||||
#### Paso 3: Configurar tu `.env`
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=gemma3:4b
|
||||
LLM_API_BASE=http://localhost:11434
|
||||
# LLM_API_KEY no es necesario con Ollama
|
||||
```
|
||||
|
||||
#### Paso 4: Asegúrate de que Ollama está en ejecución
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
```
|
||||
|
||||
Normalmente Ollama se inicia automáticamente tras la instalación.
|
||||
|
||||
---
|
||||
|
||||
<a id="docker-deployment"></a>
|
||||
## Despliegue con Docker
|
||||
|
||||
¿Prefieres un despliegue en contenedor? Resume Matcher incluye soporte para Docker.
|
||||
|
||||
### Usando Docker Compose (recomendado)
|
||||
|
||||
```bash
|
||||
# Construir e iniciar los contenedores
|
||||
docker-compose up -d
|
||||
|
||||
# Ver logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Detener los contenedores
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Notas importantes sobre Docker
|
||||
|
||||
- **Las API keys se configuran desde la UI** en <http://localhost:3000/settings> (no mediante archivos `.env`)
|
||||
- Los datos se persisten en un volumen de Docker
|
||||
- Se exponen los puertos del frontend (3000) y del backend (8000)
|
||||
|
||||
<!-- Nota: La documentación de Docker está pendiente. Por ahora, usa docker-compose.yml como referencia -->
|
||||
|
||||
---
|
||||
|
||||
<a id="accessing-the-application"></a>
|
||||
## Acceder a la aplicación
|
||||
|
||||
Cuando ambos servidores estén ejecutándose, abre el navegador:
|
||||
|
||||
| URL | Descripción |
|
||||
|-----|-------------|
|
||||
| **<http://localhost:3000>** | Aplicación principal (Dashboard) |
|
||||
| **<http://localhost:3000/settings>** | Configurar proveedor de IA |
|
||||
| **<http://localhost:8000>** | Raíz de la API del backend |
|
||||
| **<http://localhost:8000/docs>** | Documentación interactiva de la API |
|
||||
| **<http://localhost:8000/health>** | Health check del backend |
|
||||
|
||||
### Checklist de primera ejecución
|
||||
|
||||
1. Abre <http://localhost:3000/settings>
|
||||
2. Selecciona tu proveedor de IA
|
||||
3. Introduce tu API key (o configura Ollama)
|
||||
4. Haz clic en "Save Configuration"
|
||||
5. Haz clic en "Test Connection" para verificar
|
||||
6. Vuelve al Dashboard y sube tu primer currículum
|
||||
|
||||
---
|
||||
|
||||
<a id="common-commands-reference"></a>
|
||||
## Referencia de comandos comunes
|
||||
|
||||
### Comandos del backend
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Iniciar servidor de desarrollo (con auto-reload)
|
||||
RELOAD=true uv run app
|
||||
|
||||
# Iniciar servidor de producción
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# Instalar dependencias
|
||||
uv sync
|
||||
|
||||
# Instalar con dependencias de desarrollo (para tests)
|
||||
uv sync --group dev
|
||||
|
||||
# Ejecutar tests
|
||||
uv run pytest
|
||||
|
||||
# Verificar si la base de datos requiere reset (se guarda como JSON)
|
||||
ls -la data/
|
||||
```
|
||||
|
||||
### Comandos del frontend
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
|
||||
# Iniciar servidor de desarrollo (con Turbopack para refresco rápido)
|
||||
npm run dev
|
||||
|
||||
# Build para producción
|
||||
npm run build
|
||||
|
||||
# Iniciar servidor de producción
|
||||
npm run start
|
||||
|
||||
# Ejecutar linter
|
||||
npm run lint
|
||||
|
||||
# Formatear código con Prettier
|
||||
npm run format
|
||||
|
||||
# Ejecutar en un puerto diferente
|
||||
npm run dev -- -p 3001
|
||||
```
|
||||
|
||||
### Gestión de base de datos
|
||||
|
||||
Resume Matcher usa TinyDB (almacenamiento en archivos JSON). Todos los datos están en `apps/backend/data/`:
|
||||
|
||||
```bash
|
||||
# Ver archivos de la base de datos
|
||||
ls apps/backend/data/
|
||||
|
||||
# Hacer backup de tus datos
|
||||
cp -r apps/backend/data apps/backend/data-backup
|
||||
|
||||
# Resetear todo (empezar de cero)
|
||||
rm -rf apps/backend/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## Solución de problemas
|
||||
|
||||
### El backend no arranca
|
||||
|
||||
**Error:** `ModuleNotFoundError`
|
||||
|
||||
Asegúrate de ejecutar con `uv`:
|
||||
|
||||
```bash
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**Error:** `LLM_API_KEY not configured`
|
||||
|
||||
Revisa que tu archivo `.env` tenga una API key válida para el proveedor elegido.
|
||||
|
||||
### El frontend no arranca
|
||||
|
||||
**Error:** `ECONNREFUSED` al cargar páginas
|
||||
|
||||
El backend no está en ejecución. Inícialo primero:
|
||||
|
||||
```bash
|
||||
cd apps/backend && uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**Error:** errores de build o TypeScript
|
||||
|
||||
Limpia la caché de Next.js:
|
||||
|
||||
```bash
|
||||
rm -rf apps/frontend/.next
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Fallo al descargar PDF
|
||||
|
||||
**Error:** `Cannot connect to frontend for PDF generation`
|
||||
|
||||
El backend no puede acceder al frontend. Comprueba:
|
||||
|
||||
1. El frontend está en ejecución
|
||||
2. `FRONTEND_BASE_URL` en `.env` coincide con tu URL del frontend
|
||||
3. `CORS_ORIGINS` incluye la URL del frontend
|
||||
|
||||
Si el frontend corre en el puerto 3001:
|
||||
|
||||
```env
|
||||
FRONTEND_BASE_URL=http://localhost:3001
|
||||
CORS_ORIGINS=["http://localhost:3001", "http://127.0.0.1:3001"]
|
||||
```
|
||||
|
||||
### Fallo de conexión con Ollama
|
||||
|
||||
**Error:** `Connection refused to localhost:11434`
|
||||
|
||||
1. Comprueba que Ollama está en ejecución: `ollama list`
|
||||
2. Inicia Ollama si es necesario: `ollama serve`
|
||||
3. Asegúrate de que el modelo está descargado: `ollama pull gemma3:4b`
|
||||
|
||||
---
|
||||
|
||||
<a id="project-structure-overview"></a>
|
||||
## Estructura del proyecto
|
||||
|
||||
```text
|
||||
Resume-Matcher/
|
||||
├─ apps/
|
||||
│ ├─ backend/ # Python FastAPI backend
|
||||
│ │ ├─ app/
|
||||
│ │ │ ├─ main.py # Application entry point
|
||||
│ │ │ ├─ config.py # Environment configuration
|
||||
│ │ │ ├─ database.py # TinyDB wrapper
|
||||
│ │ │ ├─ llm.py # AI provider integration
|
||||
│ │ │ ├─ routers/ # API endpoints
|
||||
│ │ │ ├─ services/ # Business logic
|
||||
│ │ │ └─ schemas/ # Data models
|
||||
│ │ ├─ prompts/ # LLM prompt templates
|
||||
│ │ ├─ data/ # Database storage (auto-created)
|
||||
│ │ ├─ .env.example # Environment template
|
||||
│ │ └─ pyproject.toml # Python dependencies
|
||||
│ └─ frontend/ # Next.js React frontend
|
||||
│ ├─ app/ # Pages (dashboard, builder, etc.)
|
||||
│ ├─ components/ # Reusable React components
|
||||
│ ├─ lib/ # Utilities and API client
|
||||
│ ├─ .env.sample # Environment template
|
||||
│ └─ package.json # Node.js dependencies
|
||||
├─ docs/ # Additional documentation
|
||||
├─ docker-compose.yml # Docker configuration
|
||||
├─ Dockerfile # Container build instructions
|
||||
└─ README.md # Project overview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="getting-help"></a>
|
||||
## Obtener ayuda
|
||||
|
||||
¿Atascado? Estas son tus opciones:
|
||||
|
||||
- **Comunidad de Discord:** [dsc.gg/resume-matcher](https://dsc.gg/resume-matcher) - Comunidad activa para preguntas y discusiones
|
||||
- **Issues de GitHub:** [Abrir un issue](https://github.com/srbhr/Resume-Matcher/issues) para bugs o solicitudes de funcionalidades
|
||||
- **Documentación:** revisa la carpeta [docs/agent/](docs/agent/) para guías detalladas
|
||||
|
||||
### Documentación útil
|
||||
|
||||
| Documento | Descripción |
|
||||
|----------|-------------|
|
||||
| [backend-guide.md](docs/agent/architecture/backend-guide.md) | Arquitectura del backend y detalles de la API |
|
||||
| [frontend-workflow.md](docs/agent/architecture/frontend-workflow.md) | Flujo de usuario y arquitectura de componentes |
|
||||
| [swiss-design-system/](docs/portable/swiss-design-system/README.md) | Sistema de diseño UI (Swiss International Style) — paquete portable |
|
||||
|
||||
---
|
||||
|
||||
¡Feliz creación de currículums! Si Resume Matcher te resulta útil, considera [darle una estrella al repo](https://github.com/srbhr/Resume-Matcher) y [unirte a nuestro Discord](https://dsc.gg/resume-matcher).
|
||||
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
# Resume Matcher セットアップガイド
|
||||
|
||||
[English](SETUP.md) | [Español](SETUP.es.md) | [简体中文](SETUP.zh-CN.md) | [**日本語**](SETUP.ja.md)
|
||||
|
||||
ようこそ!このガイドでは、ローカル環境で Resume Matcher をセットアップする手順を説明します。開発に参加したい方も、手元でアプリを動かしたい方も、この手順で始められます。
|
||||
|
||||
---
|
||||
|
||||
## 目次
|
||||
|
||||
- [前提条件](#prerequisites)
|
||||
- [クイックスタート](#quick-start)
|
||||
- [手順どおりにセットアップ](#step-by-step-setup)
|
||||
- [1. リポジトリをクローン](#1-clone-the-repository)
|
||||
- [2. バックエンドのセットアップ](#2-backend-setup)
|
||||
- [3. フロントエンドのセットアップ](#3-frontend-setup)
|
||||
- [AI プロバイダの設定](#configuring-your-ai-provider)
|
||||
- [オプション A: クラウドプロバイダ](#option-a-cloud-providers)
|
||||
- [オプション B: Ollama によるローカル AI(無料)](#option-b-local-ai-with-ollama-free)
|
||||
- [Docker デプロイ](#docker-deployment)
|
||||
- [アプリへのアクセス](#accessing-the-application)
|
||||
- [よく使うコマンド](#common-commands-reference)
|
||||
- [トラブルシューティング](#troubleshooting)
|
||||
- [プロジェクト構成](#project-structure-overview)
|
||||
- [ヘルプ](#getting-help)
|
||||
|
||||
---
|
||||
|
||||
<a id="prerequisites"></a>
|
||||
## 前提条件
|
||||
|
||||
開始前に、以下がインストールされていることを確認してください:
|
||||
|
||||
| ツール | 最低バージョン | 確認方法 | インストール |
|
||||
|------|----------------|----------|--------------|
|
||||
| **Python** | 3.13+ | `python --version` | [python.org](https://python.org) |
|
||||
| **Node.js** | 22+ | `node --version` | [nodejs.org](https://nodejs.org) |
|
||||
| **npm** | 10+ | `npm --version` | Node.js に同梱 |
|
||||
| **uv** | 最新 | `uv --version` | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) |
|
||||
| **Git** | 任意 | `git --version` | [git-scm.com](https://git-scm.com) |
|
||||
|
||||
### uv のインストール(Python パッケージマネージャ)
|
||||
|
||||
Resume Matcher は Python 依存関係の管理に `uv` を使用します。インストール方法:
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
|
||||
# または pip
|
||||
pip install uv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="quick-start"></a>
|
||||
## クイックスタート
|
||||
|
||||
開発ツールに慣れていて、まず動かしたい方向け:
|
||||
|
||||
```bash
|
||||
# 1. リポジトリをクローン
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
|
||||
# 2. バックエンド起動(ターミナル 1)
|
||||
cd apps/backend
|
||||
cp .env.example .env # テンプレートから設定を作成
|
||||
uv sync # Python 依存関係をインストール
|
||||
uv run app
|
||||
|
||||
# 3. フロントエンド起動(ターミナル 2)
|
||||
cd apps/frontend
|
||||
npm install # Node.js 依存関係をインストール
|
||||
npm run dev # 開発サーバを起動
|
||||
```
|
||||
|
||||
ブラウザで **<http://localhost:3000>** を開けば OK です。
|
||||
|
||||
> **注意:** 利用前に AI プロバイダの設定が必要です。下の [AI プロバイダの設定](#configuring-your-ai-provider) を参照してください。
|
||||
|
||||
---
|
||||
|
||||
<a id="step-by-step-setup"></a>
|
||||
## 手順どおりにセットアップ
|
||||
|
||||
<a id="1-clone-the-repository"></a>
|
||||
### 1. リポジトリをクローン
|
||||
|
||||
まずはコードを取得します:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
```
|
||||
|
||||
<a id="2-backend-setup"></a>
|
||||
### 2. バックエンドのセットアップ
|
||||
|
||||
バックエンドは Python(FastAPI)で、AI 処理、履歴書の解析、データ保存を担当します。
|
||||
|
||||
#### バックエンドディレクトリへ移動
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
```
|
||||
|
||||
#### 環境ファイルを作成
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
#### `.env` を好みのエディタで編集
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
nano .env
|
||||
|
||||
# 好みのエディタでも OK
|
||||
code .env # VS Code
|
||||
```
|
||||
|
||||
最重要設定は AI プロバイダです。OpenAI の最小例:
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=openai
|
||||
LLM_MODEL=gpt-5-nano-2025-08-07
|
||||
LLM_API_KEY=sk-your-api-key-here
|
||||
|
||||
# ローカル開発では既定のままで OK
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
FRONTEND_BASE_URL=http://localhost:3000
|
||||
CORS_ORIGINS=["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
```
|
||||
|
||||
#### Python 依存関係をインストール
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
仮想環境を作成し、必要なパッケージをインストールします。
|
||||
|
||||
#### バックエンドサーバを起動
|
||||
|
||||
```bash
|
||||
RELOAD=true uv run app
|
||||
```
|
||||
|
||||
次のような出力が表示されます:
|
||||
|
||||
```
|
||||
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||
INFO: Started reloader process
|
||||
```
|
||||
|
||||
**このターミナルは起動したまま**、フロントエンド用に別ターミナルを開きます。
|
||||
|
||||
<a id="3-frontend-setup"></a>
|
||||
### 3. フロントエンドのセットアップ
|
||||
|
||||
フロントエンドは Next.js で、UI を提供します。
|
||||
|
||||
#### フロントエンドディレクトリへ移動
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
```
|
||||
|
||||
#### (任意)フロントエンドの環境ファイルを作成
|
||||
|
||||
バックエンドを別ポートで動かす場合のみ必要です:
|
||||
|
||||
```bash
|
||||
cp .env.sample .env.local
|
||||
```
|
||||
|
||||
#### Node.js 依存関係をインストール
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### 開発サーバを起動
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
次のように表示されます:
|
||||
|
||||
```
|
||||
▲ Next.js 16.x.x (Turbopack)
|
||||
- Local: http://localhost:3000
|
||||
```
|
||||
|
||||
ブラウザで **<http://localhost:3000>** を開くと、Resume Matcher のダッシュボードが表示されます。
|
||||
|
||||
---
|
||||
|
||||
<a id="configuring-your-ai-provider"></a>
|
||||
## AI プロバイダの設定
|
||||
|
||||
Resume Matcher は複数の AI プロバイダに対応しています。アプリ内の Settings ページ、またはバックエンドの `.env` を編集して設定できます。
|
||||
|
||||
<a id="option-a-cloud-providers"></a>
|
||||
### オプション A: クラウドプロバイダ
|
||||
|
||||
| プロバイダ | 設定 | API キー取得先 |
|
||||
|----------|------|----------------|
|
||||
| **OpenAI** | `LLM_PROVIDER=openai`<br>`LLM_MODEL=gpt-5-nano-2025-08-07` | [platform.openai.com](https://platform.openai.com/api-keys) |
|
||||
| **Anthropic** | `LLM_PROVIDER=anthropic`<br>`LLM_MODEL=claude-haiku-4-5-20251001` | [console.anthropic.com](https://console.anthropic.com/) |
|
||||
| **Google Gemini** | `LLM_PROVIDER=gemini`<br>`LLM_MODEL=gemini-3-flash-preview` | [aistudio.google.com](https://aistudio.google.com/app/apikey) |
|
||||
| **OpenRouter** | `LLM_PROVIDER=openrouter`<br>`LLM_MODEL=deepseek/deepseek-chat` | [openrouter.ai](https://openrouter.ai/keys) |
|
||||
| **DeepSeek** | `LLM_PROVIDER=deepseek`<br>`LLM_MODEL=deepseek-chat` | [platform.deepseek.com](https://platform.deepseek.com/) |
|
||||
| **OpenAI-Compatible** | `LLM_PROVIDER=openai_compatible`<br>`LLM_MODEL=llama-3.1-8b`<br>`LLM_API_BASE=http://localhost:8080/v1` | — (ローカル) |
|
||||
|
||||
**OpenAI-Compatible** は OpenAI Chat Completions API を公開する任意のローカルサーバー(llama.cpp、vLLM、LM Studio など)を対象とします。API キーは任意です。
|
||||
|
||||
Anthropic の `.env` 例:
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=anthropic
|
||||
LLM_MODEL=claude-haiku-4-5-20251001
|
||||
LLM_API_KEY=sk-ant-your-key-here
|
||||
```
|
||||
|
||||
<a id="option-b-local-ai-with-ollama-free"></a>
|
||||
### オプション B: Ollama によるローカル AI(無料)
|
||||
|
||||
API コストなしでローカル実行したい場合は Ollama を使えます。
|
||||
|
||||
#### ステップ 1: Ollama をインストール
|
||||
|
||||
[ollama.com](https://ollama.com) からダウンロードしてインストールします。
|
||||
|
||||
#### ステップ 2: モデルを取得
|
||||
|
||||
```bash
|
||||
ollama pull gemma3:4b
|
||||
```
|
||||
|
||||
他の候補:`mistral`、`codellama`、`neural-chat`
|
||||
|
||||
#### ステップ 3: `.env` を設定
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=gemma3:4b
|
||||
LLM_API_BASE=http://localhost:11434
|
||||
# Ollama では LLM_API_KEY は不要です
|
||||
```
|
||||
|
||||
#### ステップ 4: Ollama が起動していることを確認
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
```
|
||||
|
||||
通常はインストール後に自動起動します。
|
||||
|
||||
---
|
||||
|
||||
<a id="docker-deployment"></a>
|
||||
## Docker デプロイ
|
||||
|
||||
コンテナで動かしたい場合、Resume Matcher は Docker に対応しています。
|
||||
|
||||
### Docker Compose を使う(推奨)
|
||||
|
||||
```bash
|
||||
# コンテナをビルドして起動
|
||||
docker-compose up -d
|
||||
|
||||
# ログを見る
|
||||
docker-compose logs -f
|
||||
|
||||
# コンテナ停止
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Docker の注意点
|
||||
|
||||
- **API キーは UI から設定**:<http://localhost:3000/settings>(`.env` ではありません)
|
||||
- データは Docker volume に永続化されます
|
||||
- フロントエンド(3000)とバックエンド(8000)のポートが公開されます
|
||||
|
||||
<!-- 注:Docker ドキュメントは準備中です。現在は docker-compose.yml を参照してください -->
|
||||
|
||||
---
|
||||
|
||||
<a id="accessing-the-application"></a>
|
||||
## アプリへのアクセス
|
||||
|
||||
両方のサーバが起動したら、ブラウザで以下にアクセスします:
|
||||
|
||||
| URL | 内容 |
|
||||
|-----|------|
|
||||
| **<http://localhost:3000>** | メインアプリ(Dashboard) |
|
||||
| **<http://localhost:3000/settings>** | AI プロバイダ設定 |
|
||||
| **<http://localhost:8000>** | バックエンド API ルート |
|
||||
| **<http://localhost:8000/docs>** | 対話型 API ドキュメント |
|
||||
| **<http://localhost:8000/health>** | バックエンドヘルスチェック |
|
||||
|
||||
### 初回セットアップチェックリスト
|
||||
|
||||
1. <http://localhost:3000/settings> を開く
|
||||
2. AI プロバイダを選択
|
||||
3. API キーを入力(または Ollama を設定)
|
||||
4. "Save Configuration" をクリック
|
||||
5. "Test Connection" をクリックして確認
|
||||
6. Dashboard に戻り、最初の履歴書をアップロード
|
||||
|
||||
---
|
||||
|
||||
<a id="common-commands-reference"></a>
|
||||
## よく使うコマンド
|
||||
|
||||
### バックエンド
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# 開発サーバ(自動リロード)
|
||||
RELOAD=true uv run app
|
||||
|
||||
# 本番サーバ
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# 依存関係のインストール
|
||||
uv sync
|
||||
|
||||
# 開発用依存関係も含める(テスト用)
|
||||
uv sync --group dev
|
||||
|
||||
# テスト実行
|
||||
uv run pytest
|
||||
|
||||
# DB の状態確認(JSON ファイル)
|
||||
ls -la data/
|
||||
```
|
||||
|
||||
### フロントエンド
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
|
||||
# 開発サーバ(Turbopack)
|
||||
npm run dev
|
||||
|
||||
# 本番ビルド
|
||||
npm run build
|
||||
|
||||
# 本番起動
|
||||
npm run start
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
|
||||
# Prettier で整形
|
||||
npm run format
|
||||
|
||||
# 別ポートで起動
|
||||
npm run dev -- -p 3001
|
||||
```
|
||||
|
||||
### データベース管理
|
||||
|
||||
Resume Matcher は TinyDB(JSON ファイル保存)を使用します。データは `apps/backend/data/` にあります:
|
||||
|
||||
```bash
|
||||
# DB ファイルを見る
|
||||
ls apps/backend/data/
|
||||
|
||||
# バックアップ
|
||||
cp -r apps/backend/data apps/backend/data-backup
|
||||
|
||||
# 全リセット(初期化)
|
||||
rm -rf apps/backend/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## トラブルシューティング
|
||||
|
||||
### バックエンドが起動しない
|
||||
|
||||
**Error:** `ModuleNotFoundError`
|
||||
|
||||
`uv` で起動していることを確認してください:
|
||||
|
||||
```bash
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**Error:** `LLM_API_KEY not configured`
|
||||
|
||||
`.env` に選択したプロバイダ用の API キーが設定されているか確認してください。
|
||||
|
||||
### フロントエンドが起動しない
|
||||
|
||||
**Error:** ページ読み込み時に `ECONNREFUSED`
|
||||
|
||||
バックエンドが起動していません。先に起動してください:
|
||||
|
||||
```bash
|
||||
cd apps/backend && uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**Error:** build または TypeScript エラー
|
||||
|
||||
Next.js のキャッシュを削除します:
|
||||
|
||||
```bash
|
||||
rm -rf apps/frontend/.next
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### PDF のダウンロードに失敗する
|
||||
|
||||
**Error:** `Cannot connect to frontend for PDF generation`
|
||||
|
||||
バックエンドからフロントエンドへ接続できません。以下を確認してください:
|
||||
|
||||
1. フロントエンドが起動している
|
||||
2. `.env` の `FRONTEND_BASE_URL` がフロントエンド URL と一致している
|
||||
3. `CORS_ORIGINS` にフロントエンド URL が含まれている
|
||||
|
||||
フロントエンドが 3001 の場合:
|
||||
|
||||
```env
|
||||
FRONTEND_BASE_URL=http://localhost:3001
|
||||
CORS_ORIGINS=["http://localhost:3001", "http://127.0.0.1:3001"]
|
||||
```
|
||||
|
||||
### Ollama の接続に失敗する
|
||||
|
||||
**Error:** `Connection refused to localhost:11434`
|
||||
|
||||
1. Ollama の稼働確認:`ollama list`
|
||||
2. 必要なら起動:`ollama serve`
|
||||
3. モデルが取得済みか確認:`ollama pull gemma3:4b`
|
||||
|
||||
---
|
||||
|
||||
<a id="project-structure-overview"></a>
|
||||
## プロジェクト構成
|
||||
|
||||
```text
|
||||
Resume-Matcher/
|
||||
├─ apps/
|
||||
│ ├─ backend/ # Python FastAPI backend
|
||||
│ │ ├─ app/
|
||||
│ │ │ ├─ main.py # Application entry point
|
||||
│ │ │ ├─ config.py # Environment configuration
|
||||
│ │ │ ├─ database.py # TinyDB wrapper
|
||||
│ │ │ ├─ llm.py # AI provider integration
|
||||
│ │ │ ├─ routers/ # API endpoints
|
||||
│ │ │ ├─ services/ # Business logic
|
||||
│ │ │ └─ schemas/ # Data models
|
||||
│ │ ├─ prompts/ # LLM prompt templates
|
||||
│ │ ├─ data/ # Database storage (auto-created)
|
||||
│ │ ├─ .env.example # Environment template
|
||||
│ │ └─ pyproject.toml # Python dependencies
|
||||
│ └─ frontend/ # Next.js React frontend
|
||||
│ ├─ app/ # Pages (dashboard, builder, etc.)
|
||||
│ ├─ components/ # Reusable React components
|
||||
│ ├─ lib/ # Utilities and API client
|
||||
│ ├─ .env.sample # Environment template
|
||||
│ └─ package.json # Node.js dependencies
|
||||
├─ docs/ # Additional documentation
|
||||
├─ docker-compose.yml # Docker configuration
|
||||
├─ Dockerfile # Container build instructions
|
||||
└─ README.md # Project overview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="getting-help"></a>
|
||||
## ヘルプ
|
||||
|
||||
困ったときは次を参照してください:
|
||||
|
||||
- **Discord:** [dsc.gg/resume-matcher](https://dsc.gg/resume-matcher) - 質問・議論に活発です
|
||||
- **GitHub Issues:** [Issue を作成](https://github.com/srbhr/Resume-Matcher/issues)(バグ報告や要望)
|
||||
- **ドキュメント:** 詳細は [docs/agent/](docs/agent/) を参照
|
||||
|
||||
### 参考ドキュメント
|
||||
|
||||
| ドキュメント | 内容 |
|
||||
|-------------|------|
|
||||
| [backend-guide.md](docs/agent/architecture/backend-guide.md) | バックエンドのアーキテクチャと API 詳細 |
|
||||
| [frontend-workflow.md](docs/agent/architecture/frontend-workflow.md) | ユーザーフローとコンポーネント構成 |
|
||||
| [swiss-design-system/](docs/portable/swiss-design-system/README.md) | UI デザインシステム(Swiss International Style)— ポータブルパック |
|
||||
|
||||
---
|
||||
|
||||
楽しい履歴書づくりを!Resume Matcher が役立ったら、[リポジトリに Star](https://github.com/srbhr/Resume-Matcher) と [Discord 参加](https://dsc.gg/resume-matcher) をぜひ。
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
# Resume Matcher Setup Guide
|
||||
|
||||
[**English**](SETUP.md) | [Español](SETUP.es.md) | [简体中文](SETUP.zh-CN.md) | [日本語](SETUP.ja.md)
|
||||
|
||||
Welcome! This guide will walk you through setting up Resume Matcher on your local machine. Whether you're a developer looking to contribute or someone who wants to run the application locally, this guide has you covered.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Step-by-Step Setup](#step-by-step-setup)
|
||||
- [1. Clone the Repository](#1-clone-the-repository)
|
||||
- [2. Backend Setup](#2-backend-setup)
|
||||
- [3. Frontend Setup](#3-frontend-setup)
|
||||
- [Configuring Your AI Provider](#configuring-your-ai-provider)
|
||||
- [Option A: Cloud Providers](#option-a-cloud-providers)
|
||||
- [Option B: Local AI with Ollama](#option-b-local-ai-with-ollama-free)
|
||||
- [Docker Deployment](#docker-deployment)
|
||||
- [Accessing the Application](#accessing-the-application)
|
||||
- [Common Commands Reference](#common-commands-reference)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Project Structure Overview](#project-structure-overview)
|
||||
- [Getting Help](#getting-help)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, make sure you have the following installed on your system:
|
||||
|
||||
| Tool | Minimum Version | How to Check | Installation |
|
||||
|------|-----------------|--------------|--------------|
|
||||
| **Python** | 3.13+ | `python --version` | [python.org](https://python.org) |
|
||||
| **Node.js** | 22+ | `node --version` | [nodejs.org](https://nodejs.org) |
|
||||
| **npm** | 10+ | `npm --version` | Comes with Node.js |
|
||||
| **uv** | Latest | `uv --version` | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) |
|
||||
| **Git** | Any | `git --version` | [git-scm.com](https://git-scm.com) |
|
||||
|
||||
### Installing uv (Python Package Manager)
|
||||
|
||||
Resume Matcher uses `uv` for fast, reliable Python dependency management. Install it with:
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
|
||||
# Or via pip
|
||||
pip install uv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
If you're familiar with development tools and want to get running quickly:
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
|
||||
# 2. Start the backend (Terminal 1)
|
||||
cd apps/backend
|
||||
cp .env.example .env # Create config from template
|
||||
uv sync # Install Python dependencies
|
||||
uv run app
|
||||
|
||||
# 3. Start the frontend (Terminal 2)
|
||||
cd apps/frontend
|
||||
npm install # Install Node.js dependencies
|
||||
npm run dev # Start the dev server
|
||||
```
|
||||
|
||||
Open your browser to **<http://localhost:3000>** and you're ready to go!
|
||||
|
||||
> **Note:** You'll need to configure an AI provider before using the app. See [Configuring Your AI Provider](#configuring-your-ai-provider) below.
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
First, get the code on your machine:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
```
|
||||
|
||||
### 2. Backend Setup
|
||||
|
||||
The backend is a Python FastAPI application that handles AI processing, resume parsing, and data storage.
|
||||
|
||||
#### Navigate to the backend directory
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
```
|
||||
|
||||
#### Create your environment file
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
#### Edit the `.env` file with your preferred text editor
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
nano .env
|
||||
|
||||
# Or use any editor you prefer
|
||||
code .env # VS Code
|
||||
```
|
||||
|
||||
The most important setting is your AI provider. Here's a minimal configuration for OpenAI:
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=openai
|
||||
LLM_MODEL=gpt-5-nano-2025-08-07
|
||||
LLM_API_KEY=sk-your-api-key-here
|
||||
|
||||
# Keep these as default for local development
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
FRONTEND_BASE_URL=http://localhost:3000
|
||||
CORS_ORIGINS=["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
```
|
||||
|
||||
#### Install Python dependencies
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
This creates a virtual environment and installs all required packages.
|
||||
|
||||
#### Start the backend server
|
||||
|
||||
```bash
|
||||
RELOAD=true uv run app
|
||||
```
|
||||
|
||||
You should see output like:
|
||||
|
||||
```
|
||||
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||
INFO: Started reloader process
|
||||
```
|
||||
|
||||
**Keep this terminal running** and open a new terminal for the frontend.
|
||||
|
||||
### 3. Frontend Setup
|
||||
|
||||
The frontend is a Next.js application that provides the user interface.
|
||||
|
||||
#### Navigate to the frontend directory
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
```
|
||||
|
||||
#### (Optional) Create a frontend environment file
|
||||
|
||||
This is only needed if your backend runs on a different port:
|
||||
|
||||
```bash
|
||||
cp .env.sample .env.local
|
||||
```
|
||||
|
||||
#### Install Node.js dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### Start the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```
|
||||
▲ Next.js 16.x.x (Turbopack)
|
||||
- Local: http://localhost:3000
|
||||
```
|
||||
|
||||
Open **<http://localhost:3000>** in your browser. You should see the Resume Matcher dashboard!
|
||||
|
||||
---
|
||||
|
||||
## Configuring Your AI Provider
|
||||
|
||||
Resume Matcher supports multiple AI providers. You can configure your provider through the Settings page in the app, or by editing the backend `.env` file.
|
||||
|
||||
### Option A: Cloud Providers
|
||||
|
||||
| Provider | Configuration | Get API Key |
|
||||
|----------|--------------|-------------|
|
||||
| **OpenAI** | `LLM_PROVIDER=openai`<br>`LLM_MODEL=gpt-5-nano-2025-08-07` | [platform.openai.com](https://platform.openai.com/api-keys) |
|
||||
| **Anthropic** | `LLM_PROVIDER=anthropic`<br>`LLM_MODEL=claude-haiku-4-5-20251001` | [console.anthropic.com](https://console.anthropic.com/) |
|
||||
| **Google Gemini** | `LLM_PROVIDER=gemini`<br>`LLM_MODEL=gemini/gemini-3-flash-preview` | [aistudio.google.com](https://aistudio.google.com/app/apikey) |
|
||||
| **OpenRouter** | `LLM_PROVIDER=openrouter`<br>`LLM_MODEL=deepseek/deepseek-chat` | [openrouter.ai](https://openrouter.ai/keys) |
|
||||
| **DeepSeek** | `LLM_PROVIDER=deepseek`<br>`LLM_MODEL=deepseek-chat` | [platform.deepseek.com](https://platform.deepseek.com/) |
|
||||
| **OpenAI-Compatible** | `LLM_PROVIDER=openai_compatible`<br>`LLM_MODEL=llama-3.1-8b`<br>`LLM_API_BASE=http://localhost:8080/v1` | — (local) |
|
||||
|
||||
**OpenAI-Compatible** targets any local server that exposes the OpenAI Chat Completions API — llama.cpp, vLLM, LM Studio, etc. API key is optional.
|
||||
|
||||
Example `.env` for Anthropic:
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=anthropic
|
||||
LLM_MODEL=claude-haiku-4-5-20251001
|
||||
LLM_API_KEY=sk-ant-your-key-here
|
||||
```
|
||||
|
||||
### Option B: Local AI with Ollama (Free)
|
||||
|
||||
Want to run AI models locally without API costs? Use Ollama!
|
||||
|
||||
#### Step 1: Install Ollama
|
||||
|
||||
Download and install from [ollama.com](https://ollama.com)
|
||||
|
||||
#### Step 2: Pull a model
|
||||
|
||||
```bash
|
||||
ollama pull gemma3:4b
|
||||
```
|
||||
|
||||
Other good options: `llama3.2`, `mistral`, `codellama`, `neural-chat`
|
||||
|
||||
#### Step 3: Configure your `.env`
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=gemma3:4b
|
||||
LLM_API_BASE=http://localhost:11434
|
||||
# LLM_API_KEY is not needed for Ollama
|
||||
```
|
||||
|
||||
#### Step 4: Make sure Ollama is running
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
```
|
||||
|
||||
Ollama typically starts automatically after installation.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
Prefer containerized deployment? Resume Matcher includes Docker support.
|
||||
|
||||
### Quick Start with Docker Compose
|
||||
|
||||
```bash
|
||||
# Start the container from a published image
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop the container
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Customizing Ports
|
||||
|
||||
```bash
|
||||
# Change host port only (container stays on 3000)
|
||||
PORT=4000 docker compose up -d
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3000` | Host port mapped to container port `3000` |
|
||||
| `LOG_LEVEL` | `INFO` | Application-wide Python/Uvicorn log level (`ERROR`, `WARNING`, `INFO`, `DEBUG`) |
|
||||
| `LOG_LLM` | `WARNING` | LiteLLM log level (`ERROR`, `WARNING`, `INFO`, `DEBUG`) |
|
||||
| `LLM_PROVIDER` | `openai` | AI provider (openai, anthropic, gemini, etc.) |
|
||||
| `LLM_MODEL` | — | Model to use (configured via Settings UI) |
|
||||
| `LLM_API_KEY` | — | API key (recommended: configure via Settings UI) |
|
||||
| `LLM_API_BASE` | — | Custom API endpoint (for Ollama or proxies) |
|
||||
|
||||
> **Note:** Changes to `LOG_LEVEL` and `LOG_LLM` require a container restart to take effect.
|
||||
|
||||
### Using Ollama with Docker
|
||||
|
||||
To use Ollama running on your host machine:
|
||||
|
||||
```bash
|
||||
LLM_API_BASE=http://host.docker.internal:11434 docker compose up -d
|
||||
```
|
||||
|
||||
Then configure Ollama as your provider in the Settings UI.
|
||||
|
||||
### Using Docker Secrets
|
||||
|
||||
The container supports `*_FILE` from
|
||||
[docker secrets](https://docs.docker.com/compose/how-tos/use-secrets/#use-secrets).
|
||||
For sensitive values, you can mount a secret file and point to it:
|
||||
|
||||
```bash
|
||||
LLM_API_KEY_FILE=/run/secrets/llm_api_key docker compose up -d
|
||||
```
|
||||
|
||||
Supported `*_FILE` variables:
|
||||
|
||||
| Variable | `*_FILE` variant |
|
||||
|----------|-----------------|
|
||||
| `LOG_LEVEL` | `LOG_LEVEL_FILE` |
|
||||
| `LOG_LLM` | `LOG_LLM_FILE` |
|
||||
| `LLM_PROVIDER` | `LLM_PROVIDER_FILE` |
|
||||
| `LLM_MODEL` | `LLM_MODEL_FILE` |
|
||||
| `LLM_API_KEY` | `LLM_API_KEY_FILE` |
|
||||
| `LLM_API_BASE` | `LLM_API_BASE_FILE` |
|
||||
|
||||
Rules:
|
||||
|
||||
- Use either the variable or its `*_FILE` variant, not both.
|
||||
- If both are set, the container exits with an explicit error.
|
||||
|
||||
### Logging Level Configuration
|
||||
|
||||
You can tune logs globally and for LiteLLM separately:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=INFO LOG_LLM=DEBUG docker compose up -d
|
||||
```
|
||||
|
||||
> **Security warning:** `LOG_LLM=DEBUG` causes LiteLLM to log API keys in
|
||||
> plaintext. Do not use `DEBUG` level in production or shared environments.
|
||||
> The default `WARNING` is safe.
|
||||
|
||||
> **Note:** LiteLLM also reads the `LITELLM_LOG` environment variable internally
|
||||
> to control handler-level filtering. `LOG_LLM` sets the *logger* level. Both must
|
||||
> allow a message for it to appear. If you set `LITELLM_LOG` from LiteLLM docs,
|
||||
> make sure `LOG_LLM` is set to an equal or lower level.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **API keys are best configured through the UI** at `http://localhost:3000/settings`
|
||||
- Data is persisted in a Docker volume (`resume-data`)
|
||||
- The Settings UI configuration is stored in the volume and persists across restarts
|
||||
- App and API share the same origin: frontend on `/`, API on `/api`
|
||||
|
||||
|
||||
|
||||
## Accessing the Application
|
||||
|
||||
Once the container is running, open your browser:
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| **<http://localhost:3000>** | Main application (Dashboard) |
|
||||
| **<http://localhost:3000/settings>** | Configure AI provider |
|
||||
| **<http://localhost:3000/api/v1/health>** | Backend health check |
|
||||
| **<http://localhost:3000/docs>** | Interactive API documentation |
|
||||
|
||||
### First-Time Setup Checklist
|
||||
|
||||
1. Open <http://localhost:3000/settings>
|
||||
2. Select your AI provider
|
||||
3. Enter your API key (or configure Ollama)
|
||||
4. Click "Save Configuration"
|
||||
5. Click "Test Connection" to verify it works
|
||||
6. Return to Dashboard and upload your first resume!
|
||||
|
||||
---
|
||||
|
||||
## Common Commands Reference
|
||||
|
||||
### Backend Commands
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Start development server (with auto-reload)
|
||||
RELOAD=true uv run app
|
||||
|
||||
# Start production server
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Install with dev dependencies (for testing)
|
||||
uv sync --group dev
|
||||
|
||||
# Run tests
|
||||
uv run pytest
|
||||
|
||||
# Check if database needs reset (stored as JSON files)
|
||||
ls -la data/
|
||||
```
|
||||
|
||||
### Frontend Commands
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
|
||||
# Start development server (with Turbopack for fast refresh)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Start production server
|
||||
npm run start
|
||||
|
||||
# Run linter
|
||||
npm run lint
|
||||
|
||||
# Format code with Prettier
|
||||
npm run format
|
||||
|
||||
# Run on a different port
|
||||
npm run dev -- -p 3001
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
Resume Matcher uses TinyDB (JSON file storage). All data is in `apps/backend/data/`:
|
||||
|
||||
```bash
|
||||
# View database files
|
||||
ls apps/backend/data/
|
||||
|
||||
# Backup your data
|
||||
cp -r apps/backend/data apps/backend/data-backup
|
||||
|
||||
# Reset everything (start fresh)
|
||||
rm -rf apps/backend/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start
|
||||
|
||||
**Error:** `ModuleNotFoundError`
|
||||
|
||||
Make sure you're running with `uv`:
|
||||
|
||||
```bash
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**Error:** `LLM_API_KEY not configured`
|
||||
|
||||
Check your `.env` file has a valid API key for your chosen provider.
|
||||
|
||||
### Frontend won't start
|
||||
|
||||
**Error:** `ECONNREFUSED` when loading pages
|
||||
|
||||
The backend isn't running. Start it first:
|
||||
|
||||
```bash
|
||||
cd apps/backend && uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**Error:** Build or TypeScript errors
|
||||
|
||||
Clear the Next.js cache:
|
||||
|
||||
```bash
|
||||
rm -rf apps/frontend/.next
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### PDF Download fails
|
||||
|
||||
**Error:** `Cannot connect to frontend for PDF generation`
|
||||
|
||||
Your backend can't reach the frontend. Check:
|
||||
|
||||
1. Frontend is running
|
||||
2. `FRONTEND_BASE_URL` in `.env` matches your frontend URL
|
||||
3. `CORS_ORIGINS` includes your frontend URL
|
||||
|
||||
If frontend runs on port 3001:
|
||||
|
||||
```env
|
||||
FRONTEND_BASE_URL=http://localhost:3001
|
||||
CORS_ORIGINS=["http://localhost:3001", "http://127.0.0.1:3001"]
|
||||
```
|
||||
|
||||
### Ollama connection fails
|
||||
|
||||
**Error:** `Connection refused to localhost:11434`
|
||||
|
||||
1. Check Ollama is running: `ollama list`
|
||||
2. Start Ollama if needed: `ollama serve`
|
||||
3. Make sure the model is downloaded: `ollama pull gemma3:4b`
|
||||
|
||||
---
|
||||
|
||||
## Project Structure Overview
|
||||
|
||||
```
|
||||
Resume-Matcher/
|
||||
├── apps/
|
||||
│ ├── backend/ # Python FastAPI backend
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── main.py # Application entry point
|
||||
│ │ │ ├── config.py # Environment configuration
|
||||
│ │ │ ├── database.py # TinyDB wrapper
|
||||
│ │ │ ├── llm.py # AI provider integration
|
||||
│ │ │ ├── routers/ # API endpoints
|
||||
│ │ │ ├── services/ # Business logic
|
||||
│ │ │ ├── schemas/ # Data models
|
||||
│ │ │ └── prompts/ # LLM prompt templates
|
||||
│ │ ├── data/ # Database storage (auto-created)
|
||||
│ │ ├── .env.example # Environment template
|
||||
│ │ └── pyproject.toml # Python dependencies
|
||||
│ │
|
||||
│ └── frontend/ # Next.js React frontend
|
||||
│ ├── app/ # Pages (dashboard, builder, etc.)
|
||||
│ ├── components/ # Reusable React components
|
||||
│ ├── lib/ # Utilities and API client
|
||||
│ ├── .env.sample # Environment template
|
||||
│ └── package.json # Node.js dependencies
|
||||
│
|
||||
├── docs/ # Additional documentation
|
||||
├── docker-compose.yml # Docker configuration
|
||||
├── Dockerfile # Container build instructions
|
||||
└── README.md # Project overview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
Stuck? Here are your options:
|
||||
|
||||
- **Discord Community:** [dsc.gg/resume-matcher](https://dsc.gg/resume-matcher) - Active community for questions and discussions
|
||||
- **GitHub Issues:** [Open an issue](https://github.com/srbhr/Resume-Matcher/issues) for bugs or feature requests
|
||||
- **Documentation:** Check the [docs/agent/](docs/agent/) folder for detailed guides
|
||||
|
||||
### Useful Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [backend-guide.md](docs/agent/architecture/backend-guide.md) | Backend architecture and API details |
|
||||
| [frontend-workflow.md](docs/agent/architecture/frontend-workflow.md) | User flow and component architecture |
|
||||
| [swiss-design-system/](docs/portable/swiss-design-system/README.md) | UI design system (Swiss International Style) — portable pack |
|
||||
|
||||
---
|
||||
|
||||
Happy resume building! If you find Resume Matcher helpful, consider [starring the repo](https://github.com/srbhr/Resume-Matcher) and [joining our Discord](https://dsc.gg/resume-matcher).
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
# Resume Matcher 安装与配置指南
|
||||
|
||||
[English](SETUP.md) | [Español](SETUP.es.md) | [**简体中文**](SETUP.zh-CN.md) | [日本語](SETUP.ja.md)
|
||||
|
||||
欢迎!本指南将带你在本地完成 Resume Matcher 的安装与配置。无论你是想参与开发,还是只想在本机运行应用,都可以按本文档完成上手。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [前置条件](#prerequisites)
|
||||
- [快速开始](#quick-start)
|
||||
- [逐步安装](#step-by-step-setup)
|
||||
- [1. 克隆仓库](#1-clone-the-repository)
|
||||
- [2. 后端配置](#2-backend-setup)
|
||||
- [3. 前端配置](#3-frontend-setup)
|
||||
- [配置 AI 提供商](#configuring-your-ai-provider)
|
||||
- [选项 A:云端提供商](#option-a-cloud-providers)
|
||||
- [选项 B:使用 Ollama 的本地 AI(免费)](#option-b-local-ai-with-ollama-free)
|
||||
- [Docker 部署](#docker-deployment)
|
||||
- [访问应用](#accessing-the-application)
|
||||
- [常用命令速查](#common-commands-reference)
|
||||
- [故障排查](#troubleshooting)
|
||||
- [项目结构概览](#project-structure-overview)
|
||||
- [获取帮助](#getting-help)
|
||||
|
||||
---
|
||||
|
||||
<a id="prerequisites"></a>
|
||||
## 前置条件
|
||||
|
||||
开始前请确保系统已安装以下工具:
|
||||
|
||||
| 工具 | 最低版本 | 如何检查 | 安装 |
|
||||
|------|----------|----------|------|
|
||||
| **Python** | 3.13+ | `python --version` | [python.org](https://python.org) |
|
||||
| **Node.js** | 22+ | `node --version` | [nodejs.org](https://nodejs.org) |
|
||||
| **npm** | 10+ | `npm --version` | 随 Node.js 一起安装 |
|
||||
| **uv** | 最新 | `uv --version` | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) |
|
||||
| **Git** | 任意 | `git --version` | [git-scm.com](https://git-scm.com) |
|
||||
|
||||
### 安装 uv(Python 包管理器)
|
||||
|
||||
Resume Matcher 使用 `uv` 来实现更快、更稳定的 Python 依赖管理。可通过以下方式安装:
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
|
||||
# 或通过 pip
|
||||
pip install uv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="quick-start"></a>
|
||||
## 快速开始
|
||||
|
||||
如果你对开发工具比较熟悉,想快速跑起来:
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
|
||||
# 2. 启动后端(终端 1)
|
||||
cd apps/backend
|
||||
cp .env.example .env # 从模板创建配置
|
||||
uv sync # 安装 Python 依赖
|
||||
uv run app
|
||||
|
||||
# 3. 启动前端(终端 2)
|
||||
cd apps/frontend
|
||||
npm install # 安装 Node.js 依赖
|
||||
npm run dev # 启动开发服务器
|
||||
```
|
||||
|
||||
浏览器打开 **<http://localhost:3000>** 即可。
|
||||
|
||||
> **注意:** 使用应用前需要先配置 AI 提供商。见下方 [配置 AI 提供商](#configuring-your-ai-provider)。
|
||||
|
||||
---
|
||||
|
||||
<a id="step-by-step-setup"></a>
|
||||
## 逐步安装
|
||||
|
||||
<a id="1-clone-the-repository"></a>
|
||||
### 1. 克隆仓库
|
||||
|
||||
先把代码拉到本机:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/srbhr/Resume-Matcher.git
|
||||
cd Resume-Matcher
|
||||
```
|
||||
|
||||
<a id="2-backend-setup"></a>
|
||||
### 2. 后端配置
|
||||
|
||||
后端是 Python FastAPI 应用,负责 AI 调用、简历解析以及数据存储。
|
||||
|
||||
#### 进入后端目录
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
```
|
||||
|
||||
#### 创建环境变量文件
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
#### 使用你偏好的编辑器编辑 `.env`
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
nano .env
|
||||
|
||||
# 或使用任意编辑器
|
||||
code .env # VS Code
|
||||
```
|
||||
|
||||
最关键的配置是 AI 提供商。下面是 OpenAI 的最小示例配置:
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=openai
|
||||
LLM_MODEL=gpt-5-nano-2025-08-07
|
||||
LLM_API_KEY=sk-your-api-key-here
|
||||
|
||||
# 本地开发建议保持默认
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
FRONTEND_BASE_URL=http://localhost:3000
|
||||
CORS_ORIGINS=["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
```
|
||||
|
||||
#### 安装 Python 依赖
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
该命令会创建虚拟环境并安装所有必需依赖。
|
||||
|
||||
#### 启动后端服务
|
||||
|
||||
```bash
|
||||
RELOAD=true uv run app
|
||||
```
|
||||
|
||||
你会看到类似输出:
|
||||
|
||||
```
|
||||
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||
INFO: Started reloader process
|
||||
```
|
||||
|
||||
**保持该终端运行**,然后为前端另开一个终端窗口。
|
||||
|
||||
<a id="3-frontend-setup"></a>
|
||||
### 3. 前端配置
|
||||
|
||||
前端是 Next.js 应用,提供用户界面。
|
||||
|
||||
#### 进入前端目录
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
```
|
||||
|
||||
#### (可选)创建前端环境变量文件
|
||||
|
||||
仅当你的后端运行在不同端口时需要:
|
||||
|
||||
```bash
|
||||
cp .env.sample .env.local
|
||||
```
|
||||
|
||||
#### 安装 Node.js 依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
你会看到:
|
||||
|
||||
```
|
||||
▲ Next.js 16.x.x (Turbopack)
|
||||
- Local: http://localhost:3000
|
||||
```
|
||||
|
||||
浏览器打开 **<http://localhost:3000>**,你应该能看到 Resume Matcher 的界面。
|
||||
|
||||
---
|
||||
|
||||
<a id="configuring-your-ai-provider"></a>
|
||||
## 配置 AI 提供商
|
||||
|
||||
Resume Matcher 支持多种 AI 提供商。你可以在应用的 Settings 页面中配置,也可以直接编辑后端的 `.env` 文件。
|
||||
|
||||
<a id="option-a-cloud-providers"></a>
|
||||
### 选项 A:云端提供商
|
||||
|
||||
| 提供商 | 配置方式 | 获取 API Key |
|
||||
|--------|----------|--------------|
|
||||
| **OpenAI** | `LLM_PROVIDER=openai`<br>`LLM_MODEL=gpt-5-nano-2025-08-07` | [platform.openai.com](https://platform.openai.com/api-keys) |
|
||||
| **Anthropic** | `LLM_PROVIDER=anthropic`<br>`LLM_MODEL=claude-haiku-4-5-20251001` | [console.anthropic.com](https://console.anthropic.com/) |
|
||||
| **Google Gemini** | `LLM_PROVIDER=gemini`<br>`LLM_MODEL=gemini-3-flash-preview` | [aistudio.google.com](https://aistudio.google.com/app/apikey) |
|
||||
| **OpenRouter** | `LLM_PROVIDER=openrouter`<br>`LLM_MODEL=deepseek/deepseek-chat` | [openrouter.ai](https://openrouter.ai/keys) |
|
||||
| **DeepSeek** | `LLM_PROVIDER=deepseek`<br>`LLM_MODEL=deepseek-chat` | [platform.deepseek.com](https://platform.deepseek.com/) |
|
||||
| **OpenAI-Compatible** | `LLM_PROVIDER=openai_compatible`<br>`LLM_MODEL=llama-3.1-8b`<br>`LLM_API_BASE=http://localhost:8080/v1` | —(本地) |
|
||||
|
||||
**OpenAI-Compatible** 适用于任何暴露 OpenAI Chat Completions API 的本地服务器,例如 llama.cpp、vLLM、LM Studio 等。API Key 可选。
|
||||
|
||||
Anthropic 的 `.env` 示例:
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=anthropic
|
||||
LLM_MODEL=claude-haiku-4-5-20251001
|
||||
LLM_API_KEY=sk-ant-your-key-here
|
||||
```
|
||||
|
||||
<a id="option-b-local-ai-with-ollama-free"></a>
|
||||
### 选项 B:使用 Ollama 的本地 AI(免费)
|
||||
|
||||
想在本机运行模型、避免 API 费用?可以使用 Ollama。
|
||||
|
||||
#### 第 1 步:安装 Ollama
|
||||
|
||||
从 [ollama.com](https://ollama.com) 下载并安装。
|
||||
|
||||
#### 第 2 步:拉取模型
|
||||
|
||||
```bash
|
||||
ollama pull gemma3:4b
|
||||
```
|
||||
|
||||
其他可选模型:`mistral`、`codellama`、`neural-chat`
|
||||
|
||||
#### 第 3 步:配置 `.env`
|
||||
|
||||
```env
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=gemma3:4b
|
||||
LLM_API_BASE=http://localhost:11434
|
||||
# Ollama 不需要 LLM_API_KEY
|
||||
```
|
||||
|
||||
#### 第 4 步:确保 Ollama 正在运行
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
```
|
||||
|
||||
通常安装后 Ollama 会自动启动。
|
||||
|
||||
---
|
||||
|
||||
<a id="docker-deployment"></a>
|
||||
## Docker 部署
|
||||
|
||||
如果你更喜欢容器化部署,Resume Matcher 已提供 Docker 支持。
|
||||
|
||||
### 使用 Docker Compose(推荐)
|
||||
|
||||
```bash
|
||||
# 构建并启动容器
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 停止容器
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Docker 重要说明
|
||||
|
||||
- **API Key 通过 UI 配置**:<http://localhost:3000/settings>(不是通过 `.env` 文件)
|
||||
- 数据会保存在 Docker volume 中
|
||||
- 暴露前端(3000)与后端(8000)端口
|
||||
|
||||
<!-- 注意:Docker 文档正在编写中。目前请参考 docker-compose.yml -->
|
||||
|
||||
---
|
||||
|
||||
<a id="accessing-the-application"></a>
|
||||
## 访问应用
|
||||
|
||||
当后端与前端都启动后,可通过以下地址访问:
|
||||
|
||||
| URL | 说明 |
|
||||
|-----|------|
|
||||
| **<http://localhost:3000>** | 主应用(Dashboard) |
|
||||
| **<http://localhost:3000/settings>** | 配置 AI 提供商 |
|
||||
| **<http://localhost:8000>** | 后端 API 根路径 |
|
||||
| **<http://localhost:8000/docs>** | 可交互的 API 文档 |
|
||||
| **<http://localhost:8000/health>** | 后端健康检查 |
|
||||
|
||||
### 首次配置检查清单
|
||||
|
||||
1. 打开 <http://localhost:3000/settings>
|
||||
2. 选择你的 AI 提供商
|
||||
3. 填写 API Key(或配置 Ollama)
|
||||
4. 点击 “Save Configuration”
|
||||
5. 点击 “Test Connection” 验证连通性
|
||||
6. 回到 Dashboard,上传你的第一份简历
|
||||
|
||||
---
|
||||
|
||||
<a id="common-commands-reference"></a>
|
||||
## 常用命令速查
|
||||
|
||||
### 后端命令
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# 启动开发服务器(自动热重载)
|
||||
RELOAD=true uv run app
|
||||
|
||||
# 启动生产服务器
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# 安装依赖
|
||||
uv sync
|
||||
|
||||
# 安装开发依赖(用于测试)
|
||||
uv sync --group dev
|
||||
|
||||
# 运行测试
|
||||
uv run pytest
|
||||
|
||||
# 查看数据库文件(JSON 存储)
|
||||
ls -la data/
|
||||
```
|
||||
|
||||
### 前端命令
|
||||
|
||||
```bash
|
||||
cd apps/frontend
|
||||
|
||||
# 启动开发服务器(Turbopack 快速刷新)
|
||||
npm run dev
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
|
||||
# 启动生产服务器
|
||||
npm run start
|
||||
|
||||
# 运行 linter
|
||||
npm run lint
|
||||
|
||||
# 使用 Prettier 格式化
|
||||
npm run format
|
||||
|
||||
# 指定其他端口运行
|
||||
npm run dev -- -p 3001
|
||||
```
|
||||
|
||||
### 数据库管理
|
||||
|
||||
Resume Matcher 使用 TinyDB(JSON 文件存储)。数据位于 `apps/backend/data/`:
|
||||
|
||||
```bash
|
||||
# 查看数据库文件
|
||||
ls apps/backend/data/
|
||||
|
||||
# 备份数据
|
||||
cp -r apps/backend/data apps/backend/data-backup
|
||||
|
||||
# 重置数据(重新开始)
|
||||
rm -rf apps/backend/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="troubleshooting"></a>
|
||||
## 故障排查
|
||||
|
||||
### 后端无法启动
|
||||
|
||||
**错误:** `ModuleNotFoundError`
|
||||
|
||||
确认使用 `uv` 启动:
|
||||
|
||||
```bash
|
||||
uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**错误:** `LLM_API_KEY not configured`
|
||||
|
||||
检查你的 `.env` 是否为所选提供商配置了有效的 API Key。
|
||||
|
||||
### 前端无法启动
|
||||
|
||||
**错误:** 页面加载时报 `ECONNREFUSED`
|
||||
|
||||
后端未运行,请先启动后端:
|
||||
|
||||
```bash
|
||||
cd apps/backend && uv run uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
**错误:** 构建或 TypeScript 报错
|
||||
|
||||
清理 Next.js 缓存:
|
||||
|
||||
```bash
|
||||
rm -rf apps/frontend/.next
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### PDF 下载失败
|
||||
|
||||
**错误:** `Cannot connect to frontend for PDF generation`
|
||||
|
||||
后端无法访问前端,请检查:
|
||||
|
||||
1. 前端正在运行
|
||||
2. `.env` 中的 `FRONTEND_BASE_URL` 与前端 URL 一致
|
||||
3. `CORS_ORIGINS` 包含前端 URL
|
||||
|
||||
如果前端运行在 3001 端口:
|
||||
|
||||
```env
|
||||
FRONTEND_BASE_URL=http://localhost:3001
|
||||
CORS_ORIGINS=["http://localhost:3001", "http://127.0.0.1:3001"]
|
||||
```
|
||||
|
||||
### Ollama 连接失败
|
||||
|
||||
**错误:** `Connection refused to localhost:11434`
|
||||
|
||||
1. 确认 Ollama 在运行:`ollama list`
|
||||
2. 如有需要手动启动:`ollama serve`
|
||||
3. 确认模型已下载:`ollama pull gemma3:4b`
|
||||
|
||||
---
|
||||
|
||||
<a id="project-structure-overview"></a>
|
||||
## 项目结构概览
|
||||
|
||||
```text
|
||||
Resume-Matcher/
|
||||
├─ apps/
|
||||
│ ├─ backend/ # Python FastAPI backend
|
||||
│ │ ├─ app/
|
||||
│ │ │ ├─ main.py # Application entry point
|
||||
│ │ │ ├─ config.py # Environment configuration
|
||||
│ │ │ ├─ database.py # TinyDB wrapper
|
||||
│ │ │ ├─ llm.py # AI provider integration
|
||||
│ │ │ ├─ routers/ # API endpoints
|
||||
│ │ │ ├─ services/ # Business logic
|
||||
│ │ │ └─ schemas/ # Data models
|
||||
│ │ ├─ prompts/ # LLM prompt templates
|
||||
│ │ ├─ data/ # Database storage (auto-created)
|
||||
│ │ ├─ .env.example # Environment template
|
||||
│ │ └─ pyproject.toml # Python dependencies
|
||||
│ └─ frontend/ # Next.js React frontend
|
||||
│ ├─ app/ # Pages (dashboard, builder, etc.)
|
||||
│ ├─ components/ # Reusable React components
|
||||
│ ├─ lib/ # Utilities and API client
|
||||
│ ├─ .env.sample # Environment template
|
||||
│ └─ package.json # Node.js dependencies
|
||||
├─ docs/ # Additional documentation
|
||||
├─ docker-compose.yml # Docker configuration
|
||||
├─ Dockerfile # Container build instructions
|
||||
└─ README.md # Project overview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a id="getting-help"></a>
|
||||
## 获取帮助
|
||||
|
||||
如果遇到问题,可以从以下渠道获得支持:
|
||||
|
||||
- **Discord 社区:** [dsc.gg/resume-matcher](https://dsc.gg/resume-matcher) - 提问与讨论都很活跃
|
||||
- **GitHub Issues:** [提交 Issue](https://github.com/srbhr/Resume-Matcher/issues) 反馈 bug 或提出需求
|
||||
- **项目文档:** 查看 [docs/agent/](docs/agent/) 获取更详细的指南
|
||||
|
||||
### 推荐文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [backend-guide.md](docs/agent/architecture/backend-guide.md) | 后端架构与 API 细节 |
|
||||
| [frontend-workflow.md](docs/agent/architecture/frontend-workflow.md) | 用户流程与组件架构 |
|
||||
| [swiss-design-system/](docs/portable/swiss-design-system/README.md) | UI 设计系统(Swiss International Style)— 便携包 |
|
||||
|
||||
---
|
||||
|
||||
祝你简历制作顺利!如果 Resume Matcher 对你有帮助,欢迎 [给仓库点个 Star](https://github.com/srbhr/Resume-Matcher),以及 [加入我们的 Discord](https://dsc.gg/resume-matcher)。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Database files (local data)
|
||||
data/*.json
|
||||
data/config.json
|
||||
!data/.gitkeep
|
||||
|
||||
# Config file with API keys - NEVER COMMIT
|
||||
config.json
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -0,0 +1,171 @@
|
||||
# CLAUDE.md - Backend (`apps/backend`)
|
||||
|
||||
> FastAPI backend for Resume Matcher. This file goes **deeper on the backend**.
|
||||
> For project-wide context see the root [`.claude/CLAUDE.md`](../../.claude/CLAUDE.md) and [`docs/agent/README.md`](../../docs/agent/README.md).
|
||||
|
||||
Stack: FastAPI 0.128 · Python **3.13+** · Pydantic v2 / pydantic-settings · SQLAlchemy 2 (async) + SQLite (`aiosqlite`) · LiteLLM (multi-provider AI) · markitdown (DOCX/PDF→Markdown) · Playwright/Chromium (PDF). Managed with **uv** (`pyproject.toml`, version `1.2.0`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture / Module Map
|
||||
|
||||
| Module | Responsibility | Key files |
|
||||
|--------|----------------|-----------|
|
||||
| Entry / wiring | App, lifespan, CORS, router mounting (all under `/api/v1`) | `app/main.py` |
|
||||
| Settings | Env vars via `pydantic-settings`; `settings` singleton; API keys read from the encrypted SQLite store | `app/config.py` |
|
||||
| Crypto | Fernet encrypt/decrypt for API keys at rest (`data/.secret_key`, `chmod 600`, gitignored) | `app/crypto.py` |
|
||||
| Config cache | Shared, TTL-cached (5 min) read of `data/config.json`; `get_content_language()` | `app/config_cache.py` |
|
||||
| Database | Async SQLAlchemy/SQLite facade; tables `resumes`/`jobs`/`improvements`/`applications`/`api_keys`; returns plain dicts; global `db` singleton | `app/database.py`, `app/models.py`, `app/db_engine.py` |
|
||||
| Tracker | Kanban application-tracker endpoints | `app/routers/applications.py`, `app/schemas/applications.py` |
|
||||
| LLM | LiteLLM wrapper: Router, retries, JSON extraction, timeouts, provider quirks | `app/llm.py` |
|
||||
| PDF | Headless Chromium render of frontend `/print/*` pages; lazy browser init | `app/pdf.py` |
|
||||
| Routers | HTTP endpoints (see below) | `app/routers/*.py` |
|
||||
| Services | Business logic (parse, improve/diff, refine, cover-letter) | `app/services/*.py` |
|
||||
| Prompts | All LLM prompt templates + placeholder validation | `app/prompts/*.py` |
|
||||
| Schemas | Pydantic request/response + `ResumeData` models | `app/schemas/*.py` |
|
||||
|
||||
`data/` holds `resume_matcher.db` (SQLite; primary store), `config.json` (non-secret config), `.secret_key` (Fernet secret for encrypted API keys), an `uploads/` dir, and possibly a legacy `database.json` (TinyDB — imported into SQLite on first startup, then renamed `database.json.migrated`). `.gitignore` ignores `*.db*`, `data/*.json`, and `data/.secret_key` (DB + config + secret never get committed), but **`uploads/` is NOT git-ignored** — don't commit user uploads. `db.reset_database()` truncates the document tables + `applications` (preserving `api_keys`) and wipes `uploads/`.
|
||||
|
||||
### Routers (all prefixed `/api/v1`)
|
||||
- `health.py` — `GET /health` (liveness, no LLM call), `GET /status` (LLM health + DB stats).
|
||||
- `config.py` — `/config/llm-api-key` (GET/PUT), `/config/llm-test` (POST live health check), `/config/features`, `/config/language`, `/config/prompts`, `/config/feature-prompts`, `/config/api-keys` (per-provider CRUD), `/config/reset` (POST; confirmation token `{"confirm": "RESET_ALL_DATA"}` in the JSON **body**, not a query param).
|
||||
- `resumes.py` — the biggest router: `/resumes/upload`, `GET /resumes`, `/resumes/list`, `/resumes/improve` + `/improve/preview` + `/improve/confirm`, `PATCH /resumes/{id}`, `/{id}/pdf`, `/{id}/retry-processing`, cover-letter/outreach/title PATCH + on-demand generate, `/{id}/job-description`, `/{id}/cover-letter/pdf`.
|
||||
- `jobs.py` — `/jobs/upload` (batch JD text → job_ids), `GET /jobs/{id}`.
|
||||
- `enrichment.py` — `/enrichment/analyze/{id}`, `/enhance`, `/apply/{id}`, `/regenerate`, `/apply-regenerated/{id}`.
|
||||
|
||||
### Services
|
||||
- `parser.py` — `parse_document` (markitdown bytes→Markdown), `parse_resume_to_json` (LLM→`ResumeData`), `restore_dates_from_markdown` (re-inserts months the LLM drops).
|
||||
- `improver.py` (largest) — keyword extraction, **diff-based** improvement (`generate_resume_diffs` → `apply_diffs` with path allow/block-lists → `verify_diff_result`), skill-target planning (`generate_skill_target_plan`/`verify_skill_target_plan`), legacy full-output `improve_resume`, `calculate_resume_diff`. Sanitizes prompt-injection patterns in user input.
|
||||
- `refiner.py` — multi-pass polish: keyword injection (LLM), AI-phrase removal (local, via `refinement.py` blacklist), master-alignment validation. Driven by `RefinementConfig`.
|
||||
- `cover_letter.py` — `generate_cover_letter`, `generate_outreach_message`, `generate_resume_title`; resolves custom-vs-default feature prompts at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Request / Data Flow (improve = core pipeline)
|
||||
|
||||
`POST /resumes/improve/preview` is the canonical path:
|
||||
1. Load resume + job from `db`; resolve content language (`config_cache`) and `prompt_id`.
|
||||
2. `extract_job_keywords(jd)` (LLM, cached on the job by content hash).
|
||||
3. If structured `processed_data` exists → **diff mode**: skill-target plan → `generate_resume_diffs` → `apply_diffs` → `verify_diff_result`. Else → fallback `improve_resume` (full-output).
|
||||
4. **Local safety nets** (always run, defense-in-depth): `_preserve_personal_info`, `_restore_original_dates`, `restore_dates_from_markdown`, `_preserve_original_skills`, `_protect_custom_sections`.
|
||||
5. `refine_resume` (keyword injection + AI-phrase scrub + alignment check).
|
||||
6. Persist a `preview_hash` on the job. `/improve/confirm` re-validates that hash (and that `personalInfo` is unchanged) before persisting the tailored resume + an `improvements` record.
|
||||
|
||||
Routers call services; services call `app/llm.py`; persistence goes through the `db` singleton. The whole preview is wrapped in a 240s `asyncio.wait_for`.
|
||||
|
||||
---
|
||||
|
||||
## Prompt Management (read this before touching prompts)
|
||||
|
||||
Prompts are **plain Python string constants** — no Jinja, no external prompt files at runtime (`data/prompts.json` is *not* loaded by the app code paths reviewed). Layout:
|
||||
|
||||
| File | Holds |
|
||||
|------|-------|
|
||||
| `app/prompts/templates.py` | Resume parse, keyword extraction, the 3 improve variants, diff prompt, skill-target plan, cover-letter / outreach / title, `RESUME_SCHEMA_EXAMPLE`, `CRITICAL_TRUTHFULNESS_RULES`, `LANGUAGE_NAMES` + `get_language_name()` |
|
||||
| `app/prompts/enrichment.py` | `ANALYZE_RESUME_PROMPT`, `ENHANCE_DESCRIPTION_PROMPT`, `REGENERATE_ITEM_PROMPT`, `REGENERATE_SKILLS_PROMPT` |
|
||||
| `app/prompts/refinement.py` | `KEYWORD_INJECTION_PROMPT`, `VALIDATION_POLISH_PROMPT`, `AI_PHRASE_BLACKLIST`, `AI_PHRASE_REPLACEMENTS` |
|
||||
| `app/prompts/__init__.py` | Re-exports template constants; placeholder validation |
|
||||
|
||||
**Loading / parameterization:** services `from app.prompts import ...` then call `PROMPT.format(**vars)`. So `{placeholder}` = a real format key, and any *literal* `{}` (e.g. JSON examples) **must be doubled `{{ }}`** — see `EXTRACT_KEYWORDS_PROMPT`, `DIFF_IMPROVE_PROMPT`, the enrichment prompts. `PARSE_RESUME_PROMPT` is the exception: it embeds the schema via `{schema}` so it does *not* double-brace.
|
||||
|
||||
**Improve prompt selection:** `IMPROVE_RESUME_PROMPTS` = `{nudge, keywords, full}`; `IMPROVE_PROMPT_OPTIONS` is the UI list; `DEFAULT_IMPROVE_PROMPT_ID = "keywords"`. The active id comes from `config.json` `default_prompt_id` (validated against option ids) or the request's `prompt_id`. `CRITICAL_TRUTHFULNESS_RULES[id]` is injected into each improve prompt via `{critical_truthfulness_rules}`.
|
||||
|
||||
**Custom feature prompts (user-editable):** cover-letter & outreach prompts can be overridden in `config.json` (`cover_letter_prompt`, `outreach_message_prompt`). On save (`PUT /config/feature-prompts`) they are validated by `validate_prompt_placeholders()` to contain all of `REQUIRED_FEATURE_PROMPT_PLACEHOLDERS` = `{job_description}`, `{resume_data}`, `{output_language}`; missing → HTTP 422. Empty string = "use default". At runtime `cover_letter.py::_resolve_feature_prompt` picks custom-or-default and falls back to the built-in default (with a warning) if a custom prompt fails `.format()`.
|
||||
|
||||
**Language:** every generative prompt takes `{output_language}` (full name from `get_language_name(code)`), so all output is produced in the configured content language (`en`/`es`/`zh`/`ja`/`pt`).
|
||||
|
||||
---
|
||||
|
||||
## LLM Integration (`app/llm.py`)
|
||||
|
||||
- **Provider abstraction:** LiteLLM. Providers: `openai`, `openai_compatible` (llama.cpp/vLLM/LM Studio), `anthropic`, `openrouter`, `gemini`, `deepseek`, `groq`, `ollama`. `get_model_name()` maps provider→LiteLLM prefix; `_normalize_api_base()` fixes `/v1/v1` duplication per provider.
|
||||
- **Router:** a cached `litellm.Router` (`get_router`) rebuilt only when a config fingerprint changes. `num_retries=3` with a `RetryPolicy` (auth/bad-request/content-policy = 0 retries; timeout/500 = 2; rate-limit = 3). Cooldowns disabled (single deployment). **Transport retries live in the Router; do not re-retry them in callers.**
|
||||
- **`complete()` / `complete_json()`:** `complete_json` adds app-level *content-quality* retries (malformed JSON, truncation) with temperature escalation and a JSON-mode→prompt-only fallback. JSON is parsed by the brace-balancing `_extract_json`; `_appears_truncated` is `schema_type`-aware (`resume`/`enrichment`/`diff`/`keywords`).
|
||||
- **Capabilities via registry, not hardcoded:** `_supports_json_mode`, `_supports_temperature`, `get_safe_max_tokens` query `litellm.get_model_info` (with Ollama/local fallbacks). `litellm.drop_params = True` lets unsupported params (e.g. `reasoning_effort`) be dropped silently.
|
||||
- **Timeouts:** adaptive (`_calculate_timeout`): base 30s health / 120s completion / 180s JSON, scaled by token count and a provider factor (ollama 2x, openrouter 1.5x...).
|
||||
- **Reasoning models:** `<think>` tags stripped; `reasoning_content`/`thinking` used as content fallback. gpt-5 configs auto-migrate to `reasoning_effort="minimal"` once.
|
||||
- **Key resolution:** single source of truth is `resolve_api_key(stored, provider)`. `openai_compatible`/`ollama` deliberately **skip** the env-level `LLM_API_KEY` fallback so a paid key can't leak to a local server. Error text is scrubbed of key-like tokens (`_scrub_secrets`) before reaching clients.
|
||||
- API keys are passed directly to LiteLLM calls (never via `os.environ`) to avoid async races.
|
||||
|
||||
---
|
||||
|
||||
## Essential Commands
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
uv sync # install deps (creates .venv)
|
||||
uv run uvicorn app.main:app --reload --port 8000 # dev server on :8000
|
||||
uv run app # console script (app.main:main, uses HOST/PORT/RELOAD)
|
||||
uv run playwright install chromium # one-time, required for PDF endpoints
|
||||
```
|
||||
Config via `.env` (see `.env.example`). Interactive API docs at `/docs`.
|
||||
|
||||
---
|
||||
|
||||
## Non-Negotiable Backend Rules
|
||||
|
||||
1. **Type hints on every function** (params + return), incl. helpers.
|
||||
2. **Log details server-side, return generic client messages.** Pattern:
|
||||
```python
|
||||
except Exception as e:
|
||||
logger.error(f"Operation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail="Operation failed. Please try again.")
|
||||
```
|
||||
3. **`copy.deepcopy()` for any mutable default / before mutating shared/cached data** (e.g. `config_cache.load_config` returns a deep copy; the resume safety-net helpers deepcopy before editing).
|
||||
4. New endpoints mount under `/api/v1` via `app/routers/__init__.py`.
|
||||
5. Schema/prompt changes must be reflected in the relevant `docs/agent/` doc.
|
||||
|
||||
---
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
- **uv.lock is gitignored** (`.gitignore`), so dependency resolution isn't reproducible from VCS — rely on the exact pins in `pyproject.toml` / `requirements.txt`.
|
||||
- **litellm ↔ python-dotenv trap:** litellm `<1.84.0` hard-pinned `python-dotenv==1.0.1`, which used to fight other pins. Resolved at the current pins (`litellm==1.86.2`, `python-dotenv==1.2.2`); do **not** downgrade litellm below 1.84 without re-checking dotenv.
|
||||
- **Keys vs non-secret config:** API **keys** live ONLY in the encrypted `api_keys` SQLite table (per-provider, via `_PROVIDER_KEY_MAP`); `load_config_file()` injects the decrypted keys into the returned dict and `save_config_file()` strips them, so secrets never round-trip to `config.json`. Non-secret provider/model/base/features stay in `config.json`. `PUT /config/llm-api-key` no longer writes any key; keys go through `PUT /config/api-keys`. `migrate_legacy_keys()` folds any legacy plaintext keys into the encrypted store (idempotent, non-clobbering). After any write to `config.json`, call `invalidate_config_cache()`.
|
||||
- **Master resume invariant:** exactly one resume has `is_master=True`. Concurrent uploads use `create_resume_atomic_master` (an `asyncio.Lock`, not threading) and auto-promote if the current master is stuck `failed`/`processing`.
|
||||
- **Dates lose months:** LLMs drop month precision; `restore_dates_from_markdown` + `_restore_original_dates` re-insert them. Preserve this when editing the parse/improve flow.
|
||||
- **Single-worker assumption:** caches and locks assume one uvicorn worker / cooperative async. Don't add cross-worker shared mutable state without revisiting `config_cache` and the master lock.
|
||||
- **PDF needs the frontend running** (`FRONTEND_BASE_URL`, default `http://localhost:3000`) — Chromium renders `/print/*` pages. Browser is lazily initialized on first PDF request.
|
||||
- **Improve/confirm requires a prior preview** — it validates `preview_hash`; arbitrary payloads are rejected (400).
|
||||
|
||||
---
|
||||
|
||||
## Documentation by Task
|
||||
|
||||
| Topic | Doc |
|
||||
|-------|-----|
|
||||
| Project orientation | [`docs/agent/README.md`](../../docs/agent/README.md) |
|
||||
| Backend architecture / modules | [`backend-guide.md`](../../docs/agent/architecture/backend-guide.md) · [`backend-architecture.md`](../../docs/agent/architecture/backend-architecture.md) |
|
||||
| LLM / multi-provider | [`llm-integration.md`](../../docs/agent/llm-integration.md) |
|
||||
| Prompt pipeline (diff/retry design) | [`prompt-workflow-design.md`](../../docs/agent/architecture/prompt-workflow-design.md) |
|
||||
| API contracts | [`apis/front-end-apis.md`](../../docs/agent/apis/front-end-apis.md) · [`apis/api-flow-maps.md`](../../docs/agent/apis/api-flow-maps.md) · [`apis/backend-requirements.md`](../../docs/agent/apis/backend-requirements.md) |
|
||||
| Coding standards | [`coding-standards.md`](../../docs/agent/coding-standards.md) |
|
||||
| Scope / principles | [`scope-and-principles.md`](../../docs/agent/scope-and-principles.md) · [`workflow.md`](../../docs/agent/workflow.md) |
|
||||
| AI enrichment | [`features/enrichment.md`](../../docs/agent/features/enrichment.md) |
|
||||
| JD matching | [`features/jd-match.md`](../../docs/agent/features/jd-match.md) |
|
||||
| Custom sections | [`features/custom-sections.md`](../../docs/agent/features/custom-sections.md) |
|
||||
| i18n | [`features/i18n.md`](../../docs/agent/features/i18n.md) |
|
||||
| PDF / templates | [`design/pdf-template-guide.md`](../../docs/agent/design/pdf-template-guide.md) · [`design/template-system.md`](../../docs/agent/design/template-system.md) |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
**Tests are in scope** (deliberate testing initiative — see [`testing-strategy.md`](../../docs/agent/testing-strategy.md) for the full assessment + roadmap). Stack: pytest + pytest-asyncio + httpx + respx; config in `[tool.pytest.ini_options]`. Run `uv run pytest` (the LLM-judge evals are excluded by default via `addopts -m "not eval"`). Coverage: `uv run --with pytest-cov pytest --cov=app --cov-report=term-missing` (ephemeral plugin, no pyproject change).
|
||||
|
||||
Layout (`apps/backend/tests/`):
|
||||
|
||||
| Dir | What | Notes |
|
||||
|-----|------|-------|
|
||||
| `unit/` | pure functions | diffs, `llm` provider/key helpers, parser date-restore, real-SQLite CRUD |
|
||||
| `service/` | service layer, **LLM mocked** | improver diff flow, prompt construction |
|
||||
| `integration/` | endpoints via httpx `ASGITransport` | config/health/jobs/resume/upload, plus `test_llm_contract.py` (real `llm.py` over `respx`) and `test_pipeline_e2e.py` (upload→tailor→render, real routers + real temp DB) |
|
||||
| `evals/` | prompt quality | pure structural scorers (always run) + a gated LLM-judge (`@pytest.mark.eval`, uses the dev's own key; run with `uv run pytest -m eval`) |
|
||||
|
||||
Key fixtures/tools: `conftest.py::isolated_db` swaps the global `db` singleton for a disposable temp-file SQLite database across **all** router modules (for real-DB endpoint/e2e tests); `respx` mocks the HTTP transport so `llm.py`'s real routing runs against a fake Ollama / OpenAI server (gotcha: litellm 1.86 needs `disable_aiohttp_transport=True` for respx to intercept). Keep every test **anti-theater** — it must fail when its target breaks.
|
||||
|
||||
**Local push gate:** `.githooks/pre-push` runs this suite + a locale-parity check and blocks red pushes (`git config core.hooksPath .githooks`; see [`.githooks/README.md`](../../.githooks/README.md)). We avoid a GitHub Actions PR gate (high external-PR volume).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Without an explicit request: `.github/workflows/`, CI/CD, Docker behavior, and **removing/disabling existing tests** (adding/fixing tests is encouraged).
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Resume Matcher Backend - Lean & Local"""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Application configuration using pydantic-settings."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
# Path to config file for API key persistence
|
||||
CONFIG_FILE_PATH = Path(__file__).parent.parent / "data" / "config.json"
|
||||
ALLOWED_LOG_LEVELS = ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG")
|
||||
|
||||
|
||||
def _read_config_json() -> dict[str, Any]:
|
||||
"""Raw read of config.json (no key injection)."""
|
||||
if CONFIG_FILE_PATH.exists():
|
||||
try:
|
||||
return json.loads(CONFIG_FILE_PATH.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _write_config_json(config: dict[str, Any]) -> None:
|
||||
"""Raw write of config.json (no secret stripping)."""
|
||||
CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_FILE_PATH.write_text(json.dumps(config, indent=2))
|
||||
|
||||
|
||||
def load_config_file() -> dict[str, Any]:
|
||||
"""Load non-secret configuration, with decrypted API keys injected.
|
||||
|
||||
API keys live in the encrypted SQLite store, not config.json. They are
|
||||
injected here under ``api_keys`` so ``resolve_api_key(stored, provider)``
|
||||
keeps resolving per-provider keys everywhere ``stored`` is built from this
|
||||
function. ``save_config_file`` strips them again, so they never round-trip
|
||||
back to disk.
|
||||
"""
|
||||
config = _read_config_json()
|
||||
config["api_keys"] = get_api_keys_from_config()
|
||||
return config
|
||||
|
||||
|
||||
def save_config_file(config: dict[str, Any]) -> None:
|
||||
"""Save non-secret configuration to config.json.
|
||||
|
||||
Secrets (``api_keys`` map and the legacy single ``api_key``) are stripped
|
||||
before writing — they belong to the encrypted store only.
|
||||
"""
|
||||
config = dict(config)
|
||||
config.pop("api_keys", None)
|
||||
config.pop("api_key", None)
|
||||
_write_config_json(config)
|
||||
|
||||
|
||||
def get_api_keys_from_config() -> dict[str, str]:
|
||||
"""Get decrypted API keys from the encrypted SQLite store.
|
||||
|
||||
Returns:
|
||||
Dictionary with key-store provider names as keys and plaintext keys as
|
||||
values (entries that fail to decrypt are omitted).
|
||||
"""
|
||||
from app.crypto import decrypt
|
||||
from app.database import db
|
||||
|
||||
decrypted: dict[str, str] = {}
|
||||
for provider, ciphertext in db.get_api_key_ciphertexts().items():
|
||||
plaintext = decrypt(ciphertext)
|
||||
if plaintext:
|
||||
decrypted[provider] = plaintext
|
||||
return decrypted
|
||||
|
||||
|
||||
def save_api_keys_to_config(api_keys: dict[str, str]) -> None:
|
||||
"""Replace the encrypted key store with ``api_keys`` (encrypting each).
|
||||
|
||||
Replace-all semantics mirror the legacy ``config["api_keys"] = api_keys``;
|
||||
the config router reads-merges-saves the full map.
|
||||
"""
|
||||
from app.crypto import encrypt
|
||||
from app.database import db
|
||||
|
||||
# Encrypt everything first, then swap in a single transaction, so a partial
|
||||
# failure (encryption error or DB write) can never wipe previously stored
|
||||
# keys mid-replace.
|
||||
ciphertexts = {provider: encrypt(key) for provider, key in api_keys.items() if key}
|
||||
db.replace_api_keys(ciphertexts)
|
||||
|
||||
|
||||
def delete_api_key_from_config(provider: str) -> None:
|
||||
"""Delete a specific API key from the encrypted store."""
|
||||
from app.database import db
|
||||
|
||||
db.delete_api_key(provider)
|
||||
|
||||
|
||||
def clear_all_api_keys() -> None:
|
||||
"""Clear all API keys from the encrypted store and any legacy config slots."""
|
||||
from app.database import db
|
||||
|
||||
db.clear_api_keys()
|
||||
# Defensively clear any legacy plaintext remnants from config.json.
|
||||
config = _read_config_json()
|
||||
if "api_keys" in config or "api_key" in config:
|
||||
config.pop("api_keys", None)
|
||||
config.pop("api_key", None)
|
||||
_write_config_json(config)
|
||||
|
||||
|
||||
def migrate_legacy_keys() -> None:
|
||||
"""Fold legacy plaintext keys from config.json into the encrypted store.
|
||||
|
||||
Idempotent and non-clobbering: an existing config.json ``api_keys`` map and
|
||||
the legacy single ``api_key`` (mapped to its key-store provider via the
|
||||
active provider) are written to the encrypted store **only if that provider
|
||||
slot is empty**, then removed from config.json. This eliminates the
|
||||
legacy-shadow bug where ``resolve_api_key`` returned one shared key for
|
||||
every provider.
|
||||
"""
|
||||
config = _read_config_json()
|
||||
legacy_map = config.get("api_keys")
|
||||
legacy_single = config.get("api_key")
|
||||
if not legacy_map and not legacy_single:
|
||||
return
|
||||
|
||||
from app.crypto import encrypt
|
||||
from app.database import db
|
||||
|
||||
existing = set(db.get_api_key_ciphertexts().keys())
|
||||
|
||||
if isinstance(legacy_map, dict):
|
||||
for provider, key in legacy_map.items():
|
||||
if key and provider not in existing:
|
||||
db.set_api_key_ciphertext(provider, encrypt(key))
|
||||
existing.add(provider)
|
||||
|
||||
if legacy_single:
|
||||
# Map the active LLM provider to its key-store provider name.
|
||||
provider = config.get("provider") or settings.llm_provider
|
||||
key_provider = _LEGACY_PROVIDER_KEY_MAP.get(provider, provider)
|
||||
if key_provider not in existing:
|
||||
db.set_api_key_ciphertext(key_provider, encrypt(legacy_single))
|
||||
|
||||
# Strip the legacy slots from config.json now that they're in the store.
|
||||
config.pop("api_keys", None)
|
||||
config.pop("api_key", None)
|
||||
_write_config_json(config)
|
||||
|
||||
|
||||
# Mirror of llm._PROVIDER_KEY_MAP, duplicated to avoid importing llm.py (which
|
||||
# pulls in litellm) at config import time.
|
||||
_LEGACY_PROVIDER_KEY_MAP: dict[str, str] = {
|
||||
"openai": "openai",
|
||||
"openai_compatible": "openai_compatible",
|
||||
"anthropic": "anthropic",
|
||||
"gemini": "google",
|
||||
"openrouter": "openrouter",
|
||||
"deepseek": "deepseek",
|
||||
"groq": "groq",
|
||||
"ollama": "ollama",
|
||||
}
|
||||
|
||||
|
||||
def _get_llm_api_key_with_fallback() -> str:
|
||||
"""Get LLM API key with fallback to config file.
|
||||
|
||||
Priority: Environment variable > config.json > empty string
|
||||
"""
|
||||
import os
|
||||
|
||||
# First check environment variable
|
||||
env_key = os.environ.get("LLM_API_KEY", "")
|
||||
if env_key:
|
||||
return env_key
|
||||
|
||||
# Fallback to config file based on provider
|
||||
config_keys = get_api_keys_from_config()
|
||||
provider = os.environ.get("LLM_PROVIDER", "openai")
|
||||
|
||||
# Map provider to config key
|
||||
provider_map = {
|
||||
"openai": "openai",
|
||||
"anthropic": "anthropic",
|
||||
"gemini": "google",
|
||||
"openrouter": "openrouter",
|
||||
"deepseek": "deepseek",
|
||||
"groq": "groq",
|
||||
"ollama": "ollama",
|
||||
}
|
||||
|
||||
config_provider = provider_map.get(provider, provider)
|
||||
return config_keys.get(config_provider, "")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# LLM Configuration
|
||||
llm_provider: Literal[
|
||||
"openai",
|
||||
"openai_compatible",
|
||||
"anthropic",
|
||||
"openrouter",
|
||||
"gemini",
|
||||
"deepseek",
|
||||
"groq",
|
||||
"ollama",
|
||||
] = "openai"
|
||||
llm_model: str = "gpt-5-nano-2025-08-07"
|
||||
llm_api_key: str = ""
|
||||
llm_api_base: str | None = None # For Ollama or custom endpoints
|
||||
log_llm: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = "WARNING"
|
||||
|
||||
@field_validator("llm_provider", mode="before")
|
||||
@classmethod
|
||||
def set_default_provider(cls, v: Any) -> str:
|
||||
"""Handle empty string provider by defaulting to openai."""
|
||||
if not v or (isinstance(v, str) and not v.strip()):
|
||||
return "openai"
|
||||
return v
|
||||
|
||||
@field_validator("log_llm", mode="before")
|
||||
@classmethod
|
||||
def normalize_log_llm_level(cls, v: Any) -> str:
|
||||
"""Normalize LiteLLM log level from environment values."""
|
||||
value = "WARNING" if not v else str(v).strip().upper()
|
||||
if value not in ALLOWED_LOG_LEVELS:
|
||||
raise ValueError(f"Invalid LOG_LLM: {value}. Allowed: {ALLOWED_LOG_LEVELS}")
|
||||
return value
|
||||
|
||||
# Server Configuration
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
reload: bool = False
|
||||
log_level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = "INFO"
|
||||
frontend_base_url: str = "http://localhost:3000"
|
||||
|
||||
# Hard timeout (seconds) for a single resume tailoring/improve request — the
|
||||
# backend wraps the improve flow in asyncio.wait_for(timeout=this). It MUST be
|
||||
# kept in sync with the two frontend layers (Next.js `proxyTimeout` and the
|
||||
# client AbortController, both driven by NEXT_PUBLIC_REQUEST_TIMEOUT_MS):
|
||||
# whichever layer is shortest aborts first, so raising only one silently fails
|
||||
# (this is why issue #776's backend-only workaround didn't work). Local LLMs
|
||||
# (Ollama, llama.cpp, …) often need longer than the 240s default; bounded to
|
||||
# [30, 1800]s so a stuck request can't hold a worker indefinitely.
|
||||
request_timeout_seconds: int = 240
|
||||
|
||||
@field_validator("request_timeout_seconds", mode="before")
|
||||
@classmethod
|
||||
def clamp_request_timeout(cls, v: Any) -> int:
|
||||
"""Clamp to [30, 1800] seconds; fall back to 240 on blank/invalid input."""
|
||||
if v is None or (isinstance(v, str) and not v.strip()):
|
||||
return 240
|
||||
try:
|
||||
seconds = int(float(str(v).strip()))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError guards against inf (int(float("inf"))); ValueError
|
||||
# against nan/garbage. A bad env value must never crash startup.
|
||||
return 240
|
||||
return max(30, min(1800, seconds))
|
||||
|
||||
# Reasoning effort for models that support it (OpenAI gpt-5 family,
|
||||
# Anthropic Claude 3.7+, DeepSeek R1, etc.). None means "do not send the
|
||||
# param" — the default for maximum compatibility. LiteLLM drops this
|
||||
# parameter for providers that don't support it (via drop_params=True).
|
||||
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
|
||||
|
||||
@field_validator("reasoning_effort", mode="before")
|
||||
@classmethod
|
||||
def normalize_reasoning_effort(cls, v: Any) -> Any:
|
||||
"""Treat empty string (common when env var is blank) as None."""
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator("log_level", mode="before")
|
||||
@classmethod
|
||||
def normalize_log_level(cls, v: Any) -> str:
|
||||
"""Normalize application log level from environment values."""
|
||||
value = "INFO" if not v else str(v).strip().upper()
|
||||
if value not in ALLOWED_LOG_LEVELS:
|
||||
raise ValueError(f"Invalid LOG_LEVEL: {value}. Allowed: {ALLOWED_LOG_LEVELS}")
|
||||
return value
|
||||
|
||||
# CORS Configuration
|
||||
cors_origins: list[str] = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
|
||||
@property
|
||||
def effective_cors_origins(self) -> list[str]:
|
||||
"""CORS origins including frontend_base_url for production deployments."""
|
||||
origins = list(self.cors_origins)
|
||||
url = self.frontend_base_url.strip().rstrip("/")
|
||||
if url and url not in origins:
|
||||
origins.append(url)
|
||||
return origins
|
||||
|
||||
# Paths
|
||||
data_dir: Path = Path(__file__).parent.parent / "data"
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
"""Path to the legacy TinyDB database file (migration source only)."""
|
||||
return self.data_dir / "database.json"
|
||||
|
||||
@property
|
||||
def sqlite_path(self) -> Path:
|
||||
"""Path to the SQLite database file (primary data store)."""
|
||||
return self.data_dir / "resume_matcher.db"
|
||||
|
||||
@property
|
||||
def config_path(self) -> Path:
|
||||
"""Path to config storage file."""
|
||||
return self.data_dir / "config.json"
|
||||
|
||||
def get_effective_api_key(self) -> str:
|
||||
"""Get the effective API key with config file fallback.
|
||||
|
||||
Priority: Environment/settings value > config.json > empty string
|
||||
"""
|
||||
if self.llm_api_key:
|
||||
return self.llm_api_key
|
||||
return _get_llm_api_key_with_fallback()
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Shared config file cache used by multiple routers.
|
||||
|
||||
This module owns the cached read of ``config.json`` so that routers
|
||||
(resumes, enrichment, config) can share it without importing each other.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache state — accessed without a lock because:
|
||||
# 1. The app runs single-worker uvicorn (one thread, cooperative async).
|
||||
# 2. The GIL protects dict/float assignment; worst-case TOCTOU is a
|
||||
# redundant disk read (benign, same file, same result).
|
||||
# 3. A threading.Lock would block the event loop; an asyncio.Lock would
|
||||
# require making load_config async and changing every caller.
|
||||
_config_cache: dict[str, Any] = {}
|
||||
_config_cache_time: float = 0.0
|
||||
_CONFIG_CACHE_TTL: float = 300.0 # 5 minutes
|
||||
|
||||
|
||||
def invalidate_config_cache() -> None:
|
||||
"""Invalidate the config cache so the next read fetches from disk.
|
||||
|
||||
Call this after any write to config.json.
|
||||
"""
|
||||
global _config_cache, _config_cache_time
|
||||
_config_cache = {}
|
||||
_config_cache_time = 0.0
|
||||
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
"""Load configuration from config file with 5-minute TTL cache.
|
||||
|
||||
Returns a deep copy so callers cannot corrupt the cached data.
|
||||
"""
|
||||
global _config_cache, _config_cache_time
|
||||
now = time.monotonic()
|
||||
if _config_cache and (now - _config_cache_time) < _CONFIG_CACHE_TTL:
|
||||
return copy.deepcopy(_config_cache)
|
||||
|
||||
config_path = settings.config_path
|
||||
if not config_path.exists():
|
||||
_config_cache = {}
|
||||
_config_cache_time = now
|
||||
return {}
|
||||
try:
|
||||
_config_cache = json.loads(config_path.read_text())
|
||||
_config_cache_time = now
|
||||
return copy.deepcopy(_config_cache)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error("Failed to load config: %s", e)
|
||||
_config_cache = {}
|
||||
_config_cache_time = now
|
||||
return {}
|
||||
|
||||
|
||||
def get_content_language() -> str:
|
||||
"""Get configured content language from cached config."""
|
||||
config = load_config()
|
||||
return config.get("content_language", config.get("language", "en"))
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Fernet encryption for API keys at rest.
|
||||
|
||||
The symmetric secret lives at ``data/.secret_key`` (auto-generated, ``chmod
|
||||
600``, gitignored). It is loaded once and used to encrypt/decrypt provider keys
|
||||
so plaintext exists in memory only at call time.
|
||||
|
||||
Resilience: a missing secret is generated on demand; a key that fails to
|
||||
decrypt (e.g. the secret was rotated/lost) is treated as empty rather than
|
||||
crashing — the user is prompted to re-enter, and stored ciphertext is never
|
||||
recoverable without the original secret.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
_loaded_from: Path | None = None
|
||||
|
||||
|
||||
def _secret_path() -> Path:
|
||||
return settings.data_dir / ".secret_key"
|
||||
|
||||
|
||||
def _write_secret(path: Path, key: bytes, *, exclusive: bool = False) -> None:
|
||||
"""Atomically write the secret with 0600 perms.
|
||||
|
||||
The key is written **in full** to a temp file in the same directory (mode
|
||||
0600 via ``mkstemp``), fsync'd, then moved into place atomically — so a
|
||||
concurrent reader can never observe a partial key. With ``exclusive=True``
|
||||
the move is an atomic hard-link that raises ``FileExistsError`` if the
|
||||
target already exists (first-run generation must not clobber a secret that
|
||||
may already have encrypted data); otherwise it is an atomic replace (used to
|
||||
overwrite a corrupt/unreadable secret).
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=".secret_key.tmp.")
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
remaining = key
|
||||
while remaining:
|
||||
written = os.write(fd, remaining)
|
||||
if written == 0:
|
||||
# Guard against a 0-byte write spinning forever.
|
||||
raise OSError("os.write wrote 0 bytes to the secret file")
|
||||
remaining = remaining[written:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
if exclusive:
|
||||
# Atomic + exclusive: fails if the target exists, and the linked
|
||||
# file is already complete (no partial-read window).
|
||||
os.link(tmp, path)
|
||||
else:
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _load_fernet() -> Fernet:
|
||||
"""Load (or generate) the Fernet instance, cached per secret path."""
|
||||
global _fernet, _loaded_from
|
||||
path = _secret_path()
|
||||
if _fernet is not None and _loaded_from == path:
|
||||
return _fernet
|
||||
|
||||
if path.exists():
|
||||
key = path.read_bytes().strip()
|
||||
# Re-assert restrictive perms in case the file pre-existed (or was
|
||||
# copied) with broader permissions before this hardening (best-effort).
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError: # pragma: no cover - platform dependent (e.g. Windows)
|
||||
pass
|
||||
else:
|
||||
key = Fernet.generate_key()
|
||||
try:
|
||||
_write_secret(path, key, exclusive=True)
|
||||
logger.info("Generated new encryption secret at %s", path)
|
||||
except FileExistsError:
|
||||
# Another caller generated the secret first — use theirs so we
|
||||
# never overwrite a key that may already have encrypted data.
|
||||
key = path.read_bytes().strip()
|
||||
|
||||
try:
|
||||
_fernet = Fernet(key)
|
||||
except (ValueError, TypeError):
|
||||
# A corrupt/invalid secret would otherwise crash every encrypt/decrypt
|
||||
# call. Regenerate a fresh secret (previously stored ciphertext is
|
||||
# already unrecoverable) so key save/read flows keep working.
|
||||
logger.warning("Invalid encryption secret at %s; regenerating.", path)
|
||||
key = Fernet.generate_key()
|
||||
_write_secret(path, key)
|
||||
_fernet = Fernet(key)
|
||||
_loaded_from = path
|
||||
return _fernet
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Drop the cached Fernet (used by tests that point data_dir elsewhere)."""
|
||||
global _fernet, _loaded_from
|
||||
_fernet = None
|
||||
_loaded_from = None
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Encrypt a plaintext secret; returns ciphertext as a string."""
|
||||
if not plaintext:
|
||||
return ""
|
||||
return _load_fernet().encrypt(plaintext.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt(ciphertext: str) -> str:
|
||||
"""Decrypt ciphertext; returns "" if it can't be decrypted (lost secret)."""
|
||||
if not ciphertext:
|
||||
return ""
|
||||
try:
|
||||
return _load_fernet().decrypt(ciphertext.encode("utf-8")).decode("utf-8")
|
||||
except (InvalidToken, ValueError) as e:
|
||||
logger.warning("Failed to decrypt a stored API key (secret rotated/lost?): %s", e)
|
||||
return ""
|
||||
@@ -0,0 +1,777 @@
|
||||
"""SQLAlchemy (SQLite) data layer for Resume Matcher.
|
||||
|
||||
This is a behavior-preserving replacement for the original TinyDB wrapper. The
|
||||
``Database`` facade keeps the same method names/signatures and returns **plain
|
||||
dicts** (never ORM rows), so the ~50 call sites only needed ``await`` added.
|
||||
|
||||
Two engines back one SQLite file:
|
||||
- an **async** engine (``aiosqlite``) for the document tables and applications;
|
||||
- a **sync** engine for the encrypted ``api_keys`` table, which is read on the
|
||||
synchronous LLM hot path (``get_llm_config`` → ``resolve_api_key``).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Columns that are first-class on the jobs table; everything else the pipeline
|
||||
# attaches dynamically is stored in ``metadata_json`` (see Job model).
|
||||
_JOB_CORE_FIELDS = frozenset({"job_id", "content", "resume_id", "created_at"})
|
||||
|
||||
# Application status columns (stable keys, decoupled from i18n labels).
|
||||
APPLICATION_STATUSES: tuple[str, ...] = (
|
||||
"saved",
|
||||
"applied",
|
||||
"no_response",
|
||||
"response",
|
||||
"interview",
|
||||
"accepted",
|
||||
"rejected",
|
||||
)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
"""Current UTC time as an ISO-8601 string (TinyDB-era format)."""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Database:
|
||||
"""Async SQLAlchemy facade for resume matcher data."""
|
||||
|
||||
# Serializes concurrent master-resume promotion. Stays the *primary*
|
||||
# mechanism for the single-master invariant (the partial unique index is a
|
||||
# storage-level backstop).
|
||||
_master_resume_lock = asyncio.Lock()
|
||||
|
||||
def __init__(self, db_path: Path | None = None):
|
||||
self.db_path = db_path or settings.sqlite_path
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._async_engine = None
|
||||
self._async_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
self._sync_engine = None
|
||||
self._sync_session_factory: sessionmaker[Session] | None = None
|
||||
self._initialized = False
|
||||
|
||||
# -- engine / session plumbing ------------------------------------------
|
||||
|
||||
def _ensure_initialized(self) -> None:
|
||||
"""Create engines and tables once (idempotent).
|
||||
|
||||
Tables are created via the **sync** engine so both the sync (api_keys)
|
||||
and async (docs) paths see them immediately, without needing an event
|
||||
loop. Both engines point at the same file.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
self._sync_engine = make_sync_engine(self.db_path)
|
||||
self._sync_session_factory = sessionmaker(self._sync_engine, expire_on_commit=False)
|
||||
init_models_sync(self._sync_engine)
|
||||
self._async_engine = make_async_engine(self.db_path)
|
||||
self._async_session_factory = async_sessionmaker(
|
||||
self._async_engine, expire_on_commit=False
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
@property
|
||||
def _session(self) -> async_sessionmaker[AsyncSession]:
|
||||
self._ensure_initialized()
|
||||
assert self._async_session_factory is not None
|
||||
return self._async_session_factory
|
||||
|
||||
@property
|
||||
def _sync(self) -> sessionmaker[Session]:
|
||||
self._ensure_initialized()
|
||||
assert self._sync_session_factory is not None
|
||||
return self._sync_session_factory
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Dispose engines and release file handles."""
|
||||
if self._async_engine is not None:
|
||||
await self._async_engine.dispose()
|
||||
self._async_engine = None
|
||||
self._async_session_factory = None
|
||||
if self._sync_engine is not None:
|
||||
self._sync_engine.dispose()
|
||||
self._sync_engine = None
|
||||
self._sync_session_factory = None
|
||||
self._initialized = False
|
||||
|
||||
# -- row -> dict converters ---------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _resume_to_dict(row: Resume) -> dict[str, Any]:
|
||||
doc: dict[str, Any] = {
|
||||
"resume_id": row.resume_id,
|
||||
"content": row.content,
|
||||
"content_type": row.content_type,
|
||||
"filename": row.filename,
|
||||
"is_master": row.is_master,
|
||||
"parent_id": row.parent_id,
|
||||
"processed_data": row.processed_data,
|
||||
"processing_status": row.processing_status,
|
||||
"cover_letter": row.cover_letter,
|
||||
"outreach_message": row.outreach_message,
|
||||
"interview_prep": row.interview_prep,
|
||||
"title": row.title,
|
||||
"created_at": row.created_at,
|
||||
"updated_at": row.updated_at,
|
||||
}
|
||||
# Preserve TinyDB absence semantics: omit the key entirely when None.
|
||||
if row.original_markdown is not None:
|
||||
doc["original_markdown"] = row.original_markdown
|
||||
return doc
|
||||
|
||||
@staticmethod
|
||||
def _job_to_dict(row: Job) -> dict[str, Any]:
|
||||
doc: dict[str, Any] = {
|
||||
"job_id": row.job_id,
|
||||
"content": row.content,
|
||||
"resume_id": row.resume_id,
|
||||
"created_at": row.created_at,
|
||||
}
|
||||
meta = row.metadata_json or {}
|
||||
if isinstance(meta, dict):
|
||||
doc.update(meta) # flatten dynamic fields to top level
|
||||
return doc
|
||||
|
||||
@staticmethod
|
||||
def _improvement_to_dict(row: Improvement) -> dict[str, Any]:
|
||||
return {
|
||||
"request_id": row.request_id,
|
||||
"original_resume_id": row.original_resume_id,
|
||||
"tailored_resume_id": row.tailored_resume_id,
|
||||
"job_id": row.job_id,
|
||||
"improvements": row.improvements,
|
||||
"created_at": row.created_at,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _application_to_dict(row: Application) -> dict[str, Any]:
|
||||
return {
|
||||
"application_id": row.application_id,
|
||||
"job_id": row.job_id,
|
||||
"resume_id": row.resume_id,
|
||||
"master_resume_id": row.master_resume_id,
|
||||
"status": row.status,
|
||||
"company": row.company,
|
||||
"role": row.role,
|
||||
"applied_at": row.applied_at,
|
||||
"notes": row.notes,
|
||||
"position": row.position,
|
||||
"created_at": row.created_at,
|
||||
"updated_at": row.updated_at,
|
||||
}
|
||||
|
||||
# -- Resume operations --------------------------------------------------
|
||||
|
||||
async def create_resume(
|
||||
self,
|
||||
content: str,
|
||||
content_type: str = "md",
|
||||
filename: str | None = None,
|
||||
is_master: bool = False,
|
||||
parent_id: str | None = None,
|
||||
processed_data: dict[str, Any] | None = None,
|
||||
processing_status: str = "pending",
|
||||
cover_letter: str | None = None,
|
||||
outreach_message: str | None = None,
|
||||
title: str | None = None,
|
||||
original_markdown: str | None = None,
|
||||
interview_prep: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new resume entry.
|
||||
|
||||
processing_status: "pending", "processing", "ready", "failed"
|
||||
"""
|
||||
resume_id = str(uuid4())
|
||||
now = _now()
|
||||
async with self._session() as session:
|
||||
session.add(
|
||||
Resume(
|
||||
resume_id=resume_id,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
is_master=is_master,
|
||||
parent_id=parent_id,
|
||||
processed_data=processed_data,
|
||||
processing_status=processing_status,
|
||||
cover_letter=cover_letter,
|
||||
outreach_message=outreach_message,
|
||||
interview_prep=interview_prep,
|
||||
title=title,
|
||||
original_markdown=original_markdown,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
doc: dict[str, Any] = {
|
||||
"resume_id": resume_id,
|
||||
"content": content,
|
||||
"content_type": content_type,
|
||||
"filename": filename,
|
||||
"is_master": is_master,
|
||||
"parent_id": parent_id,
|
||||
"processed_data": processed_data,
|
||||
"processing_status": processing_status,
|
||||
"cover_letter": cover_letter,
|
||||
"outreach_message": outreach_message,
|
||||
"interview_prep": interview_prep,
|
||||
"title": title,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if original_markdown is not None:
|
||||
doc["original_markdown"] = original_markdown
|
||||
return doc
|
||||
|
||||
async def create_resume_atomic_master(
|
||||
self,
|
||||
content: str,
|
||||
content_type: str = "md",
|
||||
filename: str | None = None,
|
||||
processed_data: dict[str, Any] | None = None,
|
||||
processing_status: str = "pending",
|
||||
cover_letter: str | None = None,
|
||||
outreach_message: str | None = None,
|
||||
original_markdown: str | None = None,
|
||||
title: str | None = None,
|
||||
interview_prep: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new resume with atomic master assignment.
|
||||
|
||||
Uses an asyncio.Lock to prevent race conditions when multiple uploads
|
||||
happen concurrently and both try to become master.
|
||||
"""
|
||||
async with self._master_resume_lock:
|
||||
current_master = await self.get_master_resume()
|
||||
is_master = current_master is None
|
||||
|
||||
# Recovery: if the current master is stuck failed/processing, demote
|
||||
# it so this upload can become the new master.
|
||||
if current_master and current_master.get("processing_status") in (
|
||||
"failed",
|
||||
"processing",
|
||||
):
|
||||
async with self._session() as session:
|
||||
row = await session.get(Resume, current_master["resume_id"])
|
||||
if row is not None:
|
||||
row.is_master = False
|
||||
await session.commit()
|
||||
is_master = True
|
||||
|
||||
return await self.create_resume(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
is_master=is_master,
|
||||
processed_data=processed_data,
|
||||
processing_status=processing_status,
|
||||
cover_letter=cover_letter,
|
||||
outreach_message=outreach_message,
|
||||
interview_prep=interview_prep,
|
||||
original_markdown=original_markdown,
|
||||
title=title,
|
||||
)
|
||||
|
||||
async def get_resume(self, resume_id: str) -> dict[str, Any] | None:
|
||||
"""Get resume by ID."""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Resume, resume_id)
|
||||
return self._resume_to_dict(row) if row else None
|
||||
|
||||
async def get_master_resume(self) -> dict[str, Any] | None:
|
||||
"""Get the master resume if exists."""
|
||||
async with self._session() as session:
|
||||
result = await session.execute(
|
||||
select(Resume).where(Resume.is_master.is_(True))
|
||||
)
|
||||
row = result.scalars().first()
|
||||
return self._resume_to_dict(row) if row else None
|
||||
|
||||
async def update_resume(self, resume_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update resume by ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If resume not found.
|
||||
"""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Resume, resume_id)
|
||||
if row is None:
|
||||
raise ValueError(f"Resume not found: {resume_id}")
|
||||
for key, value in updates.items():
|
||||
if hasattr(row, key):
|
||||
setattr(row, key, value)
|
||||
else:
|
||||
logger.warning("Ignoring unknown resume field on update: %s", key)
|
||||
row.updated_at = _now()
|
||||
await session.commit()
|
||||
return self._resume_to_dict(row)
|
||||
|
||||
async def delete_resume(self, resume_id: str) -> bool:
|
||||
"""Delete resume by ID."""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Resume, resume_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def list_resumes(self) -> list[dict[str, Any]]:
|
||||
"""List all resumes."""
|
||||
async with self._session() as session:
|
||||
result = await session.execute(select(Resume).order_by(Resume.created_at))
|
||||
return [self._resume_to_dict(row) for row in result.scalars().all()]
|
||||
|
||||
async def set_master_resume(self, resume_id: str) -> bool:
|
||||
"""Set a resume as the master, unsetting any existing master.
|
||||
|
||||
Returns False if the resume doesn't exist. Demote-then-promote happens
|
||||
in a single transaction so the partial unique index is never violated.
|
||||
"""
|
||||
async with self._session() as session:
|
||||
target = await session.get(Resume, resume_id)
|
||||
if target is None:
|
||||
logger.warning("Cannot set master: resume %s not found", resume_id)
|
||||
return False
|
||||
|
||||
current = await session.execute(
|
||||
select(Resume).where(Resume.is_master.is_(True))
|
||||
)
|
||||
for row in current.scalars().all():
|
||||
if row.resume_id != resume_id:
|
||||
row.is_master = False
|
||||
# Flush the demotions before promoting to satisfy the unique index.
|
||||
await session.flush()
|
||||
target.is_master = True
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
# -- Job operations -----------------------------------------------------
|
||||
|
||||
async def create_job(self, content: str, resume_id: str | None = None) -> dict[str, Any]:
|
||||
"""Create a new job description entry."""
|
||||
job_id = str(uuid4())
|
||||
now = _now()
|
||||
async with self._session() as session:
|
||||
session.add(
|
||||
Job(job_id=job_id, content=content, resume_id=resume_id, created_at=now, metadata_json={})
|
||||
)
|
||||
await session.commit()
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"content": content,
|
||||
"resume_id": resume_id,
|
||||
"created_at": now,
|
||||
}
|
||||
|
||||
async def get_job(self, job_id: str) -> dict[str, Any] | None:
|
||||
"""Get job by ID (dynamic fields flattened to top level)."""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Job, job_id)
|
||||
return self._job_to_dict(row) if row else None
|
||||
|
||||
async def update_job(
|
||||
self, job_id: str, updates: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a job by ID.
|
||||
|
||||
Core columns are set directly; every other key is merged into
|
||||
``metadata_json`` so dynamic pipeline fields (``preview_hash``,
|
||||
``job_keywords``, ``company``/``role``, …) round-trip through
|
||||
``get_job`` as top-level keys.
|
||||
"""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Job, job_id)
|
||||
if row is None:
|
||||
return None
|
||||
meta = dict(row.metadata_json or {})
|
||||
for key, value in updates.items():
|
||||
if key in _JOB_CORE_FIELDS:
|
||||
setattr(row, key, value)
|
||||
else:
|
||||
meta[key] = value
|
||||
# Reassign so SQLAlchemy detects the JSON mutation.
|
||||
row.metadata_json = meta
|
||||
await session.commit()
|
||||
return self._job_to_dict(row)
|
||||
|
||||
async def delete_job(self, job_id: str) -> bool:
|
||||
"""Delete a job by ID (used to clean up an orphaned manual-add job)."""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Job, job_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
# -- Improvement operations ---------------------------------------------
|
||||
|
||||
async def create_improvement(
|
||||
self,
|
||||
original_resume_id: str,
|
||||
tailored_resume_id: str,
|
||||
job_id: str,
|
||||
improvements: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Create an improvement result entry."""
|
||||
request_id = str(uuid4())
|
||||
now = _now()
|
||||
async with self._session() as session:
|
||||
session.add(
|
||||
Improvement(
|
||||
request_id=request_id,
|
||||
original_resume_id=original_resume_id,
|
||||
tailored_resume_id=tailored_resume_id,
|
||||
job_id=job_id,
|
||||
improvements=improvements,
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"original_resume_id": original_resume_id,
|
||||
"tailored_resume_id": tailored_resume_id,
|
||||
"job_id": job_id,
|
||||
"improvements": improvements,
|
||||
"created_at": now,
|
||||
}
|
||||
|
||||
async def get_improvement_by_tailored_resume(
|
||||
self, tailored_resume_id: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get improvement record by tailored resume ID."""
|
||||
async with self._session() as session:
|
||||
result = await session.execute(
|
||||
select(Improvement).where(
|
||||
Improvement.tailored_resume_id == tailored_resume_id
|
||||
)
|
||||
)
|
||||
row = result.scalars().first()
|
||||
return self._improvement_to_dict(row) if row else None
|
||||
|
||||
# -- Application (tracker) operations -----------------------------------
|
||||
|
||||
async def _next_position(self, session: AsyncSession, status: str) -> int:
|
||||
result = await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Application)
|
||||
.where(Application.status == status)
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
async def _renumber(self, session: AsyncSession, status: str) -> None:
|
||||
"""Renumber a column's positions to a contiguous 0..n-1 sequence."""
|
||||
result = await session.execute(
|
||||
select(Application)
|
||||
.where(Application.status == status)
|
||||
.order_by(Application.position, Application.created_at)
|
||||
)
|
||||
for index, row in enumerate(result.scalars().all()):
|
||||
if row.position != index:
|
||||
row.position = index
|
||||
|
||||
async def create_application(
|
||||
self,
|
||||
job_id: str,
|
||||
resume_id: str,
|
||||
master_resume_id: str | None = None,
|
||||
status: str = "applied",
|
||||
company: str | None = None,
|
||||
role: str | None = None,
|
||||
applied_at: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a tracker card, deduped on (job_id, resume_id).
|
||||
|
||||
If a card for the same job+resume already exists it is returned as-is
|
||||
(survives double-submit / retried confirms).
|
||||
"""
|
||||
async with self._session() as session:
|
||||
existing = await session.execute(
|
||||
select(Application).where(
|
||||
Application.job_id == job_id, Application.resume_id == resume_id
|
||||
)
|
||||
)
|
||||
found = existing.scalars().first()
|
||||
if found is not None:
|
||||
return self._application_to_dict(found)
|
||||
|
||||
now = _now()
|
||||
if applied_at is None and status != "saved":
|
||||
applied_at = now
|
||||
position = await self._next_position(session, status)
|
||||
row = Application(
|
||||
application_id=str(uuid4()),
|
||||
job_id=job_id,
|
||||
resume_id=resume_id,
|
||||
master_resume_id=master_resume_id,
|
||||
status=status,
|
||||
company=company,
|
||||
role=role,
|
||||
applied_at=applied_at,
|
||||
notes=notes,
|
||||
position=position,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
# A concurrent create won the (job_id, resume_id) unique
|
||||
# constraint — return the existing card instead of duplicating.
|
||||
await session.rollback()
|
||||
dup = await session.execute(
|
||||
select(Application).where(
|
||||
Application.job_id == job_id,
|
||||
Application.resume_id == resume_id,
|
||||
)
|
||||
)
|
||||
found = dup.scalars().first()
|
||||
if found is not None:
|
||||
logger.debug(
|
||||
"Deduped concurrent application create for job=%s resume=%s",
|
||||
job_id,
|
||||
resume_id,
|
||||
)
|
||||
return self._application_to_dict(found)
|
||||
raise
|
||||
return self._application_to_dict(row)
|
||||
|
||||
async def list_applications(self, status: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List applications ordered by (status, position)."""
|
||||
async with self._session() as session:
|
||||
stmt = select(Application)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Application.status == status)
|
||||
stmt = stmt.order_by(Application.status, Application.position)
|
||||
result = await session.execute(stmt)
|
||||
return [self._application_to_dict(row) for row in result.scalars().all()]
|
||||
|
||||
async def get_application(self, application_id: str) -> dict[str, Any] | None:
|
||||
"""Get an application by ID."""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Application, application_id)
|
||||
return self._application_to_dict(row) if row else None
|
||||
|
||||
async def update_application(
|
||||
self, application_id: str, updates: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update an application; renumber columns when status/position change.
|
||||
|
||||
``position`` is interpreted as the desired index within the (possibly
|
||||
new) ``status`` column; siblings are renumbered server-side so the
|
||||
column stays a contiguous 0..n-1 sequence.
|
||||
"""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Application, application_id)
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
old_status = row.status
|
||||
new_status = updates.get("status", old_status)
|
||||
target_position = updates.get("position", None)
|
||||
|
||||
for key in ("company", "role", "applied_at", "notes"):
|
||||
if key in updates:
|
||||
setattr(row, key, updates[key])
|
||||
|
||||
moved = "status" in updates or "position" in updates
|
||||
if moved:
|
||||
row.status = new_status
|
||||
# Park it out of the way, renumber both columns, then reinsert.
|
||||
row.position = 10_000_000
|
||||
await session.flush()
|
||||
if old_status != new_status:
|
||||
await self._renumber(session, old_status)
|
||||
# Renumber the target column excluding this row, then splice in.
|
||||
siblings = await session.execute(
|
||||
select(Application)
|
||||
.where(
|
||||
Application.status == new_status,
|
||||
Application.application_id != application_id,
|
||||
)
|
||||
.order_by(Application.position, Application.created_at)
|
||||
)
|
||||
ordered = list(siblings.scalars().all())
|
||||
if target_position is None or target_position > len(ordered):
|
||||
target_position = len(ordered)
|
||||
if target_position < 0:
|
||||
target_position = 0
|
||||
ordered.insert(target_position, row)
|
||||
for index, item in enumerate(ordered):
|
||||
item.position = index
|
||||
|
||||
row.updated_at = _now()
|
||||
await session.commit()
|
||||
return self._application_to_dict(row)
|
||||
|
||||
async def bulk_update_applications(
|
||||
self, application_ids: list[str], status: str
|
||||
) -> int:
|
||||
"""Move many applications to the end of ``status``. Returns count moved."""
|
||||
moved = 0
|
||||
async with self._session() as session:
|
||||
affected_old: set[str] = set()
|
||||
for application_id in application_ids:
|
||||
row = await session.get(Application, application_id)
|
||||
if row is None:
|
||||
continue
|
||||
affected_old.add(row.status)
|
||||
row.status = status
|
||||
row.position = 20_000_000 + moved # provisional, renumbered below
|
||||
row.updated_at = _now()
|
||||
moved += 1
|
||||
await session.flush()
|
||||
for old_status in affected_old - {status}:
|
||||
await self._renumber(session, old_status)
|
||||
await self._renumber(session, status)
|
||||
await session.commit()
|
||||
return moved
|
||||
|
||||
async def delete_application(self, application_id: str) -> bool:
|
||||
"""Delete an application; renumber its column."""
|
||||
async with self._session() as session:
|
||||
row = await session.get(Application, application_id)
|
||||
if row is None:
|
||||
return False
|
||||
status = row.status
|
||||
await session.delete(row)
|
||||
await session.flush()
|
||||
await self._renumber(session, status)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def bulk_delete_applications(self, application_ids: list[str]) -> int:
|
||||
"""Delete many applications; renumber affected columns. Returns count."""
|
||||
deleted = 0
|
||||
async with self._session() as session:
|
||||
affected: set[str] = set()
|
||||
for application_id in application_ids:
|
||||
row = await session.get(Application, application_id)
|
||||
if row is None:
|
||||
continue
|
||||
affected.add(row.status)
|
||||
await session.delete(row)
|
||||
deleted += 1
|
||||
await session.flush()
|
||||
for status in affected:
|
||||
await self._renumber(session, status)
|
||||
await session.commit()
|
||||
return deleted
|
||||
|
||||
# -- Encrypted API key store (sync; read on the LLM hot path) -----------
|
||||
|
||||
def get_api_key_ciphertexts(self) -> dict[str, str]:
|
||||
"""Return ``{provider: ciphertext}`` for all stored keys (sync)."""
|
||||
with self._sync() as session:
|
||||
rows = session.execute(select(ApiKey)).scalars().all()
|
||||
return {row.provider: row.ciphertext for row in rows}
|
||||
|
||||
def set_api_key_ciphertext(self, provider: str, ciphertext: str) -> None:
|
||||
"""Upsert one provider's ciphertext (sync)."""
|
||||
with self._sync() as session:
|
||||
row = session.get(ApiKey, provider)
|
||||
if row is None:
|
||||
session.add(
|
||||
ApiKey(provider=provider, ciphertext=ciphertext, updated_at=_now())
|
||||
)
|
||||
else:
|
||||
row.ciphertext = ciphertext
|
||||
row.updated_at = _now()
|
||||
session.commit()
|
||||
|
||||
def delete_api_key(self, provider: str) -> None:
|
||||
"""Delete one provider's key (sync)."""
|
||||
with self._sync() as session:
|
||||
row = session.get(ApiKey, provider)
|
||||
if row is not None:
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
|
||||
def clear_api_keys(self) -> None:
|
||||
"""Delete all stored keys (sync)."""
|
||||
with self._sync() as session:
|
||||
session.execute(delete(ApiKey))
|
||||
session.commit()
|
||||
|
||||
def replace_api_keys(self, ciphertexts: dict[str, str]) -> None:
|
||||
"""Atomically replace the whole key store (clear + insert in one txn).
|
||||
|
||||
A single transaction means a failure mid-write can't leave the store
|
||||
half-cleared and wipe a user's previously saved keys.
|
||||
"""
|
||||
with self._sync() as session:
|
||||
session.execute(delete(ApiKey))
|
||||
now = _now()
|
||||
for provider, ciphertext in ciphertexts.items():
|
||||
if ciphertext:
|
||||
session.add(
|
||||
ApiKey(provider=provider, ciphertext=ciphertext, updated_at=now)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# -- Stats / maintenance ------------------------------------------------
|
||||
|
||||
async def get_stats(self) -> dict[str, Any]:
|
||||
"""Get database statistics."""
|
||||
async with self._session() as session:
|
||||
resumes = await session.scalar(select(func.count()).select_from(Resume))
|
||||
jobs = await session.scalar(select(func.count()).select_from(Job))
|
||||
improvements = await session.scalar(
|
||||
select(func.count()).select_from(Improvement)
|
||||
)
|
||||
master = await session.execute(
|
||||
select(Resume.resume_id).where(Resume.is_master.is_(True)).limit(1)
|
||||
)
|
||||
return {
|
||||
"total_resumes": int(resumes or 0),
|
||||
"total_jobs": int(jobs or 0),
|
||||
"total_improvements": int(improvements or 0),
|
||||
"has_master_resume": master.first() is not None,
|
||||
}
|
||||
|
||||
async def reset_database(self) -> None:
|
||||
"""Reset by truncating user-document tables and clearing uploads.
|
||||
|
||||
Clears resumes/jobs/improvements **and** tracker applications (leaving
|
||||
orphaned cards after a full data reset would be a bug). Encrypted
|
||||
``api_keys`` are preserved — matching the pre-existing behavior where a
|
||||
reset never wiped the user's stored credentials.
|
||||
"""
|
||||
async with self._session() as session:
|
||||
await session.execute(delete(Application))
|
||||
await session.execute(delete(Improvement))
|
||||
await session.execute(delete(Job))
|
||||
await session.execute(delete(Resume))
|
||||
await session.commit()
|
||||
|
||||
uploads_dir = settings.data_dir / "uploads"
|
||||
if uploads_dir.exists():
|
||||
shutil.rmtree(uploads_dir)
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Global database instance
|
||||
db = Database()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""SQLite engine/session plumbing for the SQLAlchemy data layer.
|
||||
|
||||
Every ``Database`` instance owns its own engines (one async for the document
|
||||
tables, one sync for the encrypted ``api_keys`` table read on the synchronous
|
||||
LLM hot path) built from these factories. Keeping construction here lets tests
|
||||
spin up fully isolated engines against a temp-file database.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from app.models import Base
|
||||
|
||||
__all__ = ["Base", "make_async_engine", "make_sync_engine", "init_models_sync"]
|
||||
|
||||
|
||||
def _apply_sqlite_pragmas(dbapi_connection: Any, _connection_record: Any) -> None:
|
||||
"""Set per-connection SQLite PRAGMAs.
|
||||
|
||||
WAL improves concurrent read/write between the async (doc tables) and sync
|
||||
(api_keys) engines pointed at the same file; ``busy_timeout`` rides out the
|
||||
brief lock contention that creates; ``foreign_keys`` enforces relational
|
||||
integrity (off by default in SQLite).
|
||||
"""
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def _url(path: Path, *, driver: str) -> str:
|
||||
"""Build a SQLite URL. Absolute paths yield the required four slashes."""
|
||||
return f"sqlite+{driver}:///{path}" if driver else f"sqlite:///{path}"
|
||||
|
||||
|
||||
def make_async_engine(path: Path) -> AsyncEngine:
|
||||
"""Create the async engine (``aiosqlite``) for the document tables."""
|
||||
engine = create_async_engine(_url(path, driver="aiosqlite"), future=True)
|
||||
event.listen(engine.sync_engine, "connect", _apply_sqlite_pragmas)
|
||||
return engine
|
||||
|
||||
|
||||
def make_sync_engine(path: Path) -> Engine:
|
||||
"""Create the sync engine used for the encrypted api_keys table.
|
||||
|
||||
Key reads happen synchronously (``get_llm_config`` → ``load_config_file`` →
|
||||
``resolve_api_key``), so a sync engine avoids threading async through
|
||||
``llm.py``. It points at the same file as the async engine.
|
||||
"""
|
||||
engine = create_engine(_url(path, driver=""), future=True)
|
||||
event.listen(engine, "connect", _apply_sqlite_pragmas)
|
||||
return engine
|
||||
|
||||
|
||||
def init_models_sync(engine: Engine) -> None:
|
||||
"""Create all tables (idempotent) using a sync engine connection."""
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
# ``create_all`` does not ALTER existing SQLite tables. Keep this additive
|
||||
# migration idempotent so older local databases can load resumes safely.
|
||||
with engine.begin() as conn:
|
||||
columns = conn.exec_driver_sql("PRAGMA table_info(resumes)").mappings().all()
|
||||
if columns and "interview_prep" not in {column["name"] for column in columns}:
|
||||
conn.exec_driver_sql("ALTER TABLE resumes ADD COLUMN interview_prep TEXT")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Fix for Windows: Use ProactorEventLoop for subprocess support (Playwright)
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import __version__
|
||||
from app.config import settings
|
||||
from app.database import db
|
||||
from app.pdf import close_pdf_renderer, init_pdf_renderer
|
||||
from app.routers import (
|
||||
applications_router,
|
||||
config_router,
|
||||
enrichment_router,
|
||||
health_router,
|
||||
jobs_router,
|
||||
resume_wizard_router,
|
||||
resumes_router,
|
||||
)
|
||||
|
||||
|
||||
def _configure_application_logging() -> None:
|
||||
"""Set application log level from configuration."""
|
||||
numeric_level = getattr(logging, settings.log_level, logging.INFO)
|
||||
logging.getLogger("app").setLevel(numeric_level)
|
||||
|
||||
|
||||
_configure_application_logging()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager."""
|
||||
# Startup
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Import a legacy TinyDB database into SQLite if present (idempotent).
|
||||
# Fail-fast on error: starting with an empty DB would look like data loss.
|
||||
from app.scripts.migrate_tinydb_to_sqlite import migrate as migrate_tinydb
|
||||
|
||||
result = await migrate_tinydb()
|
||||
if result.get("status") == "migrated":
|
||||
logger.info("Startup data migration: %s", result)
|
||||
# Fold any legacy plaintext API keys into the encrypted store (idempotent,
|
||||
# non-clobbering), then strip them from config.json.
|
||||
from app.config import migrate_legacy_keys
|
||||
|
||||
migrate_legacy_keys()
|
||||
# PDF renderer uses lazy initialization - will initialize on first use
|
||||
# await init_pdf_renderer()
|
||||
yield
|
||||
# Shutdown - wrap each cleanup in try-except to ensure all resources are released
|
||||
try:
|
||||
await close_pdf_renderer()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing PDF renderer: {e}")
|
||||
|
||||
try:
|
||||
await db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Resume Matcher API",
|
||||
description="AI-powered resume tailoring for job descriptions",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS middleware - origins configurable via CORS_ORIGINS env var
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.effective_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(health_router, prefix="/api/v1")
|
||||
app.include_router(config_router, prefix="/api/v1")
|
||||
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(applications_router, prefix="/api/v1")
|
||||
app.include_router(resume_wizard_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"name": "Resume Matcher API",
|
||||
"version": __version__,
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the project.scripts console script."""
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=settings.reload,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""SQLAlchemy ORM models for Resume Matcher.
|
||||
|
||||
A single declarative ``Base`` backs all tables (doc tables migrated from
|
||||
TinyDB plus the new ``applications`` and ``api_keys`` tables). The facade in
|
||||
``app/database.py`` converts ORM rows to plain dicts so the rest of the app
|
||||
never sees ORM objects — preserving the TinyDB-era contracts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Index, Integer, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
"""Return the current UTC time as an ISO-8601 string.
|
||||
|
||||
Timestamps are stored as strings (not native datetimes) to preserve the
|
||||
TinyDB-era behavior: code compares them lexically and returns them to
|
||||
clients verbatim.
|
||||
"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base shared by every table."""
|
||||
|
||||
|
||||
class Resume(Base):
|
||||
"""A resume document (master or tailored)."""
|
||||
|
||||
__tablename__ = "resumes"
|
||||
|
||||
resume_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
content_type: Mapped[str] = mapped_column(String, default="md")
|
||||
filename: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
is_master: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
parent_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
processed_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
processing_status: Mapped[str] = mapped_column(String, default="pending")
|
||||
cover_letter: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
outreach_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
interview_prep: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
title: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# original_markdown has *absence* semantics in the TinyDB era: the key was
|
||||
# omitted entirely when None. The facade reproduces that by only emitting
|
||||
# the key when this column is non-null.
|
||||
original_markdown: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String, default=_utcnow_iso)
|
||||
updated_at: Mapped[str] = mapped_column(String, default=_utcnow_iso)
|
||||
|
||||
__table_args__ = (
|
||||
# At most one master resume. Partial unique index enforces the invariant
|
||||
# at the storage layer; ``_master_resume_lock`` remains the primary
|
||||
# (race-free) mechanism in the facade.
|
||||
Index(
|
||||
"ux_resumes_single_master",
|
||||
"is_master",
|
||||
unique=True,
|
||||
sqlite_where=text("is_master = 1"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Job(Base):
|
||||
"""A job description.
|
||||
|
||||
Only the stable columns are first-class; everything the pipeline attaches
|
||||
dynamically (``job_keywords``, ``job_keywords_hash``, ``preview_hash``,
|
||||
``preview_hashes``, ``preview_prompt_id``, ``company``, ``role``) lives in
|
||||
``metadata_json``. The facade flattens that map to top-level keys on read
|
||||
and merges non-core keys into it on update, reproducing TinyDB semantics.
|
||||
"""
|
||||
|
||||
__tablename__ = "jobs"
|
||||
|
||||
job_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
resume_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String, default=_utcnow_iso)
|
||||
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class Improvement(Base):
|
||||
"""A tailoring result linking an original resume, a tailored resume, and a job."""
|
||||
|
||||
__tablename__ = "improvements"
|
||||
|
||||
request_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
original_resume_id: Mapped[str] = mapped_column(String)
|
||||
tailored_resume_id: Mapped[str] = mapped_column(String, index=True)
|
||||
job_id: Mapped[str] = mapped_column(String)
|
||||
improvements: Mapped[list] = mapped_column(JSON, default=list)
|
||||
created_at: Mapped[str] = mapped_column(String, default=_utcnow_iso)
|
||||
|
||||
|
||||
class Application(Base):
|
||||
"""A Kanban application-tracker card."""
|
||||
|
||||
__tablename__ = "applications"
|
||||
__table_args__ = (
|
||||
# Concurrency-safe dedupe: a card is unique per (job, applied resume).
|
||||
# The app-level select-then-insert relies on this to collapse races.
|
||||
UniqueConstraint("job_id", "resume_id", name="uq_application_job_resume"),
|
||||
)
|
||||
|
||||
application_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
job_id: Mapped[str] = mapped_column(String, index=True)
|
||||
# The applied/tailored resume shown in the modal and opened by "Edit".
|
||||
resume_id: Mapped[str] = mapped_column(String, index=True)
|
||||
# Optional base resume the tailored one descends from (powers "stack" grouping).
|
||||
master_resume_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, default="applied", index=True)
|
||||
company: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
role: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
applied_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[str] = mapped_column(String, default=_utcnow_iso)
|
||||
updated_at: Mapped[str] = mapped_column(String, default=_utcnow_iso)
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""An encrypted LLM provider API key.
|
||||
|
||||
``provider`` is the *key-store* provider name (e.g. ``google`` for the
|
||||
``gemini`` LLM provider, via ``_PROVIDER_KEY_MAP``). Only ciphertext is
|
||||
stored; plaintext exists in memory only at call time.
|
||||
"""
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
provider: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
ciphertext: Mapped[str] = mapped_column(Text)
|
||||
updated_at: Mapped[str] = mapped_column(String, default=_utcnow_iso)
|
||||
@@ -0,0 +1,338 @@
|
||||
"""PDF rendering utilities using headless Chromium."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, NoReturn, Optional
|
||||
|
||||
from playwright.async_api import (
|
||||
Browser,
|
||||
Error as PlaywrightError,
|
||||
Page,
|
||||
Playwright,
|
||||
async_playwright,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Explicit, bounded navigation/selector timeout. Chosen over Playwright's
|
||||
# implicit 30s default so a slow-but-working render (large resume, cold cache,
|
||||
# modest hardware) still completes, while a genuinely stuck page still fails
|
||||
# in finite time rather than hanging.
|
||||
_NAV_TIMEOUT_MS = 60_000
|
||||
|
||||
|
||||
class PDFRenderError(Exception):
|
||||
"""Custom exception for PDF rendering errors with helpful messages."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
_playwright = None
|
||||
_browser: Optional[Browser] = None
|
||||
_init_lock = asyncio.Lock() # Lock to prevent race condition during initialization
|
||||
_subprocess_lock = asyncio.Lock()
|
||||
_subprocess_supported = True
|
||||
|
||||
|
||||
async def init_pdf_renderer() -> None:
|
||||
"""Initialize the Playwright browser instance.
|
||||
|
||||
Uses asyncio.Lock to prevent race conditions when multiple
|
||||
concurrent requests try to initialize the browser simultaneously.
|
||||
"""
|
||||
global _playwright, _browser
|
||||
|
||||
# Fast path: already initialized
|
||||
if _browser is not None:
|
||||
return
|
||||
|
||||
# Use lock to prevent race condition during initialization
|
||||
async with _init_lock:
|
||||
# Double-check after acquiring lock
|
||||
if _browser is not None:
|
||||
return
|
||||
_playwright = await async_playwright().start()
|
||||
_browser = await _launch_browser(_playwright)
|
||||
|
||||
|
||||
def _resolve_pdf_format(page_size: str) -> str:
|
||||
format_map = {
|
||||
"A4": "A4",
|
||||
"LETTER": "Letter",
|
||||
}
|
||||
return format_map.get(page_size, "A4")
|
||||
|
||||
|
||||
def _resolve_pdf_margins(margins: Optional[dict]) -> dict:
|
||||
if margins:
|
||||
return {
|
||||
"top": f"{margins.get('top', 10)}mm",
|
||||
"right": f"{margins.get('right', 10)}mm",
|
||||
"bottom": f"{margins.get('bottom', 10)}mm",
|
||||
"left": f"{margins.get('left', 10)}mm",
|
||||
}
|
||||
return {"top": "10mm", "right": "10mm", "bottom": "10mm", "left": "10mm"}
|
||||
|
||||
|
||||
def _find_chromium_executable() -> Optional[str]:
|
||||
"""Find system Chrome/Chromium/Edge executable across platforms."""
|
||||
if sys.platform == "win32":
|
||||
candidates = [
|
||||
Path(os.environ.get("PROGRAMFILES", "C:/Program Files"))
|
||||
/ "Google/Chrome/Application/chrome.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)"))
|
||||
/ "Google/Chrome/Application/chrome.exe",
|
||||
Path(os.environ.get("PROGRAMFILES", "C:/Program Files"))
|
||||
/ "Microsoft/Edge/Application/msedge.exe",
|
||||
Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)"))
|
||||
/ "Microsoft/Edge/Application/msedge.exe",
|
||||
]
|
||||
elif sys.platform == "darwin":
|
||||
# macOS application paths
|
||||
candidates = [
|
||||
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||
Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
|
||||
Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
|
||||
]
|
||||
else:
|
||||
# Linux paths: standard locations, Snap, and Flatpak
|
||||
candidates = [
|
||||
Path("/usr/bin/google-chrome"),
|
||||
Path("/usr/bin/google-chrome-stable"),
|
||||
Path("/usr/bin/chromium"),
|
||||
Path("/usr/bin/chromium-browser"),
|
||||
Path("/usr/bin/microsoft-edge"),
|
||||
Path("/snap/bin/chromium"),
|
||||
Path("/var/lib/flatpak/exports/bin/com.google.Chrome"),
|
||||
Path("/var/lib/flatpak/exports/bin/org.chromium.Chromium"),
|
||||
Path(os.path.expanduser("~/.local/share/flatpak/exports/bin/com.google.Chrome")),
|
||||
Path(os.path.expanduser("~/.local/share/flatpak/exports/bin/org.chromium.Chromium")),
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
async def _launch_browser(playwright: Playwright) -> Browser:
|
||||
try:
|
||||
return await playwright.chromium.launch()
|
||||
except PlaywrightError as e:
|
||||
if "Executable doesn't exist" not in str(e):
|
||||
raise
|
||||
fallback_executable = _find_chromium_executable()
|
||||
if not fallback_executable:
|
||||
raise PDFRenderError(
|
||||
"Playwright browser executable is missing, and no system Chrome/Edge "
|
||||
"installation was found. Install Playwright browsers or install Chrome/Edge."
|
||||
) from e
|
||||
return await playwright.chromium.launch(executable_path=fallback_executable)
|
||||
|
||||
|
||||
async def _render_page_to_pdf(
|
||||
page: Page,
|
||||
url: str,
|
||||
selector: str,
|
||||
pdf_format: str,
|
||||
pdf_margins: dict,
|
||||
) -> bytes:
|
||||
# NOTE: do NOT use wait_until="networkidle" here. The Next.js dev server
|
||||
# (HMR/Turbopack + RSC streaming) keeps the network busy, so "idle" may
|
||||
# never arrive and goto silently hangs until timeout → 503 (issues
|
||||
# #799/#808), with the failure depending on environment/network noise.
|
||||
# Wait on the real readiness condition instead — document "load", the
|
||||
# resume content selector, and fonts — all bounded by an explicit timeout
|
||||
# so the outcome is deterministic.
|
||||
await page.goto(url, wait_until="load", timeout=_NAV_TIMEOUT_MS)
|
||||
await page.wait_for_selector(selector, timeout=_NAV_TIMEOUT_MS)
|
||||
# Bound the fonts wait too — plain page.evaluate has no timeout, so a stuck
|
||||
# font load could otherwise hang the render past _NAV_TIMEOUT_MS.
|
||||
await page.wait_for_function(
|
||||
"() => document.fonts.ready.then(() => true)", timeout=_NAV_TIMEOUT_MS
|
||||
)
|
||||
return await page.pdf(
|
||||
format=pdf_format,
|
||||
print_background=True,
|
||||
margin=pdf_margins,
|
||||
)
|
||||
|
||||
|
||||
async def _render_with_browser(
|
||||
browser: Browser,
|
||||
url: str,
|
||||
selector: str,
|
||||
pdf_format: str,
|
||||
pdf_margins: dict,
|
||||
) -> bytes:
|
||||
page: Page = await browser.new_page()
|
||||
try:
|
||||
return await _render_page_to_pdf(page, url, selector, pdf_format, pdf_margins)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
|
||||
def _run_in_new_loop(coro: Awaitable[bytes]) -> bytes:
|
||||
if sys.platform == "win32":
|
||||
from asyncio.windows_events import ProactorEventLoop
|
||||
|
||||
loop = ProactorEventLoop()
|
||||
else:
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
try:
|
||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
finally:
|
||||
loop.close()
|
||||
asyncio.set_event_loop(None)
|
||||
|
||||
|
||||
def _render_resume_pdf_sync(
|
||||
url: str,
|
||||
selector: str,
|
||||
pdf_format: str,
|
||||
pdf_margins: dict,
|
||||
) -> bytes:
|
||||
async def _run() -> bytes:
|
||||
async with async_playwright() as playwright:
|
||||
browser = await _launch_browser(playwright)
|
||||
try:
|
||||
return await _render_with_browser(
|
||||
browser, url, selector, pdf_format, pdf_margins
|
||||
)
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
return _run_in_new_loop(_run())
|
||||
|
||||
|
||||
async def _render_resume_pdf_in_thread(
|
||||
url: str,
|
||||
selector: str,
|
||||
pdf_format: str,
|
||||
pdf_margins: dict,
|
||||
) -> bytes:
|
||||
return await asyncio.to_thread(
|
||||
_render_resume_pdf_sync, url, selector, pdf_format, pdf_margins
|
||||
)
|
||||
|
||||
|
||||
def _raise_playwright_error(error: PlaywrightError, url: str) -> NoReturn:
|
||||
error_msg = str(error)
|
||||
if "Executable doesn't exist" in error_msg:
|
||||
exe = sys.executable.replace("\\", "/")
|
||||
command = f"{exe} -m playwright install chromium"
|
||||
raise PDFRenderError(
|
||||
"Playwright browser executable is missing or out of date. "
|
||||
"Command shown for reference; quote the path if it contains spaces: "
|
||||
f"{command}"
|
||||
) from error
|
||||
if "net::ERR_CONNECTION_REFUSED" in error_msg:
|
||||
raise PDFRenderError(
|
||||
f"Cannot connect to frontend for PDF generation. "
|
||||
f"Attempted URL: {url}. "
|
||||
f"Please ensure: 1) The frontend is running, "
|
||||
f"2) The FRONTEND_BASE_URL environment variable in the backend .env file "
|
||||
f"matches the URL where your frontend is accessible."
|
||||
) from error
|
||||
# Catch-all: the raw Playwright message can carry internal navigation URLs
|
||||
# and a full call log. Log it server-side; return a generic message to the
|
||||
# client (CLAUDE.md rule 5 — and it stops the verbose trace from overflowing
|
||||
# the client error modal, #811).
|
||||
logger.error("PDF rendering failed for %s: %s", url, error_msg)
|
||||
raise PDFRenderError(
|
||||
"PDF rendering failed. Please try again, or try a simpler resume or a "
|
||||
"different template."
|
||||
) from error
|
||||
|
||||
|
||||
def _loop_supports_subprocess() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return True
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return True
|
||||
return loop.__class__.__name__ == "ProactorEventLoop"
|
||||
|
||||
|
||||
async def close_pdf_renderer() -> None:
|
||||
"""Close the Playwright browser instance."""
|
||||
global _playwright, _browser
|
||||
if _browser is not None:
|
||||
await _browser.close()
|
||||
_browser = None
|
||||
if _playwright is not None:
|
||||
await _playwright.stop()
|
||||
_playwright = None
|
||||
|
||||
|
||||
async def render_resume_pdf(
|
||||
url: str,
|
||||
page_size: str = "A4",
|
||||
selector: str = ".resume-print",
|
||||
margins: Optional[dict] = None,
|
||||
) -> bytes:
|
||||
"""Render a URL to PDF bytes.
|
||||
|
||||
Args:
|
||||
url: The URL to render (print route)
|
||||
page_size: Page size format - "A4" or "LETTER"
|
||||
selector: CSS selector to wait for before rendering (default: ".resume-print")
|
||||
margins: Page margins dict with top/right/bottom/left in mm (applied to every page)
|
||||
|
||||
Note:
|
||||
Margins are applied via Playwright's PDF margins, ensuring they appear
|
||||
on every page (not just the first page like HTML padding would).
|
||||
"""
|
||||
global _subprocess_supported
|
||||
|
||||
pdf_format = _resolve_pdf_format(page_size)
|
||||
pdf_margins = _resolve_pdf_margins(margins)
|
||||
|
||||
if _browser is not None:
|
||||
try:
|
||||
return await _render_with_browser(_browser, url, selector, pdf_format, pdf_margins)
|
||||
except PlaywrightError as e:
|
||||
_raise_playwright_error(e, url)
|
||||
|
||||
async with _subprocess_lock:
|
||||
subprocess_supported = _subprocess_supported
|
||||
if subprocess_supported and not _loop_supports_subprocess():
|
||||
_subprocess_supported = False
|
||||
subprocess_supported = False
|
||||
|
||||
if subprocess_supported:
|
||||
try:
|
||||
await init_pdf_renderer()
|
||||
except NotImplementedError:
|
||||
async with _subprocess_lock:
|
||||
_subprocess_supported = False
|
||||
subprocess_supported = False
|
||||
except PlaywrightError as e:
|
||||
_raise_playwright_error(e, url)
|
||||
|
||||
if not subprocess_supported:
|
||||
try:
|
||||
return await _render_resume_pdf_in_thread(
|
||||
url, selector, pdf_format, pdf_margins
|
||||
)
|
||||
except PlaywrightError as e:
|
||||
_raise_playwright_error(e, url)
|
||||
|
||||
if _browser is None:
|
||||
raise PDFRenderError("PDF renderer failed to initialize.")
|
||||
|
||||
try:
|
||||
return await _render_with_browser(_browser, url, selector, pdf_format, pdf_margins)
|
||||
except PlaywrightError as e:
|
||||
_raise_playwright_error(e, url)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""LLM prompt templates."""
|
||||
|
||||
from app.prompts.templates import (
|
||||
CRITICAL_TRUTHFULNESS_RULES,
|
||||
DEFAULT_IMPROVE_PROMPT_ID,
|
||||
DIFF_IMPROVE_PROMPT,
|
||||
DIFF_STRATEGY_INSTRUCTIONS,
|
||||
EXTRACT_KEYWORDS_PROMPT,
|
||||
GENERATE_TITLE_PROMPT,
|
||||
IMPROVE_PROMPT_OPTIONS,
|
||||
IMPROVE_RESUME_PROMPT,
|
||||
IMPROVE_RESUME_PROMPTS,
|
||||
INTERVIEW_PREP_PROMPT,
|
||||
PARSE_RESUME_PROMPT,
|
||||
SKILL_TARGET_PLAN_PROMPT,
|
||||
get_language_name,
|
||||
)
|
||||
|
||||
# Placeholders every user-supplied cover-letter / outreach prompt must contain.
|
||||
# These correspond to the ``.format()`` keys used by the services in
|
||||
# ``app/services/cover_letter.py``. Validated at save time so a 422 surfaces
|
||||
# immediately instead of a ``KeyError`` during generation.
|
||||
REQUIRED_FEATURE_PROMPT_PLACEHOLDERS: tuple[str, ...] = (
|
||||
"{job_description}",
|
||||
"{resume_data}",
|
||||
"{output_language}",
|
||||
)
|
||||
|
||||
|
||||
def validate_prompt_placeholders(prompt: str) -> list[str]:
|
||||
"""Return required placeholders missing from ``prompt``.
|
||||
|
||||
Empty or whitespace-only prompts are treated as "use default" and return
|
||||
an empty list (valid — the router treats them as clearing the override).
|
||||
Non-empty prompts must include every entry from
|
||||
``REQUIRED_FEATURE_PROMPT_PLACEHOLDERS``.
|
||||
"""
|
||||
if not prompt or not prompt.strip():
|
||||
return []
|
||||
return [p for p in REQUIRED_FEATURE_PROMPT_PLACEHOLDERS if p not in prompt]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PARSE_RESUME_PROMPT",
|
||||
"EXTRACT_KEYWORDS_PROMPT",
|
||||
"IMPROVE_RESUME_PROMPT",
|
||||
"IMPROVE_RESUME_PROMPTS",
|
||||
"IMPROVE_PROMPT_OPTIONS",
|
||||
"DEFAULT_IMPROVE_PROMPT_ID",
|
||||
"CRITICAL_TRUTHFULNESS_RULES",
|
||||
"DIFF_IMPROVE_PROMPT",
|
||||
"DIFF_STRATEGY_INSTRUCTIONS",
|
||||
"SKILL_TARGET_PLAN_PROMPT",
|
||||
"GENERATE_TITLE_PROMPT",
|
||||
"INTERVIEW_PREP_PROMPT",
|
||||
"REQUIRED_FEATURE_PROMPT_PLACEHOLDERS",
|
||||
"validate_prompt_placeholders",
|
||||
"get_language_name",
|
||||
]
|
||||
@@ -0,0 +1,195 @@
|
||||
"""LLM prompt templates for AI-powered resume enrichment."""
|
||||
|
||||
ANALYZE_RESUME_PROMPT = """You are a professional resume analyst. Analyze this resume to identify items in Experience and Projects sections that have weak, vague, or incomplete descriptions.
|
||||
|
||||
IMPORTANT: Generate ALL output text (questions, placeholders, summaries, weakness reasons) in {output_language}.
|
||||
|
||||
RESUME DATA (JSON):
|
||||
{resume_json}
|
||||
|
||||
WEAK DESCRIPTION INDICATORS:
|
||||
1. Generic phrases: "responsible for", "worked on", "helped with", "assisted in", "involved in"
|
||||
2. Missing metrics/impact: No numbers, percentages, dollar amounts, or measurable outcomes
|
||||
3. Unclear scope: Vague about team size, project scale, user count, or responsibilities
|
||||
4. No technologies/tools: Missing specific tech stack, tools, or methodologies used
|
||||
5. Passive voice without ownership: Not clear what the candidate personally accomplished
|
||||
6. Too brief: Single short bullet that doesn't explain the work
|
||||
|
||||
GOOD DESCRIPTION EXAMPLES (for reference):
|
||||
- "Led migration of 15 microservices to Kubernetes, reducing deployment time by 60%"
|
||||
- "Built real-time analytics dashboard using React and D3.js, serving 10K daily users"
|
||||
- "Architected payment processing system handling $2M monthly transactions"
|
||||
|
||||
TASK:
|
||||
1. Review each Experience and Project item's description bullets
|
||||
2. Identify items that would benefit from more detail
|
||||
3. Generate a MAXIMUM of 6 questions total across ALL items (not per item)
|
||||
4. Prioritize the most impactful questions that will yield the best improvements
|
||||
5. If multiple items need enhancement, distribute questions wisely (e.g., 2-3 per item)
|
||||
6. Questions should help extract: metrics, technologies, scope, impact, and specific contributions
|
||||
|
||||
OUTPUT FORMAT (JSON only, no other text):
|
||||
{{
|
||||
"items_to_enrich": [
|
||||
{{
|
||||
"item_id": "exp_0",
|
||||
"item_type": "experience",
|
||||
"title": "Software Engineer",
|
||||
"subtitle": "Company Name",
|
||||
"current_description": ["bullet 1", "bullet 2"],
|
||||
"weakness_reason": "Missing quantifiable impact and specific technologies used"
|
||||
}}
|
||||
],
|
||||
"questions": [
|
||||
{{
|
||||
"question_id": "q_0",
|
||||
"item_id": "exp_0",
|
||||
"question": "What specific metrics improved as a result of your work? (e.g., performance gains, cost savings, user growth)",
|
||||
"placeholder": "e.g., Reduced API response time by 40%, saved $50K annually"
|
||||
}},
|
||||
{{
|
||||
"question_id": "q_1",
|
||||
"item_id": "exp_0",
|
||||
"question": "What technologies, frameworks, or tools did you use in this role?",
|
||||
"placeholder": "e.g., Python, FastAPI, PostgreSQL, Redis, AWS Lambda"
|
||||
}},
|
||||
{{
|
||||
"question_id": "q_2",
|
||||
"item_id": "exp_0",
|
||||
"question": "What was the scale of your work? (team size, users served, data volume)",
|
||||
"placeholder": "e.g., Team of 5, serving 100K users, processing 1M requests/day"
|
||||
}},
|
||||
{{
|
||||
"question_id": "q_3",
|
||||
"item_id": "exp_0",
|
||||
"question": "What was your specific contribution or ownership in this project?",
|
||||
"placeholder": "e.g., Designed the architecture, led the implementation, mentored 2 junior devs"
|
||||
}}
|
||||
],
|
||||
"analysis_summary": "Brief summary of overall resume strength and areas for improvement"
|
||||
}}
|
||||
|
||||
IMPORTANT RULES:
|
||||
- MAXIMUM 6 QUESTIONS TOTAL - this is a hard limit, never exceed it
|
||||
- Only include items that genuinely need improvement
|
||||
- If the resume is already strong, return empty arrays with a positive summary
|
||||
- Use "exp_0", "exp_1" for experience items (based on array index)
|
||||
- Use "proj_0", "proj_1" for project items (based on array index)
|
||||
- Generate unique question IDs: "q_0", "q_1", "q_2", etc. (max q_5)
|
||||
- Questions should be specific to the role/project context
|
||||
- Keep questions conversational but professional
|
||||
- Placeholder text should give concrete examples
|
||||
- Prioritize quality over quantity - ask the most impactful questions first"""
|
||||
|
||||
ENHANCE_DESCRIPTION_PROMPT = """You are a professional resume writer. Your goal is to ADD new bullet points to this resume item using the additional context provided by the candidate. DO NOT rewrite or replace existing bullets - only add new ones.
|
||||
|
||||
IMPORTANT: Generate ALL output text (bullet points) in {output_language}.
|
||||
|
||||
ORIGINAL ITEM:
|
||||
Type: {item_type}
|
||||
Title: {title}
|
||||
Subtitle: {subtitle}
|
||||
Current Description (KEEP ALL OF THESE):
|
||||
{current_description}
|
||||
|
||||
CANDIDATE'S ADDITIONAL CONTEXT:
|
||||
{answers}
|
||||
|
||||
TASK:
|
||||
Generate NEW bullet points to ADD to the existing description. The original bullets will be kept as-is.
|
||||
New bullets should be:
|
||||
1. Action-oriented: Start with strong verbs (Led, Built, Architected, Implemented, Optimized)
|
||||
2. Quantified: Include metrics, numbers, percentages where the candidate provided them
|
||||
3. Technically specific: Mention technologies, tools, and methodologies
|
||||
4. Impact-focused: Clearly state the business or technical outcome
|
||||
5. Ownership-clear: Show what the candidate personally did vs. the team
|
||||
|
||||
OUTPUT FORMAT (JSON only, no other text):
|
||||
{{
|
||||
"additional_bullets": [
|
||||
"New bullet point 1 with metrics and impact",
|
||||
"New bullet point 2 with technologies used",
|
||||
"New bullet point 3 with scope and ownership"
|
||||
]
|
||||
}}
|
||||
|
||||
IMPORTANT RULES:
|
||||
- Generate 2-4 NEW bullet points to ADD (not replace)
|
||||
- DO NOT repeat or rephrase existing bullets - only add new information
|
||||
- Preserve factual accuracy - only use information provided by the candidate
|
||||
- Don't invent metrics or details not given by the candidate
|
||||
- If candidate's answers are brief, still add what you can
|
||||
- Keep bullets concise (1-2 lines each)
|
||||
- Use past tense for past roles, present tense for current roles
|
||||
- Avoid buzzwords and fluff - be specific and concrete
|
||||
- Focus on information from the candidate's answers that isn't already in the original bullets"""
|
||||
|
||||
|
||||
# ============================================
|
||||
# AI Regenerate Feature Prompts
|
||||
# ============================================
|
||||
|
||||
|
||||
REGENERATE_ITEM_PROMPT = """You are a professional resume writer. Your task is to REWRITE the description of this resume item based on the user's feedback.
|
||||
|
||||
IMPORTANT: Generate ALL output text in {output_language}.
|
||||
|
||||
ITEM INFORMATION:
|
||||
Type: {item_type}
|
||||
Title: {title}
|
||||
Subtitle: {subtitle}
|
||||
|
||||
CURRENT DESCRIPTION (the user is NOT satisfied with this):
|
||||
{current_description}
|
||||
|
||||
USER'S FEEDBACK/INSTRUCTION:
|
||||
{user_instruction}
|
||||
|
||||
TASK:
|
||||
Based on the user's feedback, completely REWRITE the description bullets. The new description should:
|
||||
1. Address the user's specific concerns/requests
|
||||
2. Be action-oriented with strong verbs
|
||||
3. Highlight quantifiable impact ONLY when it already exists in the current description or the user's feedback (never invent numbers)
|
||||
4. Be technically specific with tools/technologies
|
||||
5. Show clear impact and ownership
|
||||
|
||||
OUTPUT FORMAT (JSON only):
|
||||
{{
|
||||
"new_bullets": [
|
||||
"Completely rewritten bullet point 1",
|
||||
"Completely rewritten bullet point 2",
|
||||
"Completely rewritten bullet point 3"
|
||||
],
|
||||
"change_summary": "Brief explanation of what was changed based on user feedback"
|
||||
}}
|
||||
|
||||
RULES:
|
||||
- Generate 2-5 NEW bullets (not additions, but replacements)
|
||||
- Directly address the user's instruction
|
||||
- Do NOT add any new facts, metrics, dates, companies, titles, or accomplishments that are not already present in CURRENT DESCRIPTION or USER'S FEEDBACK/INSTRUCTION
|
||||
- If the user asks for metrics but none exist in the provided text, do not fabricate numbers; rewrite to emphasize scope/impact qualitatively instead
|
||||
- Keep bullets concise (1-2 lines each)
|
||||
- Use past tense for past roles, present tense for current"""
|
||||
|
||||
|
||||
REGENERATE_SKILLS_PROMPT = """You are a professional resume writer. Rewrite the technical skills section based on user feedback.
|
||||
|
||||
IMPORTANT: Generate ALL output text in {output_language}.
|
||||
|
||||
CURRENT SKILLS:
|
||||
{current_skills}
|
||||
|
||||
USER'S FEEDBACK:
|
||||
{user_instruction}
|
||||
|
||||
OUTPUT FORMAT (JSON only):
|
||||
{{
|
||||
"new_skills": ["Skill 1", "Skill 2", "Skill 3"],
|
||||
"change_summary": "Brief explanation"
|
||||
}}
|
||||
|
||||
RULES:
|
||||
- Keep skills concise and industry-standard
|
||||
- Group similar technologies if appropriate
|
||||
- Prioritize most relevant skills based on feedback
|
||||
- Only include skills that already exist in CURRENT SKILLS or are explicitly provided in USER'S FEEDBACK"""
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Prompt templates and blacklists for multi-pass resume refinement."""
|
||||
|
||||
# AI Phrase Blacklist - Words and phrases that sound AI-generated
|
||||
AI_PHRASE_BLACKLIST: set[str] = {
|
||||
# Action verbs (overused in AI resume writing)
|
||||
"spearheaded",
|
||||
"orchestrated",
|
||||
"championed",
|
||||
"synergized",
|
||||
"leveraged",
|
||||
"revolutionized",
|
||||
"pioneered",
|
||||
"catalyzed",
|
||||
"operationalized",
|
||||
"architected",
|
||||
"envisioned",
|
||||
"effectuated",
|
||||
"endeavored",
|
||||
"facilitated",
|
||||
"utilized",
|
||||
# Corporate buzzwords
|
||||
"synergy",
|
||||
"synergies",
|
||||
"paradigm",
|
||||
"paradigm shift",
|
||||
"best-in-class",
|
||||
"world-class",
|
||||
"cutting-edge",
|
||||
"bleeding-edge",
|
||||
"game-changer",
|
||||
"game-changing",
|
||||
"disruptive",
|
||||
"disruptor",
|
||||
"holistic",
|
||||
"robust",
|
||||
"scalable",
|
||||
"actionable",
|
||||
"impactful",
|
||||
"proactive",
|
||||
"proactively",
|
||||
"stakeholder",
|
||||
"deliverables",
|
||||
"bandwidth",
|
||||
"circle back",
|
||||
"deep dive",
|
||||
"move the needle",
|
||||
"low-hanging fruit",
|
||||
"touch base",
|
||||
"value-add",
|
||||
# Filler phrases
|
||||
"in order to",
|
||||
"for the purpose of",
|
||||
"with a view to",
|
||||
"at the end of the day",
|
||||
"moving forward",
|
||||
"going forward",
|
||||
"on a daily basis",
|
||||
"on a regular basis",
|
||||
"in a timely manner",
|
||||
"at this point in time",
|
||||
"due to the fact that",
|
||||
"in the event that",
|
||||
"in light of the fact that",
|
||||
# Punctuation patterns
|
||||
"\u2014", # Em-dash
|
||||
"---",
|
||||
"--", # Double hyphen often used as em-dash substitute
|
||||
}
|
||||
|
||||
# Replacements for AI phrases - maps AI phrase to simpler alternative
|
||||
AI_PHRASE_REPLACEMENTS: dict[str, str] = {
|
||||
# Action verb replacements
|
||||
"spearheaded": "led",
|
||||
"orchestrated": "coordinated",
|
||||
"championed": "advocated for",
|
||||
"synergized": "collaborated",
|
||||
"leveraged": "used",
|
||||
"revolutionized": "transformed",
|
||||
"pioneered": "introduced",
|
||||
"catalyzed": "initiated",
|
||||
"operationalized": "implemented",
|
||||
"architected": "designed",
|
||||
"envisioned": "planned",
|
||||
"effectuated": "completed",
|
||||
"endeavored": "worked",
|
||||
"facilitated": "helped",
|
||||
"utilized": "used",
|
||||
# Buzzword replacements
|
||||
"synergy": "collaboration",
|
||||
"synergies": "collaborations",
|
||||
"paradigm": "approach",
|
||||
"paradigm shift": "change",
|
||||
"best-in-class": "top-performing",
|
||||
"world-class": "high-quality",
|
||||
"cutting-edge": "modern",
|
||||
"bleeding-edge": "modern",
|
||||
"game-changer": "innovation",
|
||||
"game-changing": "innovative",
|
||||
"disruptive": "innovative",
|
||||
"holistic": "comprehensive",
|
||||
"robust": "strong",
|
||||
"scalable": "expandable",
|
||||
"actionable": "practical",
|
||||
"impactful": "effective",
|
||||
"proactive": "active",
|
||||
"proactively": "actively",
|
||||
"stakeholder": "team member",
|
||||
"deliverables": "outputs",
|
||||
"bandwidth": "capacity",
|
||||
"circle back": "follow up",
|
||||
"deep dive": "analysis",
|
||||
"move the needle": "make progress",
|
||||
"low-hanging fruit": "quick wins",
|
||||
"touch base": "connect",
|
||||
"value-add": "benefit",
|
||||
# Phrase simplifications
|
||||
"in order to": "to",
|
||||
"for the purpose of": "to",
|
||||
"with a view to": "to",
|
||||
"at the end of the day": "",
|
||||
"moving forward": "",
|
||||
"going forward": "",
|
||||
"on a daily basis": "daily",
|
||||
"on a regular basis": "regularly",
|
||||
"in a timely manner": "promptly",
|
||||
"at this point in time": "now",
|
||||
"due to the fact that": "because",
|
||||
"in the event that": "if",
|
||||
"in light of the fact that": "since",
|
||||
# Punctuation replacements
|
||||
"\u2014": ", ", # Em-dash to comma
|
||||
"---": ", ",
|
||||
"--": ", ",
|
||||
}
|
||||
|
||||
|
||||
# Prompt for injecting missing keywords into a resume
|
||||
KEYWORD_INJECTION_PROMPT = """Inject the following keywords into this resume by reframing the candidate's existing experience in the job description's language. Target EVERY section (summary, work experience, projects, technical skills) by default.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Only reframe with keywords the master resume substantively supports (e.g., if the master shows "used Python for data analysis", surface "Python" and "data analysis" language)
|
||||
2. Do NOT add skills, technologies, or certifications not in the master resume
|
||||
3. Rephrase existing bullet points and content to include keywords - do not invent new content, metrics, or work history
|
||||
4. Maintain the exact same JSON structure
|
||||
5. Do not use em-dashes (—) or their variants (---, --)
|
||||
6. Make keyword incorporation the DEFAULT across all content sections, not an optional enhancement
|
||||
|
||||
Keywords to inject (only if supported by master resume):
|
||||
{keywords_to_inject}
|
||||
|
||||
Current tailored resume:
|
||||
{current_resume}
|
||||
|
||||
Master resume (source of truth):
|
||||
{master_resume}
|
||||
|
||||
Job description context:
|
||||
{job_description}
|
||||
|
||||
Output the complete resume JSON with keywords naturally integrated. Return ONLY valid JSON."""
|
||||
|
||||
|
||||
# Prompt for validation and polish pass
|
||||
VALIDATION_POLISH_PROMPT = """Review and polish this resume content. Remove any AI-sounding language and ensure all content is truthful.
|
||||
|
||||
REMOVE or REPLACE:
|
||||
- Buzzwords: "spearheaded", "synergy", "leverage", "orchestrated", etc.
|
||||
- Em-dashes (use commas or semicolons instead)
|
||||
- Overly formal language: "utilized" -> "used", "endeavored" -> "worked"
|
||||
- Generic filler: "in order to" -> "to"
|
||||
|
||||
VERIFY:
|
||||
- All skills exist in the master resume
|
||||
- All certifications exist in the master resume
|
||||
- No fabricated metrics or achievements
|
||||
|
||||
Resume to polish:
|
||||
{resume}
|
||||
|
||||
Master resume (verify all claims against this):
|
||||
{master_resume}
|
||||
|
||||
Output the polished resume JSON. Return ONLY valid JSON."""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Prompt template for the adaptive resume wizard turn."""
|
||||
|
||||
RESUME_WIZARD_TURN_PROMPT = """You are a truthful resume-writing assistant guiding a user \
|
||||
through building a general master resume, ONE question at a time.
|
||||
|
||||
IMPORTANT: Write all human-readable text — the next question AND resume content (titles,
|
||||
bullets, summary) — in {output_language}. But keep STRUCTURAL values in their original form:
|
||||
"next_question.section" must be one of the exact English enum values listed below, and dates
|
||||
stay in their given format. Do NOT translate section keys or dates.
|
||||
|
||||
You are working on this section right now: {current_section}
|
||||
|
||||
TRUTHFULNESS RULES (non-negotiable):
|
||||
1. Never invent employers, job titles, dates, degrees, certifications, awards, metrics, tools, or skills.
|
||||
2. Turn the user's OWN facts into strong, concise resume content. Do not add facts they did not give.
|
||||
3. If a needed fact is missing or vague, do NOT guess — ask for it in "next_question".
|
||||
4. Preserve existing draft data unless the user clearly changes it.
|
||||
5. Build a GENERAL master resume, not a job-specific tailored one.
|
||||
|
||||
CONTENT SHAPE:
|
||||
- Work and internship entries: aim for 3 bullets when enough facts exist.
|
||||
- Project entries: aim for 2 bullets when enough facts exist.
|
||||
- Skills come only from facts the user gave or existing draft data.
|
||||
|
||||
ADAPTIVE FLOW:
|
||||
- Read the CURRENT DRAFT and the user's ANSWER. Update ONLY the {current_section} part of the resume.
|
||||
- Then choose the most useful NEXT question and set "next_question.section" to the section it belongs to.
|
||||
- Valid section values: intro, contact, summary, workExperience, internships, education, personalProjects, skills, review.
|
||||
- Set "is_complete" to true ONLY when the resume is a solid general master resume (name + at least one substantive experience or project + some skills).
|
||||
|
||||
CURRENT DRAFT JSON:
|
||||
{resume_json}
|
||||
|
||||
USER ANSWER:
|
||||
{answer_text}
|
||||
|
||||
Output ONLY this JSON object and nothing else:
|
||||
{{
|
||||
"resume_data": {{
|
||||
"personalInfo": {{"name": "", "title": "", "email": "", "phone": "", "location": "", "website": "", "linkedin": "", "github": ""}},
|
||||
"summary": "",
|
||||
"workExperience": [],
|
||||
"education": [],
|
||||
"personalProjects": [],
|
||||
"additional": {{"technicalSkills": [], "languages": [], "certificationsTraining": [], "awards": []}},
|
||||
"sectionMeta": [],
|
||||
"customSections": {{}}
|
||||
}},
|
||||
"next_question": {{"text": "Your next concise question", "section": "workExperience"}},
|
||||
"inferred_skills": ["Skill"],
|
||||
"is_complete": false
|
||||
}}"""
|
||||
@@ -0,0 +1,591 @@
|
||||
"""LLM prompt templates for resume processing."""
|
||||
|
||||
# Language code to full name mapping
|
||||
LANGUAGE_NAMES = {
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"zh": "Chinese (Simplified)",
|
||||
"ja": "Japanese",
|
||||
"pt": "Brazilian Portuguese",
|
||||
"fr": "French",
|
||||
}
|
||||
|
||||
|
||||
def get_language_name(code: str) -> str:
|
||||
"""Get full language name from code."""
|
||||
return LANGUAGE_NAMES.get(code, "English")
|
||||
|
||||
|
||||
# Schema with example values - used for prompts to show LLM expected format
|
||||
RESUME_SCHEMA_EXAMPLE = """{
|
||||
"personalInfo": {
|
||||
"name": "John Doe",
|
||||
"title": "Software Engineer",
|
||||
"email": "john@example.com",
|
||||
"phone": "+1-555-0100",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://johndoe.dev",
|
||||
"linkedin": "linkedin.com/in/johndoe",
|
||||
"github": "github.com/johndoe"
|
||||
},
|
||||
"summary": "Experienced software engineer with 5+ years...",
|
||||
"workExperience": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Senior Software Engineer",
|
||||
"company": "Tech Corp",
|
||||
"location": "San Francisco, CA",
|
||||
"years": "Jan 2020 - Present",
|
||||
"description": [
|
||||
"Led development of microservices architecture",
|
||||
"Improved system performance by 40%"
|
||||
]
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"id": 1,
|
||||
"institution": "University of California",
|
||||
"degree": "B.S. Computer Science",
|
||||
"years": "2014 - 2018",
|
||||
"description": "Graduated with honors"
|
||||
}
|
||||
],
|
||||
"personalProjects": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Open Source Tool",
|
||||
"role": "Creator & Maintainer",
|
||||
"years": "Mar 2021 - Present",
|
||||
"description": [
|
||||
"Built CLI tool with 1000+ GitHub stars",
|
||||
"Used by 50+ companies worldwide"
|
||||
]
|
||||
}
|
||||
],
|
||||
"additional": {
|
||||
"technicalSkills": ["Python", "JavaScript", "AWS", "Docker"],
|
||||
"languages": ["English (Native)", "Spanish (Conversational)"],
|
||||
"certificationsTraining": ["AWS Solutions Architect"],
|
||||
"awards": ["Employee of the Year 2022"]
|
||||
},
|
||||
"customSections": {
|
||||
"publications": {
|
||||
"sectionType": "itemList",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Paper Title",
|
||||
"subtitle": "Journal Name",
|
||||
"years": "Jun 2023",
|
||||
"description": ["Brief description of the publication"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"volunteer_work": {
|
||||
"sectionType": "text",
|
||||
"text": "Description of volunteer activities..."
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
# Schema for improve prompts - excludes personalInfo (preserved from original)
|
||||
IMPROVE_SCHEMA_EXAMPLE = """{
|
||||
"summary": "Experienced software engineer with 5+ years...",
|
||||
"workExperience": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Senior Software Engineer",
|
||||
"company": "Tech Corp",
|
||||
"location": "San Francisco, CA",
|
||||
"years": "Jan 2020 - Present",
|
||||
"description": [
|
||||
"Led development of microservices architecture",
|
||||
"Improved system performance by 40%"
|
||||
]
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"id": 1,
|
||||
"institution": "University of California",
|
||||
"degree": "B.S. Computer Science",
|
||||
"years": "2014 - 2018",
|
||||
"description": "Graduated with honors"
|
||||
}
|
||||
],
|
||||
"personalProjects": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Open Source Tool",
|
||||
"role": "Creator & Maintainer",
|
||||
"years": "Mar 2021 - Present",
|
||||
"description": [
|
||||
"Built CLI tool with 1000+ GitHub stars",
|
||||
"Used by 50+ companies worldwide"
|
||||
]
|
||||
}
|
||||
],
|
||||
"additional": {
|
||||
"technicalSkills": ["Python", "JavaScript", "AWS", "Docker"],
|
||||
"languages": ["English (Native)", "Spanish (Conversational)"],
|
||||
"certificationsTraining": ["AWS Solutions Architect"],
|
||||
"awards": ["Employee of the Year 2022"]
|
||||
},
|
||||
"customSections": {
|
||||
"publications": {
|
||||
"sectionType": "itemList",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Paper Title",
|
||||
"subtitle": "Journal Name",
|
||||
"years": "Jun 2023",
|
||||
"description": ["Brief description of the publication"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"volunteer_work": {
|
||||
"sectionType": "text",
|
||||
"text": "Description of volunteer activities..."
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
PARSE_RESUME_PROMPT = """Parse this resume into JSON. Output ONLY the JSON object, no other text.
|
||||
|
||||
Map content to standard sections when possible. For non-standard sections (like Publications, Volunteer Work, Research, Hobbies), add them to customSections with an appropriate type.
|
||||
|
||||
Example output format:
|
||||
{schema}
|
||||
|
||||
Custom section types:
|
||||
- "text": Single text block (e.g., objective, statement)
|
||||
- "itemList": List of items with title, subtitle, years, description (e.g., publications, research)
|
||||
- "stringList": Simple list of strings (e.g., hobbies, interests)
|
||||
|
||||
Rules:
|
||||
- Use "" for missing text fields, [] for missing arrays, null for optional fields
|
||||
- Number IDs starting from 1
|
||||
- Format dates preserving the original precision. Keep months when present: "Jan 2020 - Dec 2023", "May 2021 - Present". Use "YYYY - YYYY" only when the source has no months.
|
||||
- Use snake_case for custom section keys (e.g., "volunteer_work", "publications")
|
||||
- Preserve the original section name as a descriptive key
|
||||
- Normalize date separators: "2020-2021" → "2020 - 2021", "Current"/"Ongoing" → "Present". Do NOT discard months.
|
||||
- For ambiguous dates like "3 years experience", infer approximate years from context or use "~YYYY"
|
||||
- Flag overlapping dates (concurrent roles) by preserving both, don't merge
|
||||
|
||||
Resume to parse:
|
||||
{resume_text}"""
|
||||
|
||||
EXTRACT_KEYWORDS_PROMPT = """Extract job requirements as JSON. Output ONLY the JSON object, no other text.
|
||||
|
||||
Example format:
|
||||
{{
|
||||
"company": "Acme Corp",
|
||||
"role": "Senior Backend Engineer",
|
||||
"required_skills": ["Python", "AWS"],
|
||||
"preferred_skills": ["Kubernetes"],
|
||||
"experience_requirements": ["5+ years"],
|
||||
"education_requirements": ["Bachelor's in CS"],
|
||||
"key_responsibilities": ["Lead team"],
|
||||
"keywords": ["microservices", "agile"],
|
||||
"experience_years": 5,
|
||||
"seniority_level": "senior"
|
||||
}}
|
||||
|
||||
Extract numeric years (e.g., "5+ years" → 5) and infer seniority level.
|
||||
Set "company" to the hiring company name and "role" to the job title exactly as
|
||||
written in the posting; use an empty string for either if it is not stated.
|
||||
|
||||
Job description:
|
||||
{job_description}"""
|
||||
|
||||
CRITICAL_TRUTHFULNESS_RULES_TEMPLATE = """CRITICAL TRUTHFULNESS RULES - NEVER VIOLATE:
|
||||
1. DO NOT add any skill, tool, technology, or certification that is not explicitly mentioned in the original resume
|
||||
2. DO NOT invent numeric achievements (e.g., "increased by 30%") unless they exist in original
|
||||
3. DO NOT add company names, product names, or technical terms not in the original
|
||||
4. DO NOT upgrade experience level (e.g., "Junior" -> "Senior")
|
||||
5. DO NOT add languages, frameworks, or platforms the candidate hasn't used
|
||||
6. DO NOT extend employment dates or change timelines. Copy date ranges exactly as they appear, including months.
|
||||
7. {rule_7}
|
||||
8. Preserve factual accuracy - only use information provided by the candidate
|
||||
9. NEVER remove existing skills, certifications, languages, or awards. You may reorder by relevance, but every original item must remain.
|
||||
|
||||
Violation of these rules could cause serious problems for the candidate in job interviews.
|
||||
"""
|
||||
|
||||
|
||||
def _build_truthfulness_rules(rule_7: str) -> str:
|
||||
return CRITICAL_TRUTHFULNESS_RULES_TEMPLATE.format(rule_7=rule_7)
|
||||
|
||||
|
||||
CRITICAL_TRUTHFULNESS_RULES = {
|
||||
"nudge": _build_truthfulness_rules(
|
||||
"DO NOT add new bullet points or content - only rephrase existing content"
|
||||
),
|
||||
"keywords": _build_truthfulness_rules(
|
||||
"You may rephrase existing bullet points to include keywords, but do NOT add new bullet points"
|
||||
),
|
||||
"full": _build_truthfulness_rules(
|
||||
"You may expand existing bullet points or add new ones that elaborate on existing work, but DO NOT invent entirely new responsibilities"
|
||||
),
|
||||
}
|
||||
|
||||
IMPROVE_RESUME_PROMPT_NUDGE = """Lightly nudge this resume toward the job description. Output ONLY the JSON object, no other text.
|
||||
|
||||
{critical_truthfulness_rules}
|
||||
|
||||
IMPORTANT: Generate ALL text content (summary, descriptions, skills) in {output_language}.
|
||||
Do NOT include personalInfo in your output - it will be preserved from the original resume.
|
||||
|
||||
Rules:
|
||||
- Make minimal, conservative edits only where there is a clear existing match
|
||||
- Do NOT change the candidate's role, industry, or seniority level
|
||||
- Do NOT introduce new tools, technologies, or certifications not already present
|
||||
- Do NOT add new bullet points or sections
|
||||
- Preserve original bullet count and ordering within each section
|
||||
- Keep proper nouns (names, company names, locations) unchanged
|
||||
- For customSections: preserve exact structure, item count, titles, subtitles, and years. If an item's description is an empty array [] in the original, keep it empty []. Do NOT generate descriptions for items that had none.
|
||||
- Copy the "years" field values EXACTLY as they appear in the original resume (including any month prefixes like "Jan 2020 - Present"). Do not shorten, reformat, or drop months.
|
||||
- If the resume is non-technical, do NOT add technical jargon
|
||||
- Do NOT use em dash ("—") anywhere in the writing/output, even if it exists, remove it
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Keywords to emphasize (only if already supported by resume content):
|
||||
{job_keywords}
|
||||
|
||||
Original Resume:
|
||||
{original_resume}
|
||||
|
||||
Output in this JSON format:
|
||||
{schema}"""
|
||||
|
||||
IMPROVE_RESUME_PROMPT_KEYWORDS = """Enhance this resume with relevant keywords from the job description. Output ONLY the JSON object, no other text.
|
||||
|
||||
{critical_truthfulness_rules}
|
||||
|
||||
IMPORTANT: Generate ALL text content (summary, descriptions, skills) in {output_language}.
|
||||
Do NOT include personalInfo in your output - it will be preserved from the original resume.
|
||||
|
||||
Rules:
|
||||
- Strengthen alignment by weaving in relevant keywords where evidence already exists
|
||||
- You may rephrase bullet points to include keyword phrasing
|
||||
- Do NOT introduce new skills, tools, or certifications not in the resume
|
||||
- Do NOT change role, industry, or seniority level
|
||||
- For customSections: preserve exact structure, item count, titles, subtitles, and years. If an item's description is an empty array [] in the original, keep it empty []. Do NOT generate descriptions for items that had none.
|
||||
- Copy the "years" field values EXACTLY as they appear in the original resume (including any month prefixes like "Jan 2020 - Present"). Do not shorten, reformat, or drop months.
|
||||
- If resume is non-technical, keep language non-technical while still aligning keywords
|
||||
- Do NOT use em dash ("—") anywhere in the writing/output, even if it exists, remove it
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Keywords to emphasize:
|
||||
{job_keywords}
|
||||
|
||||
Original Resume:
|
||||
{original_resume}
|
||||
|
||||
Output in this JSON format:
|
||||
{schema}"""
|
||||
|
||||
IMPROVE_RESUME_PROMPT_FULL = """Tailor this resume for the job. Output ONLY the JSON object, no other text.
|
||||
|
||||
{critical_truthfulness_rules}
|
||||
|
||||
IMPORTANT: Generate ALL text content (summary, descriptions, skills) in {output_language}.
|
||||
Do NOT include personalInfo in your output - it will be preserved from the original resume.
|
||||
|
||||
Rules:
|
||||
- Make targeted adjustments to bullet points to align with job description phrasing. Preserve the candidate's original details and voice - adjust wording, do not rewrite entirely.
|
||||
- DO NOT invent new information
|
||||
- Preserve existing action verbs. Do not invent quantifiable achievements not in the original.
|
||||
- Keep proper nouns (names, company names, locations) unchanged
|
||||
- Translate job titles, descriptions, and skills to {output_language}
|
||||
- For customSections: preserve exact structure, item count, titles, subtitles, and years. If an item's description is an empty array [] in the original, keep it empty []. Do NOT generate descriptions for items that had none.
|
||||
- Improve custom section content the same way as standard sections
|
||||
- Copy the "years" field values EXACTLY as they appear in the original resume (including any month prefixes like "Jan 2020 - Present"). Do not shorten, reformat, or drop months.
|
||||
- Calculate and emphasize total relevant experience duration when it matches requirements
|
||||
- Do NOT use em dash ("—") anywhere in the writing/output, even if it exists, remove it
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Keywords to emphasize:
|
||||
{job_keywords}
|
||||
|
||||
Original Resume:
|
||||
{original_resume}
|
||||
|
||||
Output in this JSON format:
|
||||
{schema}"""
|
||||
|
||||
IMPROVE_PROMPT_OPTIONS = [
|
||||
{
|
||||
"id": "nudge",
|
||||
"label": "Light nudge",
|
||||
"description": "Minimal edits to better align existing experience.",
|
||||
},
|
||||
{
|
||||
"id": "keywords",
|
||||
"label": "Keyword enhance",
|
||||
"description": "Blend in relevant keywords without changing role or scope.",
|
||||
},
|
||||
{
|
||||
"id": "full",
|
||||
"label": "Full tailor",
|
||||
"description": "Comprehensive tailoring using the job description.",
|
||||
},
|
||||
]
|
||||
|
||||
IMPROVE_RESUME_PROMPTS = {
|
||||
"nudge": IMPROVE_RESUME_PROMPT_NUDGE,
|
||||
"keywords": IMPROVE_RESUME_PROMPT_KEYWORDS,
|
||||
"full": IMPROVE_RESUME_PROMPT_FULL,
|
||||
}
|
||||
|
||||
DEFAULT_IMPROVE_PROMPT_ID = "keywords"
|
||||
|
||||
# Backward-compatible alias
|
||||
IMPROVE_RESUME_PROMPT = IMPROVE_RESUME_PROMPT_FULL
|
||||
|
||||
COVER_LETTER_PROMPT = """Write a brief cover letter for this job application.
|
||||
|
||||
IMPORTANT: Write in {output_language}.
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Candidate Resume (JSON):
|
||||
{resume_data}
|
||||
|
||||
Requirements:
|
||||
- 100-150 words maximum
|
||||
- 3-4 short paragraphs
|
||||
- Opening: Reference ONE specific thing from the job description (product, tech stack, or problem they're solving) - not generic excitement about "the role"
|
||||
- Middle: Pick 1-2 qualifications from resume that DIRECTLY match stated requirements, and reframe them in the job's language/terminology where the candidate's proven experience supports it (e.g., if the resume shows "built automated data pipelines" and the job says "ETL," describe that real work as ETL) - prioritize relevance over impressiveness
|
||||
- Closing: Simple availability to discuss, no desperate enthusiasm
|
||||
- If resume shows career transition, frame the pivot as intentional and relevant
|
||||
- Extract company name from job description - do not use placeholders
|
||||
- Do NOT invent information not in the resume
|
||||
- Tone: Confident peer, not eager applicant
|
||||
- Do NOT use em dash ("—") anywhere in the writing/output, even if it exists, remove it
|
||||
|
||||
Output plain text only. No JSON, no markdown formatting."""
|
||||
|
||||
OUTREACH_MESSAGE_PROMPT = """Generate a cold outreach message for LinkedIn or email about this job opportunity.
|
||||
|
||||
IMPORTANT: Write in {output_language}.
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Candidate Resume (JSON):
|
||||
{resume_data}
|
||||
|
||||
Guidelines:
|
||||
- 70-100 words maximum (shorter than a cover letter)
|
||||
- First sentence: Reference specific detail from job description (team, product, technical challenge) - never open with "I'm reaching out" or "I saw your posting"
|
||||
- One sentence on strongest matching qualification with a concrete metric if available
|
||||
- End with low-friction ask: "Worth a quick chat?" not "I'd love the opportunity to discuss"
|
||||
- Tone: How you'd message a former colleague, not a stranger
|
||||
- Do NOT include placeholder brackets
|
||||
- Do NOT use phrases like "excited about" or "passionate about"
|
||||
- Do NOT use em dash ("—") anywhere in the writing/output, even if it exists, remove it
|
||||
|
||||
Output plain text only. No JSON, no markdown formatting."""
|
||||
|
||||
INTERVIEW_PREP_PROMPT = """Generate structured interview preparation for this tailored resume and job.
|
||||
|
||||
IMPORTANT: Write in {output_language}.
|
||||
Do NOT translate JSON property names. Keep every JSON key exactly as shown in the schema; translate only string values.
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Candidate Resume (JSON):
|
||||
{resume_data}
|
||||
|
||||
Truthfulness guardrails:
|
||||
- Use only evidence from the resume JSON and job description.
|
||||
- Do NOT invent experience, tools, employers, metrics, certifications, skills, responsibilities, education, projects, or claims beyond the provided evidence.
|
||||
- Do NOT imply the candidate has a skill or background unless it is present in the resume.
|
||||
- Skill gaps are preparation targets only. They are not claimed candidate skills.
|
||||
- If a job requirement is not evidenced by the resume, present it as something to prepare for or explain honestly.
|
||||
|
||||
Return ONLY a valid JSON object with exactly these top-level keys:
|
||||
{{
|
||||
"role_fit_analysis": ["Short evidence-based role-fit observation"],
|
||||
"resume_questions": [
|
||||
{{
|
||||
"question": "Interview question grounded in the resume and job",
|
||||
"focus_area": "Resume evidence or job requirement being tested",
|
||||
"suggested_answer_points": ["Truthful point based on resume evidence"]
|
||||
}}
|
||||
],
|
||||
"project_follow_ups": [
|
||||
{{
|
||||
"question": "Follow-up question about a real resume project or experience",
|
||||
"focus_area": "Project, impact, tradeoff, or implementation detail",
|
||||
"suggested_answer_points": ["Truthful point based on resume evidence"]
|
||||
}}
|
||||
],
|
||||
"skill_gaps": [
|
||||
{{
|
||||
"skill": "Job-relevant skill or topic to prepare",
|
||||
"why_it_matters": "Why this topic may come up for this role",
|
||||
"preparation_suggestion": "How to prepare without claiming unsupported experience"
|
||||
}}
|
||||
],
|
||||
"talking_points": ["Concise role-specific talking point grounded in the resume"]
|
||||
}}
|
||||
|
||||
Content requirements:
|
||||
- role_fit_analysis: 3-5 bullets.
|
||||
- resume_questions: 5-8 questions.
|
||||
- project_follow_ups: 3-6 questions.
|
||||
- skill_gaps: 3-5 preparation targets.
|
||||
- talking_points: 5-8 concise points.
|
||||
- Keep all suggested answer points factual and resume-grounded.
|
||||
- Do NOT use markdown fences or commentary outside the JSON."""
|
||||
|
||||
GENERATE_TITLE_PROMPT = """Extract the job title and company name from this job description.
|
||||
|
||||
IMPORTANT: Write in {output_language}.
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Rules:
|
||||
- Format: "Role @ Company" (e.g., "Senior Frontend Engineer @ Stripe")
|
||||
- If the company name is not found, return just the role (e.g., "Senior Frontend Engineer")
|
||||
- Maximum 60 characters
|
||||
- Use the most specific role title mentioned
|
||||
- Do not add any other text, quotes, or formatting
|
||||
|
||||
Output the title only, nothing else."""
|
||||
|
||||
# Alias for backward compatibility
|
||||
RESUME_SCHEMA = RESUME_SCHEMA_EXAMPLE
|
||||
|
||||
# Diff-based improvement: outputs targeted changes instead of full resume
|
||||
|
||||
DIFF_STRATEGY_INSTRUCTIONS = {
|
||||
"nudge": "Make minimal edits. Only rephrase where there is a clear match. Do not add new bullet points.",
|
||||
"keywords": "Weave in relevant keywords where evidence already exists. You may rephrase bullets but do not add new ones.",
|
||||
"full": "Make targeted adjustments. You may rephrase bullets, add verified JD skills, and add new bullets that elaborate on existing work, but do not invent new responsibilities.",
|
||||
}
|
||||
|
||||
SKILL_TARGET_PLAN_PROMPT = """Build a concise skill target plan for tailoring this resume to the job.
|
||||
|
||||
Return ONLY a JSON object. Do not rewrite the resume.
|
||||
|
||||
Rules:
|
||||
1. Prefer required and preferred JD skills.
|
||||
2. Include existing resume skills that are highly relevant to the JD.
|
||||
3. You may include JD skills that are missing from the resume skills list.
|
||||
4. Do not include skills unrelated to the JD.
|
||||
5. Do not include certifications.
|
||||
6. Generate reasons in {output_language}.
|
||||
|
||||
Existing resume skills:
|
||||
{existing_skills}
|
||||
|
||||
JD keywords and skills:
|
||||
{job_keywords}
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Resume JSON:
|
||||
{original_resume}
|
||||
|
||||
Output this exact JSON format:
|
||||
{{
|
||||
"target_skills": [
|
||||
{{
|
||||
"skill": "skill name",
|
||||
"reason": "why this skill should be emphasized"
|
||||
}}
|
||||
],
|
||||
"strategy_notes": "brief notes for the next editing pass"
|
||||
}}"""
|
||||
|
||||
DIFF_IMPROVE_PROMPT = """Given this resume and job description, output a JSON object with targeted changes to better align the resume with the job.
|
||||
|
||||
RULES:
|
||||
1. Only modify content; never change names, companies, dates, institutions, or degrees
|
||||
2. Do not invent metrics or achievements not supported by the original resume text
|
||||
3. Do not add new work entries, education entries, or project entries
|
||||
4. {strategy_instruction}
|
||||
5. Each change MUST include the original text (copied exactly) so it can be verified
|
||||
6. For each change, explain WHY it helps match the job description
|
||||
7. Generate all new text in {output_language}
|
||||
8. Do not use em dash characters
|
||||
9. Keep changes minimal and targeted; do not rewrite content that already aligns well
|
||||
10. Exception to rule 2: you may add a skill only if it appears in the verified skill targets below
|
||||
11. By DEFAULT, scan the summary and every work, project, and education description for content that already demonstrates a job-description keyword or skill, and reframe that text using the job description's terminology where it is not already phrased that way (per rule 9, leave content that already aligns well), while preserving the candidate's actual accomplishment. Do NOT add new work, metrics, or responsibilities; only restate existing content in the JD's language, and verify every reframe stays factually accurate.
|
||||
12. Preserve original capitalization, especially for proper nouns, technical terms (e.g., REST, API, AWS), and acronyms. Do not change the casing of words that were capitalized in the original.
|
||||
|
||||
PATHS you can target:
|
||||
- "summary" — the resume summary text
|
||||
- "workExperience[i].description[j]" — a specific bullet (i = entry index, j = bullet index)
|
||||
- "workExperience[i].description" — append a new bullet (action: "append")
|
||||
- "personalProjects[i].description[j]" — a specific project bullet
|
||||
- "personalProjects[i].description" — append a new project bullet (action: "append")
|
||||
- "education[i].description" — the education entry's description text (replace only; it is a single string, not a list)
|
||||
- "additional.technicalSkills" — reorder the skills list (action: "reorder") or add one verified skill (action: "add_skill")
|
||||
- "additional.languages" — reorder the languages list (action: "reorder")
|
||||
- "additional.certificationsTraining" — reorder the certifications list (action: "reorder")
|
||||
- "additional.awards" — reorder the awards list (action: "reorder")
|
||||
|
||||
Do NOT target: personalInfo, dates/years, company names, education degree/institution/years, customSections.
|
||||
|
||||
Keywords to emphasize (only if already supported by resume content):
|
||||
{job_keywords}
|
||||
|
||||
Verified skill targets:
|
||||
{skill_targets}
|
||||
|
||||
Job Description:
|
||||
{job_description}
|
||||
|
||||
Original Resume:
|
||||
{original_resume}
|
||||
|
||||
Output this exact JSON format, nothing else:
|
||||
{{
|
||||
"changes": [
|
||||
{{
|
||||
"path": "workExperience[0].description[1]",
|
||||
"action": "replace",
|
||||
"original": "the exact original text at this path",
|
||||
"value": "the improved text",
|
||||
"reason": "why this change helps"
|
||||
}},
|
||||
{{
|
||||
"path": "summary",
|
||||
"action": "replace",
|
||||
"original": "the current summary text",
|
||||
"value": "the improved summary",
|
||||
"reason": "why this change helps"
|
||||
}},
|
||||
{{
|
||||
"path": "additional.technicalSkills",
|
||||
"action": "reorder",
|
||||
"original": null,
|
||||
"value": ["most relevant skill first", "then next", "..."],
|
||||
"reason": "reordered to prioritize JD-relevant skills"
|
||||
}},
|
||||
{{
|
||||
"path": "additional.technicalSkills",
|
||||
"action": "add_skill",
|
||||
"original": null,
|
||||
"value": "verified skill target missing from the skills list",
|
||||
"reason": "added verified JD skill for review"
|
||||
}}
|
||||
],
|
||||
"strategy_notes": "brief summary of the tailoring approach"
|
||||
}}"""
|
||||
@@ -0,0 +1,19 @@
|
||||
"""API routers."""
|
||||
|
||||
from app.routers.applications import router as applications_router
|
||||
from app.routers.config import router as config_router
|
||||
from app.routers.enrichment import router as enrichment_router
|
||||
from app.routers.health import router as health_router
|
||||
from app.routers.jobs import router as jobs_router
|
||||
from app.routers.resume_wizard import router as resume_wizard_router
|
||||
from app.routers.resumes import router as resumes_router
|
||||
|
||||
__all__ = [
|
||||
"resumes_router",
|
||||
"jobs_router",
|
||||
"config_router",
|
||||
"health_router",
|
||||
"enrichment_router",
|
||||
"applications_router",
|
||||
"resume_wizard_router",
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Kanban application-tracker endpoints."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.database import db
|
||||
from app.services.improver import extract_job_keywords
|
||||
from app.schemas import (
|
||||
APPLICATION_STATUS_ORDER,
|
||||
ApplicationActionResponse,
|
||||
ApplicationDetailResponse,
|
||||
ApplicationListResponse,
|
||||
ApplicationResponse,
|
||||
ApplicationUpdate,
|
||||
BulkDelete,
|
||||
BulkStatusUpdate,
|
||||
ManualApplicationCreate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/applications", tags=["Application Tracker"])
|
||||
|
||||
|
||||
def _group_by_status(applications: list[dict[str, Any]]) -> dict[str, list[ApplicationResponse]]:
|
||||
"""Group a flat list into the seven columns (all keys always present).
|
||||
|
||||
A row with an unknown status can't be represented by the enum-backed
|
||||
``ApplicationResponse``; rather than 500 the whole board we skip it (the
|
||||
board only renders the seven known columns) and log it.
|
||||
"""
|
||||
columns: dict[str, list[ApplicationResponse]] = {s: [] for s in APPLICATION_STATUS_ORDER}
|
||||
for app in applications:
|
||||
status = app.get("status")
|
||||
if status not in columns:
|
||||
logger.warning("Skipping application with unknown status %r", status)
|
||||
continue
|
||||
columns[status].append(ApplicationResponse(**app))
|
||||
return columns
|
||||
|
||||
|
||||
@router.get("", response_model=ApplicationListResponse)
|
||||
async def list_applications() -> ApplicationListResponse:
|
||||
"""List all applications grouped by status column."""
|
||||
try:
|
||||
applications = await db.list_applications()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list applications: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to load applications. Please try again.")
|
||||
return ApplicationListResponse(columns=_group_by_status(applications))
|
||||
|
||||
|
||||
@router.post("", response_model=ApplicationResponse)
|
||||
async def create_application(request: ManualApplicationCreate) -> ApplicationResponse:
|
||||
"""Manually add a card from a pasted job description.
|
||||
|
||||
Creates the job, runs a best-effort company/role extraction when not
|
||||
provided, then creates the application. If application creation fails the
|
||||
just-created job is cleaned up (no orphan jobs / retry drift); caching
|
||||
company/role on the job is best-effort and never fails the request.
|
||||
"""
|
||||
job = await db.create_job(content=request.job_description, resume_id=request.resume_id)
|
||||
|
||||
company = request.company
|
||||
role = request.role
|
||||
if not company or not role:
|
||||
extracted = await _extract_company_role(request.job_description)
|
||||
company = company or extracted.get("company")
|
||||
role = role or extracted.get("role")
|
||||
|
||||
try:
|
||||
application = await db.create_application(
|
||||
job_id=job["job_id"],
|
||||
resume_id=request.resume_id,
|
||||
status=request.status.value,
|
||||
company=company,
|
||||
role=role,
|
||||
notes=request.notes,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create application: %s", e)
|
||||
try:
|
||||
await db.delete_job(job["job_id"])
|
||||
except Exception as cleanup_error:
|
||||
logger.warning("Failed to clean up orphan job %s: %s", job["job_id"], cleanup_error)
|
||||
raise HTTPException(status_code=500, detail="Failed to create application. Please try again.")
|
||||
|
||||
# Best-effort: cache company/role on the job for later reuse — never 500.
|
||||
if company or role:
|
||||
try:
|
||||
await db.update_job(job["job_id"], {"company": company, "role": role})
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cache company/role on job %s: %s", job["job_id"], e)
|
||||
|
||||
return ApplicationResponse(**application)
|
||||
|
||||
|
||||
@router.get("/{application_id}", response_model=ApplicationDetailResponse)
|
||||
async def get_application_detail(application_id: str) -> ApplicationDetailResponse:
|
||||
"""Get a card with its embedded JD and applied resume (one round-trip).
|
||||
|
||||
Tolerates a deleted resume by returning ``resume: null`` rather than 500.
|
||||
"""
|
||||
application = await db.get_application(application_id)
|
||||
if application is None:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
job_content: str | None = None
|
||||
resume: dict[str, Any] | None = None
|
||||
try:
|
||||
job = await db.get_job(application["job_id"])
|
||||
if job:
|
||||
job_content = job.get("content")
|
||||
resume = await db.get_resume(application["resume_id"])
|
||||
except Exception as e:
|
||||
# Detail is best-effort beyond the card itself; never 500 the modal.
|
||||
logger.warning("Failed to load detail context for %s: %s", application_id, e)
|
||||
|
||||
return ApplicationDetailResponse(**application, job_content=job_content, resume=resume)
|
||||
|
||||
|
||||
@router.patch("/bulk", response_model=ApplicationActionResponse)
|
||||
async def bulk_update_applications(request: BulkStatusUpdate) -> ApplicationActionResponse:
|
||||
"""Move many cards to one column."""
|
||||
try:
|
||||
moved = await db.bulk_update_applications(request.application_ids, request.status.value)
|
||||
except Exception as e:
|
||||
logger.error("Failed to bulk-update applications: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to move applications. Please try again.")
|
||||
return ApplicationActionResponse(message=f"Moved {moved} application(s)", affected=moved)
|
||||
|
||||
|
||||
@router.patch("/{application_id}", response_model=ApplicationResponse)
|
||||
async def update_application(application_id: str, request: ApplicationUpdate) -> ApplicationResponse:
|
||||
"""Update a card (status/position/notes/company/role/applied_at)."""
|
||||
updates = request.model_dump(exclude_unset=True)
|
||||
# Normalize the enum to its stable string value for the data layer.
|
||||
if "status" in updates and updates["status"] is not None:
|
||||
updates["status"] = request.status.value
|
||||
try:
|
||||
updated = await db.update_application(application_id, updates)
|
||||
except Exception as e:
|
||||
logger.error("Failed to update application %s: %s", application_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to update application. Please try again.")
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
return ApplicationResponse(**updated)
|
||||
|
||||
|
||||
@router.delete("/{application_id}", response_model=ApplicationActionResponse)
|
||||
async def delete_application(application_id: str) -> ApplicationActionResponse:
|
||||
"""Delete a card."""
|
||||
try:
|
||||
deleted = await db.delete_application(application_id)
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete application %s: %s", application_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to delete application. Please try again.")
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
return ApplicationActionResponse(message="Application deleted", affected=1)
|
||||
|
||||
|
||||
@router.post("/bulk-delete", response_model=ApplicationActionResponse)
|
||||
async def bulk_delete_applications(request: BulkDelete) -> ApplicationActionResponse:
|
||||
"""Delete many cards."""
|
||||
try:
|
||||
deleted = await db.bulk_delete_applications(request.application_ids)
|
||||
except Exception as e:
|
||||
logger.error("Failed to bulk-delete applications: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to delete applications. Please try again.")
|
||||
return ApplicationActionResponse(message=f"Deleted {deleted} application(s)", affected=deleted)
|
||||
|
||||
|
||||
async def _extract_company_role(job_description: str) -> dict[str, str | None]:
|
||||
"""Best-effort company/role extraction for the manual-add path.
|
||||
|
||||
Reuses the cached keyword-extraction pass; falls back to blank (editable)
|
||||
on any failure so a flaky LLM never blocks card creation. LLM output isn't
|
||||
guaranteed to be a string, so values are type-guarded before ``.strip()``.
|
||||
"""
|
||||
try:
|
||||
keywords = await extract_job_keywords(job_description)
|
||||
raw_company = keywords.get("company")
|
||||
raw_role = keywords.get("role")
|
||||
return {
|
||||
"company": (raw_company.strip() if isinstance(raw_company, str) else "") or None,
|
||||
"role": (raw_role.strip() if isinstance(raw_role, str) else "") or None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Company/role extraction failed (manual add): %s", e)
|
||||
return {}
|
||||
@@ -0,0 +1,629 @@
|
||||
"""LLM configuration endpoints."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.llm import check_llm_health, LLMConfig, resolve_api_key
|
||||
from app.schemas import (
|
||||
LLMConfigRequest,
|
||||
LLMConfigResponse,
|
||||
FeatureConfigRequest,
|
||||
FeatureConfigResponse,
|
||||
FeaturePromptsRequest,
|
||||
FeaturePromptsResponse,
|
||||
LanguageConfigRequest,
|
||||
LanguageConfigResponse,
|
||||
PromptConfigRequest,
|
||||
PromptConfigResponse,
|
||||
PromptOption,
|
||||
ApiKeyProviderStatus,
|
||||
ApiKeyStatusResponse,
|
||||
ApiKeysUpdateRequest,
|
||||
ApiKeysUpdateResponse,
|
||||
ResetDatabaseRequest,
|
||||
)
|
||||
from app.prompts import (
|
||||
DEFAULT_IMPROVE_PROMPT_ID,
|
||||
IMPROVE_PROMPT_OPTIONS,
|
||||
validate_prompt_placeholders,
|
||||
)
|
||||
from app.prompts.templates import COVER_LETTER_PROMPT, OUTREACH_MESSAGE_PROMPT
|
||||
from app.config import (
|
||||
get_api_keys_from_config,
|
||||
save_api_keys_to_config,
|
||||
delete_api_key_from_config,
|
||||
clear_all_api_keys,
|
||||
load_config_file,
|
||||
save_config_file,
|
||||
)
|
||||
from app.config_cache import invalidate_config_cache
|
||||
from app.database import db
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["Configuration"])
|
||||
|
||||
|
||||
def _get_config_path() -> Path:
|
||||
"""Get path to config storage file."""
|
||||
return settings.config_path
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load config with decrypted API keys injected (so resolve_api_key works)."""
|
||||
return load_config_file()
|
||||
|
||||
|
||||
def _save_config(config: dict) -> None:
|
||||
"""Save non-secret config (keys stripped) and invalidate the shared cache."""
|
||||
save_config_file(config)
|
||||
invalidate_config_cache()
|
||||
|
||||
|
||||
def _mask_api_key(key: str) -> str:
|
||||
"""Mask API key for display."""
|
||||
if not key:
|
||||
return ""
|
||||
if len(key) <= 8:
|
||||
return "*" * len(key)
|
||||
return key[:4] + "*" * (len(key) - 8) + key[-4:]
|
||||
|
||||
|
||||
def _get_prompt_options() -> list[PromptOption]:
|
||||
"""Return available prompt options for resume tailoring."""
|
||||
return [PromptOption(**option) for option in IMPROVE_PROMPT_OPTIONS]
|
||||
|
||||
|
||||
async def _log_llm_health_check(config: LLMConfig) -> None:
|
||||
"""Run a best-effort health check and log outcome without affecting API responses."""
|
||||
try:
|
||||
health = await check_llm_health(config)
|
||||
if not health.get("healthy", False):
|
||||
logging.warning(
|
||||
"LLM config saved but health check failed",
|
||||
extra={"provider": config.provider, "model": config.model},
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"LLM config saved but health check raised exception",
|
||||
extra={"provider": config.provider, "model": config.model},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/llm-api-key", response_model=LLMConfigResponse)
|
||||
async def get_llm_config_endpoint() -> LLMConfigResponse:
|
||||
"""Get current LLM configuration (API key masked)."""
|
||||
stored = _load_config()
|
||||
|
||||
provider = stored.get("provider", settings.llm_provider)
|
||||
reasoning_effort = stored.get("reasoning_effort", settings.reasoning_effort)
|
||||
return LLMConfigResponse(
|
||||
provider=provider,
|
||||
model=stored.get("model", settings.llm_model),
|
||||
api_key=_mask_api_key(resolve_api_key(stored, provider)),
|
||||
api_base=stored.get("api_base", settings.llm_api_base),
|
||||
reasoning_effort=reasoning_effort or None,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/llm-api-key", response_model=LLMConfigResponse)
|
||||
async def update_llm_config(
|
||||
request: LLMConfigRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> LLMConfigResponse:
|
||||
"""Update LLM configuration.
|
||||
|
||||
Saves the configuration and returns it (API key masked).
|
||||
|
||||
Note: We intentionally do NOT hard-fail the update based on a live health check.
|
||||
Users may configure proxies/aggregators or temporarily unavailable endpoints and
|
||||
still need to persist the configuration. Connectivity can be verified via
|
||||
`/config/llm-test` and the System Status panel.
|
||||
"""
|
||||
stored = _load_config()
|
||||
|
||||
# Update only provided fields
|
||||
if request.provider is not None:
|
||||
stored["provider"] = request.provider
|
||||
if request.model is not None:
|
||||
stored["model"] = request.model
|
||||
# NOTE: API keys are NOT written here anymore. They live in the encrypted
|
||||
# per-provider store (PUT /config/api-keys). Writing the legacy single
|
||||
# ``api_key`` slot here is what caused providers to overwrite each other and
|
||||
# shadow the per-provider map in resolve_api_key. request.api_key is ignored
|
||||
# for persistence (kept in the schema only for response masking/back-compat).
|
||||
# api_base: distinguish "omitted" (leave unchanged) from "present but
|
||||
# blank/null" (explicit clear). The frontend sends api_base: null/"" when
|
||||
# the Base URL field is cleared; treating that as "don't change" left a
|
||||
# stale override in config.json (issue #760). Normalize blank → None so an
|
||||
# empty string also never reaches LiteLLM as a bogus endpoint.
|
||||
if "api_base" in request.model_fields_set:
|
||||
cleaned = (request.api_base or "").strip()
|
||||
stored["api_base"] = cleaned or None
|
||||
if request.reasoning_effort is not None:
|
||||
# Persist empty string on clear so the gpt-5 auto-migration doesn't
|
||||
# re-fire on next get_llm_config() call.
|
||||
stored["reasoning_effort"] = request.reasoning_effort
|
||||
|
||||
# Build normalized config for response and background health check
|
||||
resolved_provider = stored.get("provider", settings.llm_provider)
|
||||
raw_re = stored.get("reasoning_effort", settings.reasoning_effort)
|
||||
resolved_reasoning_effort = raw_re if raw_re else None
|
||||
test_config = LLMConfig(
|
||||
provider=resolved_provider,
|
||||
model=stored.get("model", settings.llm_model),
|
||||
api_key=resolve_api_key(stored, resolved_provider),
|
||||
api_base=stored.get("api_base", settings.llm_api_base),
|
||||
reasoning_effort=resolved_reasoning_effort,
|
||||
)
|
||||
|
||||
# Save config regardless of health check outcome (see docstring).
|
||||
_save_config(stored)
|
||||
|
||||
# Best-effort health check for server-side logs/diagnostics (do not block response).
|
||||
background_tasks.add_task(_log_llm_health_check, test_config)
|
||||
|
||||
return LLMConfigResponse(
|
||||
provider=test_config.provider,
|
||||
model=test_config.model,
|
||||
api_key=_mask_api_key(test_config.api_key),
|
||||
api_base=test_config.api_base,
|
||||
reasoning_effort=test_config.reasoning_effort,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/llm-test")
|
||||
async def test_llm_connection(request: LLMConfigRequest | None = None) -> dict:
|
||||
"""Test LLM connection with provided or stored configuration.
|
||||
|
||||
If request body is provided, tests with those values (for pre-save testing).
|
||||
Otherwise, tests with the currently saved configuration.
|
||||
"""
|
||||
stored = _load_config()
|
||||
|
||||
# Build config: use request values if provided, otherwise fall back to stored/default
|
||||
test_provider = (
|
||||
request.provider
|
||||
if request and request.provider
|
||||
else stored.get("provider", settings.llm_provider)
|
||||
)
|
||||
config = LLMConfig(
|
||||
provider=test_provider,
|
||||
model=(
|
||||
request.model
|
||||
if request and request.model
|
||||
else stored.get("model", settings.llm_model)
|
||||
),
|
||||
api_key=(
|
||||
request.api_key
|
||||
if request and request.api_key
|
||||
else resolve_api_key(stored, test_provider)
|
||||
),
|
||||
api_base=(
|
||||
request.api_base
|
||||
if request and request.api_base is not None
|
||||
else stored.get("api_base", settings.llm_api_base)
|
||||
),
|
||||
reasoning_effort=(
|
||||
(request.reasoning_effort or None)
|
||||
if request and request.reasoning_effort is not None
|
||||
else (stored.get("reasoning_effort") or settings.reasoning_effort) or None
|
||||
),
|
||||
)
|
||||
|
||||
test_prompt = "Hi"
|
||||
return await check_llm_health(config, include_details=True, test_prompt=test_prompt)
|
||||
|
||||
|
||||
@router.get("/features", response_model=FeatureConfigResponse)
|
||||
async def get_feature_config() -> FeatureConfigResponse:
|
||||
"""Get current feature configuration."""
|
||||
stored = _load_config()
|
||||
|
||||
return FeatureConfigResponse(
|
||||
enable_cover_letter=stored.get("enable_cover_letter", False),
|
||||
enable_outreach_message=stored.get("enable_outreach_message", False),
|
||||
enable_interview_prep=stored.get("enable_interview_prep", False),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/features", response_model=FeatureConfigResponse)
|
||||
async def update_feature_config(request: FeatureConfigRequest) -> FeatureConfigResponse:
|
||||
"""Update feature configuration."""
|
||||
stored = _load_config()
|
||||
|
||||
# Update only provided fields
|
||||
if request.enable_cover_letter is not None:
|
||||
stored["enable_cover_letter"] = request.enable_cover_letter
|
||||
if request.enable_outreach_message is not None:
|
||||
stored["enable_outreach_message"] = request.enable_outreach_message
|
||||
if request.enable_interview_prep is not None:
|
||||
stored["enable_interview_prep"] = request.enable_interview_prep
|
||||
|
||||
# Save config
|
||||
_save_config(stored)
|
||||
|
||||
return FeatureConfigResponse(
|
||||
enable_cover_letter=stored.get("enable_cover_letter", False),
|
||||
enable_outreach_message=stored.get("enable_outreach_message", False),
|
||||
enable_interview_prep=stored.get("enable_interview_prep", False),
|
||||
)
|
||||
|
||||
|
||||
# Supported languages for i18n
|
||||
SUPPORTED_LANGUAGES = ["en", "es", "zh", "ja", "pt", "fr"]
|
||||
|
||||
|
||||
@router.get("/language", response_model=LanguageConfigResponse)
|
||||
async def get_language_config() -> LanguageConfigResponse:
|
||||
"""Get current language configuration."""
|
||||
stored = _load_config()
|
||||
|
||||
# Support legacy single 'language' field migration
|
||||
legacy_language = stored.get("language", "en")
|
||||
|
||||
return LanguageConfigResponse(
|
||||
ui_language=stored.get("ui_language", legacy_language),
|
||||
content_language=stored.get("content_language", legacy_language),
|
||||
supported_languages=SUPPORTED_LANGUAGES,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/language", response_model=LanguageConfigResponse)
|
||||
async def update_language_config(
|
||||
request: LanguageConfigRequest,
|
||||
) -> LanguageConfigResponse:
|
||||
"""Update language configuration."""
|
||||
stored = _load_config()
|
||||
|
||||
# Validate and update UI language
|
||||
if request.ui_language is not None:
|
||||
if request.ui_language not in SUPPORTED_LANGUAGES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported UI language: {request.ui_language}. Supported: {SUPPORTED_LANGUAGES}",
|
||||
)
|
||||
stored["ui_language"] = request.ui_language
|
||||
|
||||
# Validate and update content language
|
||||
if request.content_language is not None:
|
||||
if request.content_language not in SUPPORTED_LANGUAGES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported content language: {request.content_language}. Supported: {SUPPORTED_LANGUAGES}",
|
||||
)
|
||||
stored["content_language"] = request.content_language
|
||||
|
||||
# Save config
|
||||
_save_config(stored)
|
||||
|
||||
# Support legacy single 'language' field migration
|
||||
legacy_language = stored.get("language", "en")
|
||||
|
||||
return LanguageConfigResponse(
|
||||
ui_language=stored.get("ui_language", legacy_language),
|
||||
content_language=stored.get("content_language", legacy_language),
|
||||
supported_languages=SUPPORTED_LANGUAGES,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prompts", response_model=PromptConfigResponse)
|
||||
async def get_prompt_config() -> PromptConfigResponse:
|
||||
"""Get current prompt configuration for resume tailoring."""
|
||||
stored = _load_config()
|
||||
options = _get_prompt_options()
|
||||
option_ids = {option.id for option in options}
|
||||
default_prompt_id = stored.get("default_prompt_id", DEFAULT_IMPROVE_PROMPT_ID)
|
||||
if default_prompt_id not in option_ids:
|
||||
default_prompt_id = DEFAULT_IMPROVE_PROMPT_ID
|
||||
|
||||
return PromptConfigResponse(
|
||||
default_prompt_id=default_prompt_id,
|
||||
prompt_options=options,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/prompts", response_model=PromptConfigResponse)
|
||||
async def update_prompt_config(
|
||||
request: PromptConfigRequest,
|
||||
) -> PromptConfigResponse:
|
||||
"""Update prompt configuration for resume tailoring."""
|
||||
stored = _load_config()
|
||||
options = _get_prompt_options()
|
||||
option_ids = {option.id for option in options}
|
||||
|
||||
if request.default_prompt_id is not None:
|
||||
if request.default_prompt_id not in option_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Unsupported prompt id: "
|
||||
f"{request.default_prompt_id}. Supported: {sorted(option_ids)}"
|
||||
),
|
||||
)
|
||||
stored["default_prompt_id"] = request.default_prompt_id
|
||||
|
||||
_save_config(stored)
|
||||
|
||||
default_prompt_id = stored.get("default_prompt_id", DEFAULT_IMPROVE_PROMPT_ID)
|
||||
if default_prompt_id not in option_ids:
|
||||
default_prompt_id = DEFAULT_IMPROVE_PROMPT_ID
|
||||
|
||||
return PromptConfigResponse(
|
||||
default_prompt_id=default_prompt_id,
|
||||
prompt_options=options,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feature-prompts", response_model=FeaturePromptsResponse)
|
||||
async def get_feature_prompts() -> FeaturePromptsResponse:
|
||||
"""Get custom feature prompts (cover letter, outreach message).
|
||||
|
||||
Empty strings mean "use default". The ``*_default`` fields expose the
|
||||
built-in prompts so the UI can show them as placeholder text without
|
||||
duplicating the content client-side.
|
||||
"""
|
||||
stored = _load_config()
|
||||
return FeaturePromptsResponse(
|
||||
cover_letter_prompt=stored.get("cover_letter_prompt", "") or "",
|
||||
outreach_message_prompt=stored.get("outreach_message_prompt", "") or "",
|
||||
cover_letter_default=COVER_LETTER_PROMPT,
|
||||
outreach_message_default=OUTREACH_MESSAGE_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/feature-prompts", response_model=FeaturePromptsResponse)
|
||||
async def update_feature_prompts(
|
||||
request: FeaturePromptsRequest,
|
||||
) -> FeaturePromptsResponse:
|
||||
"""Update custom feature prompts.
|
||||
|
||||
Non-empty prompts are validated for the three required placeholders
|
||||
(``{job_description}``, ``{resume_data}``, ``{output_language}``).
|
||||
Missing placeholders return a 422 with a structured detail so the UI
|
||||
can list exactly which ones are absent. Empty strings clear the
|
||||
override — persisted as ``""`` so runtime resolution falls back to the
|
||||
built-in default.
|
||||
"""
|
||||
stored = _load_config()
|
||||
|
||||
if request.cover_letter_prompt is not None:
|
||||
prompt = request.cover_letter_prompt.strip()
|
||||
if prompt:
|
||||
missing = validate_prompt_placeholders(prompt)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "missing_placeholders",
|
||||
"field": "cover_letter_prompt",
|
||||
"missing": missing,
|
||||
},
|
||||
)
|
||||
stored["cover_letter_prompt"] = prompt
|
||||
|
||||
if request.outreach_message_prompt is not None:
|
||||
prompt = request.outreach_message_prompt.strip()
|
||||
if prompt:
|
||||
missing = validate_prompt_placeholders(prompt)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "missing_placeholders",
|
||||
"field": "outreach_message_prompt",
|
||||
"missing": missing,
|
||||
},
|
||||
)
|
||||
stored["outreach_message_prompt"] = prompt
|
||||
|
||||
_save_config(stored)
|
||||
|
||||
return FeaturePromptsResponse(
|
||||
cover_letter_prompt=stored.get("cover_letter_prompt", "") or "",
|
||||
outreach_message_prompt=stored.get("outreach_message_prompt", "") or "",
|
||||
cover_letter_default=COVER_LETTER_PROMPT,
|
||||
outreach_message_default=OUTREACH_MESSAGE_PROMPT,
|
||||
)
|
||||
|
||||
|
||||
# Supported API key providers (key-store names). ``openai_compatible`` and
|
||||
# ``ollama`` are included so secured local servers can store a key; they keep
|
||||
# their env-fallback skip in resolve_api_key.
|
||||
SUPPORTED_PROVIDERS = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"openrouter",
|
||||
"deepseek",
|
||||
"groq",
|
||||
"openai_compatible",
|
||||
"ollama",
|
||||
]
|
||||
|
||||
|
||||
def _mask_key_short(key: str | None) -> str | None:
|
||||
"""Mask API key showing only last 4 characters."""
|
||||
if not key:
|
||||
return None
|
||||
if len(key) <= 4:
|
||||
return "*" * len(key)
|
||||
return "..." + key[-4:]
|
||||
|
||||
|
||||
@router.get("/api-keys", response_model=ApiKeyStatusResponse)
|
||||
async def get_api_keys_status() -> ApiKeyStatusResponse:
|
||||
"""Get status of all configured API keys (masked).
|
||||
|
||||
Returns the configuration status for each supported provider.
|
||||
API keys are masked to show only the last 4 characters.
|
||||
"""
|
||||
stored_keys = get_api_keys_from_config()
|
||||
|
||||
providers = []
|
||||
for provider in SUPPORTED_PROVIDERS:
|
||||
key = stored_keys.get(provider)
|
||||
providers.append(
|
||||
ApiKeyProviderStatus(
|
||||
provider=provider,
|
||||
configured=bool(key),
|
||||
masked_key=_mask_key_short(key),
|
||||
)
|
||||
)
|
||||
|
||||
return ApiKeyStatusResponse(providers=providers)
|
||||
|
||||
|
||||
@router.post("/api-keys", response_model=ApiKeysUpdateResponse)
|
||||
async def update_api_keys(request: ApiKeysUpdateRequest) -> ApiKeysUpdateResponse:
|
||||
"""Update API keys for one or more providers.
|
||||
|
||||
Only updates the providers that are explicitly set in the request.
|
||||
Empty strings will clear the key for that provider.
|
||||
"""
|
||||
stored_keys = get_api_keys_from_config()
|
||||
updated = []
|
||||
|
||||
# Update each provider if provided in request
|
||||
if request.openai is not None:
|
||||
if request.openai:
|
||||
stored_keys["openai"] = request.openai
|
||||
elif "openai" in stored_keys:
|
||||
del stored_keys["openai"]
|
||||
updated.append("openai")
|
||||
|
||||
if request.anthropic is not None:
|
||||
if request.anthropic:
|
||||
stored_keys["anthropic"] = request.anthropic
|
||||
elif "anthropic" in stored_keys:
|
||||
del stored_keys["anthropic"]
|
||||
updated.append("anthropic")
|
||||
|
||||
if request.google is not None:
|
||||
if request.google:
|
||||
stored_keys["google"] = request.google
|
||||
elif "google" in stored_keys:
|
||||
del stored_keys["google"]
|
||||
updated.append("google")
|
||||
|
||||
if request.openrouter is not None:
|
||||
if request.openrouter:
|
||||
stored_keys["openrouter"] = request.openrouter
|
||||
elif "openrouter" in stored_keys:
|
||||
del stored_keys["openrouter"]
|
||||
updated.append("openrouter")
|
||||
|
||||
if request.deepseek is not None:
|
||||
if request.deepseek:
|
||||
stored_keys["deepseek"] = request.deepseek
|
||||
elif "deepseek" in stored_keys:
|
||||
del stored_keys["deepseek"]
|
||||
updated.append("deepseek")
|
||||
|
||||
if request.groq is not None:
|
||||
if request.groq:
|
||||
stored_keys["groq"] = request.groq
|
||||
elif "groq" in stored_keys:
|
||||
del stored_keys["groq"]
|
||||
updated.append("groq")
|
||||
|
||||
if request.openai_compatible is not None:
|
||||
if request.openai_compatible:
|
||||
stored_keys["openai_compatible"] = request.openai_compatible
|
||||
elif "openai_compatible" in stored_keys:
|
||||
del stored_keys["openai_compatible"]
|
||||
updated.append("openai_compatible")
|
||||
|
||||
if request.ollama is not None:
|
||||
if request.ollama:
|
||||
stored_keys["ollama"] = request.ollama
|
||||
elif "ollama" in stored_keys:
|
||||
del stored_keys["ollama"]
|
||||
updated.append("ollama")
|
||||
|
||||
save_api_keys_to_config(stored_keys)
|
||||
invalidate_config_cache()
|
||||
|
||||
return ApiKeysUpdateResponse(
|
||||
message=f"Updated {len(updated)} API key(s)",
|
||||
updated_providers=updated,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api-keys")
|
||||
async def delete_all_api_keys(confirm: str | None = None) -> dict:
|
||||
"""Clear all configured API keys.
|
||||
|
||||
This is a destructive operation. Requires confirmation token.
|
||||
|
||||
Args:
|
||||
confirm: Must be "CLEAR_ALL_KEYS" to execute
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Note:
|
||||
This is a local-only endpoint for single-user deployments.
|
||||
In production/multi-user scenarios, add proper authentication.
|
||||
"""
|
||||
if confirm != "CLEAR_ALL_KEYS":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Confirmation required. Pass confirm=CLEAR_ALL_KEYS query parameter.",
|
||||
)
|
||||
clear_all_api_keys()
|
||||
invalidate_config_cache()
|
||||
return {"message": "All API keys have been cleared"}
|
||||
|
||||
|
||||
@router.delete("/api-keys/{provider}")
|
||||
async def delete_api_key(provider: str) -> dict:
|
||||
"""Delete API key for a specific provider.
|
||||
|
||||
Args:
|
||||
provider: The provider name (openai, anthropic, google, openrouter, deepseek)
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
if provider not in SUPPORTED_PROVIDERS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported provider: {provider}. Supported: {SUPPORTED_PROVIDERS}",
|
||||
)
|
||||
|
||||
delete_api_key_from_config(provider)
|
||||
invalidate_config_cache()
|
||||
|
||||
return {"message": f"API key for {provider} has been removed"}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_database_endpoint(request: ResetDatabaseRequest) -> dict:
|
||||
"""Reset the database and clear all data.
|
||||
|
||||
WARNING: This action is irreversible. It will:
|
||||
1. Truncate all database tables (resumes, jobs, improvements)
|
||||
2. Delete all uploaded files
|
||||
|
||||
Requires confirmation token for safety.
|
||||
|
||||
Args:
|
||||
request: Request body containing confirmation token
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Note:
|
||||
This is a local-only endpoint for single-user deployments.
|
||||
In production/multi-user scenarios, add proper authentication.
|
||||
"""
|
||||
if request.confirm != "RESET_ALL_DATA":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Confirmation required. Pass confirm=RESET_ALL_DATA in request body.",
|
||||
)
|
||||
await db.reset_database()
|
||||
return {"message": "Database and all data have been reset successfully"}
|
||||
@@ -0,0 +1,809 @@
|
||||
"""AI-powered resume enrichment endpoints."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.config_cache import get_content_language
|
||||
from app.database import db
|
||||
from app.llm import complete_json
|
||||
from app.prompts.enrichment import (
|
||||
ANALYZE_RESUME_PROMPT,
|
||||
ENHANCE_DESCRIPTION_PROMPT,
|
||||
REGENERATE_ITEM_PROMPT,
|
||||
REGENERATE_SKILLS_PROMPT,
|
||||
)
|
||||
from app.prompts.templates import get_language_name
|
||||
from app.schemas.enrichment import (
|
||||
AnalysisResponse,
|
||||
AnswerInput,
|
||||
ApplyEnhancementsRequest,
|
||||
EnhancedDescription,
|
||||
EnhanceRequest,
|
||||
EnhancementPreview,
|
||||
EnrichmentItem,
|
||||
EnrichmentQuestion,
|
||||
RegenerateItemError,
|
||||
RegenerateItemInput,
|
||||
RegenerateRequest,
|
||||
RegenerateResponse,
|
||||
RegeneratedItem,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/enrichment", tags=["Enrichment"])
|
||||
|
||||
|
||||
def _extract_item_from_resume(processed_data: dict, item_id: str) -> dict:
|
||||
"""Derive item details from resume data using the item_id pattern.
|
||||
|
||||
Avoids a redundant LLM analysis call when the frontend already knows
|
||||
which item each answer belongs to.
|
||||
"""
|
||||
try:
|
||||
prefix, idx_str = item_id.split("_", 1)
|
||||
index = int(idx_str)
|
||||
except (ValueError, AttributeError):
|
||||
return {}
|
||||
|
||||
if index < 0:
|
||||
return {}
|
||||
|
||||
if prefix == "exp":
|
||||
entries = processed_data.get("workExperience", [])
|
||||
if not isinstance(entries, list) or index >= len(entries):
|
||||
return {}
|
||||
entry = entries[index]
|
||||
desc = entry.get("description", [])
|
||||
return {
|
||||
"item_id": item_id,
|
||||
"item_type": "experience",
|
||||
"title": entry.get("title", ""),
|
||||
"subtitle": entry.get("company", ""),
|
||||
"current_description": desc if isinstance(desc, list) else [desc] if isinstance(desc, str) and desc else [],
|
||||
}
|
||||
elif prefix == "proj":
|
||||
entries = processed_data.get("personalProjects", [])
|
||||
if not isinstance(entries, list) or index >= len(entries):
|
||||
return {}
|
||||
entry = entries[index]
|
||||
desc = entry.get("description", [])
|
||||
return {
|
||||
"item_id": item_id,
|
||||
"item_type": "project",
|
||||
"title": entry.get("name", ""),
|
||||
"subtitle": entry.get("role", ""),
|
||||
"current_description": desc if isinstance(desc, list) else [desc] if isinstance(desc, str) and desc else [],
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/analyze/{resume_id}", response_model=AnalysisResponse)
|
||||
async def analyze_resume(resume_id: str) -> AnalysisResponse:
|
||||
"""Analyze a resume to identify items that need enrichment.
|
||||
|
||||
Uses AI to examine Experience and Projects sections for weak,
|
||||
vague, or incomplete descriptions and generates clarifying questions.
|
||||
"""
|
||||
# Fetch resume
|
||||
resume = await db.get_resume(resume_id)
|
||||
if not resume:
|
||||
raise HTTPException(status_code=404, detail="Resume not found")
|
||||
|
||||
# Get processed data
|
||||
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.",
|
||||
)
|
||||
|
||||
# Build prompt with content language
|
||||
resume_json = json.dumps(processed_data)
|
||||
language = get_content_language()
|
||||
output_language = get_language_name(language)
|
||||
prompt = ANALYZE_RESUME_PROMPT.format(
|
||||
resume_json=resume_json,
|
||||
output_language=output_language
|
||||
)
|
||||
|
||||
try:
|
||||
# Call LLM with increased max_tokens for non-English languages
|
||||
result = await asyncio.wait_for(
|
||||
complete_json(prompt, max_tokens=8192, schema_type="enrichment"),
|
||||
timeout=180.0, # 3-minute hard limit
|
||||
)
|
||||
|
||||
# Parse response into schema objects
|
||||
items_to_enrich = [
|
||||
EnrichmentItem(
|
||||
item_id=item.get("item_id", f"item_{i}"),
|
||||
item_type=item.get("item_type", "experience"),
|
||||
title=item.get("title", ""),
|
||||
subtitle=item.get("subtitle"),
|
||||
current_description=item.get("current_description", []),
|
||||
weakness_reason=item.get("weakness_reason", ""),
|
||||
)
|
||||
for i, item in enumerate(result.get("items_to_enrich", []))
|
||||
]
|
||||
|
||||
questions = [
|
||||
EnrichmentQuestion(
|
||||
question_id=q.get("question_id", f"q_{i}"),
|
||||
item_id=q.get("item_id", ""),
|
||||
question=q.get("question", ""),
|
||||
placeholder=q.get("placeholder", ""),
|
||||
)
|
||||
for i, q in enumerate(result.get("questions", []))
|
||||
]
|
||||
|
||||
return AnalysisResponse(
|
||||
items_to_enrich=items_to_enrich,
|
||||
questions=questions,
|
||||
analysis_summary=result.get("analysis_summary"),
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Resume analysis timed out for resume %s", resume_id)
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="Resume analysis timed out. Please try again with a shorter resume or a faster model.",
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Resume analysis failed (content): %s", e)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="The AI returned an unreadable response. Please try again or switch models.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Resume analysis failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to analyze resume. Please try again.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/enhance", response_model=EnhancementPreview)
|
||||
async def generate_enhancements(request: EnhanceRequest) -> EnhancementPreview:
|
||||
"""Generate enhanced descriptions from user answers.
|
||||
|
||||
Takes the answers to clarifying questions and uses AI to generate
|
||||
improved description bullets for each item.
|
||||
"""
|
||||
# Fetch resume
|
||||
resume = await db.get_resume(request.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.",
|
||||
)
|
||||
|
||||
# Group answers by item_id.
|
||||
# When all answers carry item_id (from the analysis step), we can skip
|
||||
# the expensive re-analysis LLM call and derive item details from the
|
||||
# resume's processed_data directly.
|
||||
answers_by_item: dict[str, list[AnswerInput]] = {}
|
||||
item_details: dict[str, dict] = {}
|
||||
# question_id → question dict, populated only in the legacy path
|
||||
questions_by_id: dict[str, dict] = {}
|
||||
|
||||
if all(a.item_id for a in request.answers) and all(
|
||||
_extract_item_from_resume(processed_data, a.item_id or "")
|
||||
for a in request.answers
|
||||
):
|
||||
# Fast path — no re-analysis needed
|
||||
for answer in request.answers:
|
||||
item_id = answer.item_id or ""
|
||||
answers_by_item.setdefault(item_id, []).append(answer)
|
||||
if item_id not in item_details:
|
||||
item_details[item_id] = _extract_item_from_resume(
|
||||
processed_data, item_id
|
||||
)
|
||||
else:
|
||||
# Legacy path — re-analyze to get question-to-item mapping
|
||||
resume_json = json.dumps(processed_data)
|
||||
language = get_content_language()
|
||||
output_language = get_language_name(language)
|
||||
analysis_prompt = ANALYZE_RESUME_PROMPT.format(
|
||||
resume_json=resume_json,
|
||||
output_language=output_language,
|
||||
)
|
||||
|
||||
try:
|
||||
analysis_result = await asyncio.wait_for(
|
||||
complete_json(analysis_prompt, max_tokens=8192, schema_type="enrichment"),
|
||||
timeout=180.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Resume re-analysis timed out for resume %s", request.resume_id)
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="Resume analysis timed out. Please try again with a shorter resume or a faster model.",
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Resume re-analysis failed (content): %s", e)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="The AI returned an unreadable response. Please try again or switch models.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to re-analyze resume: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to process enhancements. Please try again.",
|
||||
)
|
||||
|
||||
question_to_item: dict[str, str] = {}
|
||||
for q in analysis_result.get("questions", []):
|
||||
qid = q.get("question_id", "")
|
||||
question_to_item[qid] = q.get("item_id", "")
|
||||
questions_by_id[qid] = q
|
||||
|
||||
for item in analysis_result.get("items_to_enrich", []):
|
||||
item_id = item.get("item_id", "")
|
||||
item_details[item_id] = item
|
||||
|
||||
for answer in request.answers:
|
||||
item_id = question_to_item.get(answer.question_id, "")
|
||||
if item_id:
|
||||
answers_by_item.setdefault(item_id, []).append(answer)
|
||||
|
||||
# Generate enhanced descriptions for each item
|
||||
enhancements: list[EnhancedDescription] = []
|
||||
|
||||
for item_id, answers in answers_by_item.items():
|
||||
item = item_details.get(item_id, {})
|
||||
if not item:
|
||||
continue
|
||||
|
||||
# Format answers with their questions for context.
|
||||
# In the fast path questions_by_id is empty, so fall back to
|
||||
# question_text carried on the AnswerInput itself.
|
||||
answers_text = ""
|
||||
for answer in answers:
|
||||
matching_q = questions_by_id.get(answer.question_id)
|
||||
question = (
|
||||
matching_q.get("question", "") if matching_q else answer.question_text
|
||||
)
|
||||
if question:
|
||||
answers_text += f"Q: {question}\n"
|
||||
answers_text += f"A: {answer.answer}\n\n"
|
||||
else:
|
||||
answers_text += f"Additional info: {answer.answer}\n\n"
|
||||
|
||||
# Build enhancement prompt with content language
|
||||
current_desc = item.get("current_description", [])
|
||||
current_desc_text = "\n".join(f"- {d}" for d in current_desc) if current_desc else "(No description)"
|
||||
|
||||
language = get_content_language()
|
||||
output_language = get_language_name(language)
|
||||
|
||||
prompt = ENHANCE_DESCRIPTION_PROMPT.format(
|
||||
item_type=item.get("item_type", "experience"),
|
||||
title=item.get("title", ""),
|
||||
subtitle=item.get("subtitle", ""),
|
||||
current_description=current_desc_text,
|
||||
answers=answers_text.strip(),
|
||||
output_language=output_language,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await complete_json(prompt, schema_type="diff")
|
||||
# Get additional bullets from LLM (new key name)
|
||||
additional_bullets = result.get("additional_bullets", [])
|
||||
# Fallback to old key for backwards compatibility
|
||||
if not additional_bullets:
|
||||
additional_bullets = result.get("enhanced_description", [])
|
||||
# Guard against non-list returns from LLM
|
||||
if not isinstance(additional_bullets, list):
|
||||
additional_bullets = []
|
||||
additional_bullets = [str(b) for b in additional_bullets if b]
|
||||
|
||||
enhancements.append(
|
||||
EnhancedDescription(
|
||||
item_id=item_id,
|
||||
item_type=item.get("item_type", "experience"),
|
||||
title=item.get("title", ""),
|
||||
original_description=current_desc,
|
||||
enhanced_description=additional_bullets, # These are NEW bullets to add
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to enhance item {item_id}: {e}")
|
||||
# Continue with other items
|
||||
|
||||
return EnhancementPreview(enhancements=enhancements)
|
||||
|
||||
|
||||
@router.post("/apply/{resume_id}")
|
||||
async def apply_enhancements(
|
||||
resume_id: str, request: ApplyEnhancementsRequest
|
||||
) -> dict:
|
||||
"""Apply enhancements to the master resume.
|
||||
|
||||
Updates the resume's Experience and Projects sections with
|
||||
the enhanced descriptions.
|
||||
"""
|
||||
# Fetch resume
|
||||
resume = await 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.",
|
||||
)
|
||||
|
||||
# Make a copy to modify
|
||||
updated_data = copy.deepcopy(processed_data)
|
||||
|
||||
# Apply each enhancement by ADDING new bullets to existing description
|
||||
for enhancement in request.enhancements:
|
||||
item_id = enhancement.item_id
|
||||
item_type = enhancement.item_type
|
||||
additional_bullets = enhancement.enhanced_description # These are NEW bullets to add
|
||||
|
||||
if item_type == "experience":
|
||||
# Parse item_id like "exp_0" to get index
|
||||
try:
|
||||
index = int(item_id.split("_")[1])
|
||||
if "workExperience" in updated_data and index < len(updated_data["workExperience"]):
|
||||
# Get existing description and ADD new bullets
|
||||
existing_desc = updated_data["workExperience"][index].get("description", [])
|
||||
if isinstance(existing_desc, list):
|
||||
updated_data["workExperience"][index]["description"] = existing_desc + additional_bullets
|
||||
else:
|
||||
# Handle edge case where description might be a string
|
||||
updated_data["workExperience"][index]["description"] = [existing_desc] + additional_bullets if existing_desc else additional_bullets
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"Could not apply experience enhancement for {item_id}: {e}")
|
||||
|
||||
elif item_type == "project":
|
||||
# Parse item_id like "proj_0" to get index
|
||||
try:
|
||||
index = int(item_id.split("_")[1])
|
||||
if "personalProjects" in updated_data and index < len(updated_data["personalProjects"]):
|
||||
# Get existing description and ADD new bullets
|
||||
existing_desc = updated_data["personalProjects"][index].get("description", [])
|
||||
if isinstance(existing_desc, list):
|
||||
updated_data["personalProjects"][index]["description"] = existing_desc + additional_bullets
|
||||
else:
|
||||
# Handle edge case where description might be a string
|
||||
updated_data["personalProjects"][index]["description"] = [existing_desc] + additional_bullets if existing_desc else additional_bullets
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"Could not apply project enhancement for {item_id}: {e}")
|
||||
|
||||
# Update the resume in database
|
||||
updated_content = json.dumps(updated_data, indent=2)
|
||||
try:
|
||||
await db.update_resume(
|
||||
resume_id,
|
||||
{
|
||||
"content": updated_content,
|
||||
"processed_data": updated_data,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save enhancements to database: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to save enhancements. Please try again.",
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Enhancements applied successfully",
|
||||
"updated_items": len(request.enhancements),
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# AI Regenerate Feature Endpoints
|
||||
# ============================================
|
||||
|
||||
|
||||
async def _regenerate_experience_or_project(
|
||||
item: RegenerateItemInput,
|
||||
instruction: str,
|
||||
output_language: str,
|
||||
) -> RegeneratedItem:
|
||||
"""Regenerate a single experience or project item."""
|
||||
current_desc_text = (
|
||||
"\n".join(f"- {d}" for d in item.current_content)
|
||||
if item.current_content
|
||||
else "(No description)"
|
||||
)
|
||||
|
||||
prompt = REGENERATE_ITEM_PROMPT.format(
|
||||
output_language=output_language,
|
||||
item_type=item.item_type,
|
||||
title=item.title,
|
||||
subtitle=item.subtitle or "",
|
||||
current_description=current_desc_text,
|
||||
user_instruction=instruction,
|
||||
)
|
||||
|
||||
result = await complete_json(prompt, max_tokens=4096, schema_type="diff")
|
||||
|
||||
new_bullets = result.get("new_bullets", [])
|
||||
if not isinstance(new_bullets, list):
|
||||
new_bullets = []
|
||||
new_bullets = [str(b) for b in new_bullets if b]
|
||||
|
||||
return RegeneratedItem(
|
||||
item_id=item.item_id,
|
||||
item_type=item.item_type,
|
||||
title=item.title,
|
||||
subtitle=item.subtitle,
|
||||
original_content=item.current_content,
|
||||
new_content=new_bullets,
|
||||
diff_summary=str(result.get("change_summary") or ""),
|
||||
)
|
||||
|
||||
|
||||
async def _regenerate_skills(
|
||||
item: RegenerateItemInput,
|
||||
instruction: str,
|
||||
output_language: str,
|
||||
) -> RegeneratedItem:
|
||||
"""Regenerate the skills section."""
|
||||
current_skills_text = ", ".join(item.current_content) if item.current_content else "(No skills)"
|
||||
|
||||
prompt = REGENERATE_SKILLS_PROMPT.format(
|
||||
output_language=output_language,
|
||||
current_skills=current_skills_text,
|
||||
user_instruction=instruction,
|
||||
)
|
||||
|
||||
result = await complete_json(prompt, max_tokens=2048, schema_type="diff")
|
||||
|
||||
new_skills = result.get("new_skills", [])
|
||||
if not isinstance(new_skills, list):
|
||||
new_skills = []
|
||||
new_skills = [str(s) for s in new_skills if s]
|
||||
|
||||
return RegeneratedItem(
|
||||
item_id=item.item_id,
|
||||
item_type=item.item_type,
|
||||
title=item.title,
|
||||
subtitle=item.subtitle,
|
||||
original_content=item.current_content,
|
||||
new_content=new_skills,
|
||||
diff_summary=str(result.get("change_summary") or ""),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/regenerate", response_model=RegenerateResponse)
|
||||
async def regenerate_items(request: RegenerateRequest) -> RegenerateResponse:
|
||||
"""Regenerate selected resume items based on user feedback.
|
||||
|
||||
Takes selected items (experience, projects, skills) and a user instruction,
|
||||
then uses AI to rewrite the content addressing the user's concerns.
|
||||
"""
|
||||
# Validate resume exists
|
||||
resume = await db.get_resume(request.resume_id)
|
||||
if not resume:
|
||||
raise HTTPException(status_code=404, detail="Resume not found")
|
||||
|
||||
if not request.items:
|
||||
raise HTTPException(status_code=400, detail="No items selected for regeneration")
|
||||
|
||||
# Get language name for LLM
|
||||
output_language = get_language_name(request.output_language)
|
||||
|
||||
# Process all items in parallel for better performance
|
||||
tasks = []
|
||||
for item in request.items:
|
||||
if item.item_type == "skills":
|
||||
tasks.append(_regenerate_skills(item, request.instruction, output_language))
|
||||
else:
|
||||
tasks.append(_regenerate_experience_or_project(item, request.instruction, output_language))
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
regenerated_items: list[RegeneratedItem] = []
|
||||
errors: list[RegenerateItemError] = []
|
||||
|
||||
for item, result in zip(request.items, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Failed to regenerate item. "
|
||||
f"resume_id={request.resume_id} item_id={item.item_id} item_type={item.item_type}",
|
||||
exc_info=result,
|
||||
)
|
||||
errors.append(
|
||||
RegenerateItemError(
|
||||
item_id=item.item_id,
|
||||
item_type=item.item_type,
|
||||
title=item.title,
|
||||
subtitle=item.subtitle,
|
||||
message="Failed to regenerate this item. Please try again.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
regenerated_items.append(result)
|
||||
|
||||
if not regenerated_items:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to regenerate content. Please try again.",
|
||||
)
|
||||
|
||||
return RegenerateResponse(regenerated_items=regenerated_items, errors=errors)
|
||||
|
||||
|
||||
@router.post("/apply-regenerated/{resume_id}")
|
||||
async def apply_regenerated_items(
|
||||
resume_id: str, regenerated_items: list[RegeneratedItem]
|
||||
) -> dict:
|
||||
"""Apply regenerated items to the master resume.
|
||||
|
||||
Updates the resume's Experience, Projects, and Skills sections with
|
||||
the regenerated descriptions.
|
||||
"""
|
||||
# Fetch resume
|
||||
resume = await 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.",
|
||||
)
|
||||
|
||||
# Make a copy to modify
|
||||
updated_data = copy.deepcopy(processed_data)
|
||||
|
||||
def _normalize_match_value(value: str | None) -> str:
|
||||
return (value or "").strip().casefold()
|
||||
|
||||
def _normalize_lines(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
normalized: list[str] = []
|
||||
for entry in value:
|
||||
text = str(entry).strip()
|
||||
if text:
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
text = str(value).strip()
|
||||
return [text] if text else []
|
||||
|
||||
def _lines_equal(left: object, right: object) -> bool:
|
||||
left_norm = [line.casefold() for line in _normalize_lines(left)]
|
||||
right_norm = [line.casefold() for line in _normalize_lines(right)]
|
||||
return left_norm == right_norm
|
||||
|
||||
def _find_unique_index_by_metadata(
|
||||
entries: list[dict],
|
||||
*,
|
||||
title_key: str,
|
||||
subtitle_key: str,
|
||||
expected_title: str,
|
||||
expected_subtitle: str | None,
|
||||
expected_original_content: list[str],
|
||||
content_key: str,
|
||||
) -> int | None:
|
||||
expected_title_norm = _normalize_match_value(expected_title)
|
||||
expected_subtitle_norm = _normalize_match_value(expected_subtitle)
|
||||
|
||||
if not expected_title_norm:
|
||||
return None
|
||||
|
||||
matches: list[int] = []
|
||||
for i, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_title = _normalize_match_value(str(entry.get(title_key, "")))
|
||||
entry_subtitle = _normalize_match_value(str(entry.get(subtitle_key, "")))
|
||||
|
||||
if entry_title != expected_title_norm:
|
||||
continue
|
||||
if expected_subtitle_norm and entry_subtitle != expected_subtitle_norm:
|
||||
continue
|
||||
matches.append(i)
|
||||
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
|
||||
# If metadata is ambiguous, try to disambiguate using the original content.
|
||||
matches_by_content = [
|
||||
i for i in matches if _lines_equal(entries[i].get(content_key), expected_original_content)
|
||||
]
|
||||
if len(matches_by_content) == 1:
|
||||
return matches_by_content[0]
|
||||
|
||||
return None
|
||||
|
||||
def _parse_index(item_id: str, pattern: str) -> int | None:
|
||||
match = re.fullmatch(pattern, item_id)
|
||||
if not match:
|
||||
return None
|
||||
return int(match.group(1))
|
||||
|
||||
apply_failures: list[str] = []
|
||||
|
||||
# Apply each regenerated item (all-or-nothing to avoid corrupting user data)
|
||||
for item in regenerated_items:
|
||||
item_id = item.item_id
|
||||
item_type = item.item_type
|
||||
new_content = item.new_content
|
||||
|
||||
if item_type == "experience":
|
||||
experiences = updated_data.get("workExperience", [])
|
||||
if not isinstance(experiences, list):
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
|
||||
index = _parse_index(item_id, r"exp_(\d+)")
|
||||
if index is None:
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
|
||||
expected_title = item.title
|
||||
expected_company = item.subtitle
|
||||
expected_original_content = item.original_content
|
||||
|
||||
resolved_index: int | None = None
|
||||
if 0 <= index < len(experiences):
|
||||
entry = experiences[index] if isinstance(experiences[index], dict) else {}
|
||||
entry_title = _normalize_match_value(str(entry.get("title", "")))
|
||||
entry_company = _normalize_match_value(str(entry.get("company", "")))
|
||||
if entry_title == _normalize_match_value(expected_title) and (
|
||||
not _normalize_match_value(expected_company)
|
||||
or entry_company == _normalize_match_value(expected_company)
|
||||
) and _lines_equal(entry.get("description"), expected_original_content):
|
||||
resolved_index = index
|
||||
|
||||
if resolved_index is None:
|
||||
resolved_index = _find_unique_index_by_metadata(
|
||||
experiences,
|
||||
title_key="title",
|
||||
subtitle_key="company",
|
||||
expected_title=expected_title,
|
||||
expected_subtitle=expected_company,
|
||||
expected_original_content=expected_original_content,
|
||||
content_key="description",
|
||||
)
|
||||
|
||||
if resolved_index is None:
|
||||
logger.warning(
|
||||
"apply-regenerated: experience item mismatch; resume may have changed. "
|
||||
f"resume_id={resume_id} item_id={item_id} expected_title={expected_title!r} "
|
||||
f"expected_company={expected_company!r}"
|
||||
)
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
|
||||
entry = experiences[resolved_index]
|
||||
if isinstance(entry, dict):
|
||||
if not _lines_equal(entry.get("description"), expected_original_content):
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
entry["description"] = new_content
|
||||
else:
|
||||
apply_failures.append(item_id)
|
||||
|
||||
elif item_type == "project":
|
||||
projects = updated_data.get("personalProjects", [])
|
||||
if not isinstance(projects, list):
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
|
||||
index = _parse_index(item_id, r"proj_(\d+)")
|
||||
if index is None:
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
|
||||
expected_name = item.title
|
||||
expected_role = item.subtitle
|
||||
expected_original_content = item.original_content
|
||||
|
||||
resolved_index = None
|
||||
if 0 <= index < len(projects):
|
||||
entry = projects[index] if isinstance(projects[index], dict) else {}
|
||||
entry_name = _normalize_match_value(str(entry.get("name", "")))
|
||||
entry_role = _normalize_match_value(str(entry.get("role", "")))
|
||||
if entry_name == _normalize_match_value(expected_name) and (
|
||||
not _normalize_match_value(expected_role)
|
||||
or entry_role == _normalize_match_value(expected_role)
|
||||
) and _lines_equal(entry.get("description"), expected_original_content):
|
||||
resolved_index = index
|
||||
|
||||
if resolved_index is None:
|
||||
resolved_index = _find_unique_index_by_metadata(
|
||||
projects,
|
||||
title_key="name",
|
||||
subtitle_key="role",
|
||||
expected_title=expected_name,
|
||||
expected_subtitle=expected_role,
|
||||
expected_original_content=expected_original_content,
|
||||
content_key="description",
|
||||
)
|
||||
|
||||
if resolved_index is None:
|
||||
logger.warning(
|
||||
"apply-regenerated: project item mismatch; resume may have changed. "
|
||||
f"resume_id={resume_id} item_id={item_id} expected_name={expected_name!r} "
|
||||
f"expected_role={expected_role!r}"
|
||||
)
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
|
||||
entry = projects[resolved_index]
|
||||
if isinstance(entry, dict):
|
||||
if not _lines_equal(entry.get("description"), expected_original_content):
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
entry["description"] = new_content
|
||||
else:
|
||||
apply_failures.append(item_id)
|
||||
|
||||
elif item_type == "skills":
|
||||
# Update technical skills (stored in additional.technicalSkills)
|
||||
expected_original_content = item.original_content
|
||||
|
||||
additional = updated_data.get("additional")
|
||||
if isinstance(additional, dict) and "technicalSkills" in additional:
|
||||
if not _lines_equal(additional.get("technicalSkills"), expected_original_content):
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
additional["technicalSkills"] = new_content
|
||||
elif "technicalSkills" in updated_data:
|
||||
# Fallback for legacy data structure
|
||||
if not _lines_equal(updated_data.get("technicalSkills"), expected_original_content):
|
||||
apply_failures.append(item_id)
|
||||
continue
|
||||
updated_data["technicalSkills"] = new_content
|
||||
else:
|
||||
apply_failures.append(item_id)
|
||||
|
||||
if apply_failures:
|
||||
logger.warning(
|
||||
"apply-regenerated: refusing to apply due to mismatched/missing items. "
|
||||
f"resume_id={resume_id} item_ids={apply_failures}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Resume content changed or could not be uniquely matched. "
|
||||
"Please regenerate and try again."
|
||||
),
|
||||
)
|
||||
|
||||
# Update the resume in database
|
||||
updated_content = json.dumps(updated_data, indent=2)
|
||||
try:
|
||||
await db.update_resume(
|
||||
resume_id,
|
||||
{
|
||||
"content": updated_content,
|
||||
"processed_data": updated_data,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save regenerated content to database: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to save changes. Please try again.",
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Changes applied successfully",
|
||||
"updated_items": len(regenerated_items),
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Health check and status endpoints."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.database import db
|
||||
from app.llm import check_llm_health, get_llm_config
|
||||
from app.schemas import HealthResponse, StatusResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
# Returned for database_stats when the stats query itself fails, so /status can
|
||||
# still respond (degraded) instead of 500-ing.
|
||||
_EMPTY_DB_STATS = {
|
||||
"total_resumes": 0,
|
||||
"total_jobs": 0,
|
||||
"total_improvements": 0,
|
||||
"has_master_resume": False,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health_check() -> HealthResponse:
|
||||
"""Lightweight liveness check for Docker HEALTHCHECK.
|
||||
|
||||
Does NOT call the LLM provider. Use GET /status for full LLM health.
|
||||
"""
|
||||
return HealthResponse(status="healthy")
|
||||
|
||||
|
||||
@router.get("/status", response_model=StatusResponse)
|
||||
async def get_status() -> StatusResponse:
|
||||
"""Get comprehensive application status.
|
||||
|
||||
Each subsystem check is isolated: a failure in the LLM health probe or the
|
||||
database stats query degrades only its own field instead of 500-ing the
|
||||
whole endpoint, so the status page can still report partial/degraded state.
|
||||
"""
|
||||
llm_configured = False
|
||||
llm_healthy = False
|
||||
try:
|
||||
config = get_llm_config()
|
||||
# ollama / openai_compatible run without a key, matching check_llm_health.
|
||||
llm_configured = bool(config.api_key) or config.provider in ("ollama", "openai_compatible")
|
||||
llm_status = await check_llm_health(config)
|
||||
llm_healthy = bool(llm_status.get("healthy"))
|
||||
except Exception:
|
||||
logger.exception("Status: LLM health check failed")
|
||||
|
||||
db_stats: dict = dict(_EMPTY_DB_STATS)
|
||||
try:
|
||||
db_stats = await db.get_stats()
|
||||
except Exception:
|
||||
logger.exception("Status: database stats failed")
|
||||
|
||||
has_master_resume = bool(db_stats.get("has_master_resume"))
|
||||
|
||||
return StatusResponse(
|
||||
status="ready" if llm_healthy and has_master_resume else "setup_required",
|
||||
llm_configured=llm_configured,
|
||||
llm_healthy=llm_healthy,
|
||||
has_master_resume=has_master_resume,
|
||||
database_stats=db_stats,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Job description management endpoints."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.database import db
|
||||
from app.schemas import JobUploadRequest, JobUploadResponse
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["Jobs"])
|
||||
|
||||
|
||||
@router.post("/upload", response_model=JobUploadResponse)
|
||||
async def upload_job_descriptions(request: JobUploadRequest) -> JobUploadResponse:
|
||||
"""Upload one or more job descriptions.
|
||||
|
||||
Stores the raw text for later use in resume tailoring.
|
||||
Returns an array of job_ids corresponding to the input array.
|
||||
"""
|
||||
if not request.job_descriptions:
|
||||
raise HTTPException(status_code=400, detail="No job descriptions provided")
|
||||
|
||||
job_ids = []
|
||||
for jd in request.job_descriptions:
|
||||
if not jd.strip():
|
||||
raise HTTPException(status_code=400, detail="Empty job description")
|
||||
|
||||
job = await db.create_job(
|
||||
content=jd.strip(),
|
||||
resume_id=request.resume_id,
|
||||
)
|
||||
job_ids.append(job["job_id"])
|
||||
|
||||
return JobUploadResponse(
|
||||
message="data successfully processed",
|
||||
job_id=job_ids,
|
||||
request={
|
||||
"job_descriptions": request.job_descriptions,
|
||||
"resume_id": request.resume_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
async def get_job(job_id: str) -> dict:
|
||||
"""Get job description by ID."""
|
||||
job = await db.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
return job
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Resume wizard endpoints (adaptive one-question-at-a-time flow)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.database import db
|
||||
from app.schemas.models import ResumeData, normalize_resume_data
|
||||
from app.schemas.resume_wizard import (
|
||||
ResumeWizardFinalizeRequest,
|
||||
ResumeWizardFinalizeResponse,
|
||||
ResumeWizardTurnRequest,
|
||||
ResumeWizardTurnResponse,
|
||||
)
|
||||
from app.services.resume_wizard import (
|
||||
RESUME_WIZARD_MAX_QUESTIONS,
|
||||
apply_back,
|
||||
apply_review,
|
||||
build_initial_wizard_state,
|
||||
run_ai_turn,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/resume-wizard", tags=["Resume Wizard"])
|
||||
|
||||
|
||||
@router.post("/turn", response_model=ResumeWizardTurnResponse)
|
||||
async def resume_wizard_turn(
|
||||
request: ResumeWizardTurnRequest,
|
||||
) -> ResumeWizardTurnResponse:
|
||||
"""Advance the resume wizard by one structured turn."""
|
||||
try:
|
||||
action = request.action
|
||||
if action == "start":
|
||||
return ResumeWizardTurnResponse(state=build_initial_wizard_state())
|
||||
if action == "back":
|
||||
return ResumeWizardTurnResponse(state=apply_back(request.state))
|
||||
if action == "review":
|
||||
return ResumeWizardTurnResponse(state=apply_review(request.state))
|
||||
|
||||
# Cost guard: once the question cap is reached, stop making LLM calls for
|
||||
# answer/skip turns and route the user to review instead of advancing.
|
||||
if request.state.asked_count >= RESUME_WIZARD_MAX_QUESTIONS:
|
||||
return ResumeWizardTurnResponse(state=apply_review(request.state))
|
||||
|
||||
if action == "skip":
|
||||
state = await run_ai_turn(request.state, "", skip=True)
|
||||
return ResumeWizardTurnResponse(state=state)
|
||||
|
||||
answer_text = request.answer.text if request.answer else ""
|
||||
state = await run_ai_turn(request.state, answer_text, skip=False)
|
||||
return ResumeWizardTurnResponse(state=state)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error("Resume wizard turn validation failed: %s", e)
|
||||
raise HTTPException(status_code=422, detail="Could not update the resume draft.")
|
||||
except Exception as e:
|
||||
logger.error("Resume wizard turn failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Resume wizard failed. Please try again.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/finalize", response_model=ResumeWizardFinalizeResponse)
|
||||
async def finalize_resume_wizard(
|
||||
request: ResumeWizardFinalizeRequest,
|
||||
) -> ResumeWizardFinalizeResponse:
|
||||
"""Create the master resume from a validated wizard draft."""
|
||||
try:
|
||||
current_master = await db.get_master_resume()
|
||||
if current_master and current_master.get("processing_status") == "ready":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A master resume already exists. Delete it before creating a new one.",
|
||||
)
|
||||
|
||||
normalized = normalize_resume_data(
|
||||
request.state.resume_data.model_dump(mode="json")
|
||||
)
|
||||
data = ResumeData.model_validate(normalized).model_dump(mode="json")
|
||||
content = json.dumps(data, ensure_ascii=False, sort_keys=True)
|
||||
name = data.get("personalInfo", {}).get("name", "").strip() or "Resume"
|
||||
title = f"{name} Master Resume"
|
||||
# Set the title in the atomic create so a separate update can't fail and
|
||||
# leave a committed-but-untitled master behind (which would 409 on retry).
|
||||
resume = await db.create_resume_atomic_master(
|
||||
content=content,
|
||||
content_type="json",
|
||||
filename=f"AI Resume Wizard - {name}.json",
|
||||
processed_data=data,
|
||||
processing_status="ready",
|
||||
title=title,
|
||||
)
|
||||
if not resume.get("is_master", False):
|
||||
try:
|
||||
await db.delete_resume(resume["resume_id"])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to clean up non-master wizard resume %s: %s",
|
||||
resume.get("resume_id"),
|
||||
e,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A master resume already exists. Delete it before creating a new one.",
|
||||
)
|
||||
return ResumeWizardFinalizeResponse(
|
||||
message="Master resume created.",
|
||||
request_id=str(uuid4()),
|
||||
resume_id=resume["resume_id"],
|
||||
processing_status="ready",
|
||||
is_master=resume.get("is_master", False),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Resume wizard finalize failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Could not create master resume.")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
"""Pydantic schemas for request/response models."""
|
||||
|
||||
from app.schemas.models import (
|
||||
AdditionalInfo,
|
||||
ATSScore,
|
||||
ATSSubScores,
|
||||
ApiKeyProviderStatus,
|
||||
ApiKeysUpdateRequest,
|
||||
ApiKeysUpdateResponse,
|
||||
ApiKeyStatusResponse,
|
||||
CustomSection,
|
||||
CustomSectionItem,
|
||||
Education,
|
||||
Experience,
|
||||
FeatureConfigRequest,
|
||||
FeatureConfigResponse,
|
||||
FeaturePromptsRequest,
|
||||
FeaturePromptsResponse,
|
||||
GenerateContentResponse,
|
||||
GenerateInterviewPrepResponse,
|
||||
HealthResponse,
|
||||
ImproveDiffResult,
|
||||
ImprovementSuggestion,
|
||||
ImproveResumeData,
|
||||
ImproveResumeConfirmRequest,
|
||||
ImproveResumeRequest,
|
||||
ImproveResumeResponse,
|
||||
JobUploadRequest,
|
||||
JobUploadResponse,
|
||||
LanguageConfigRequest,
|
||||
LanguageConfigResponse,
|
||||
LLMConfigRequest,
|
||||
LLMConfigResponse,
|
||||
InterviewPrepData,
|
||||
InterviewPrepQuestion,
|
||||
InterviewPrepSkillGap,
|
||||
normalize_resume_data,
|
||||
PersonalInfo,
|
||||
Project,
|
||||
PromptConfigRequest,
|
||||
PromptConfigResponse,
|
||||
PromptOption,
|
||||
RawResume,
|
||||
RefinementStats,
|
||||
ResumeChange,
|
||||
ResumeDiffSummary,
|
||||
ResumeFieldDiff,
|
||||
ResetDatabaseRequest,
|
||||
ResumeData,
|
||||
ResumeFetchData,
|
||||
ResumeFetchResponse,
|
||||
ResumeListResponse,
|
||||
ResumeSummary,
|
||||
ResumeUploadResponse,
|
||||
SectionMeta,
|
||||
SectionType,
|
||||
StatusResponse,
|
||||
UpdateCoverLetterRequest,
|
||||
UpdateOutreachMessageRequest,
|
||||
UpdateTitleRequest,
|
||||
)
|
||||
from app.schemas.applications import (
|
||||
ApplicationActionResponse,
|
||||
ApplicationDetailResponse,
|
||||
ApplicationListResponse,
|
||||
ApplicationResponse,
|
||||
ApplicationStatus,
|
||||
ApplicationUpdate,
|
||||
APPLICATION_STATUS_ORDER,
|
||||
BulkDelete,
|
||||
BulkStatusUpdate,
|
||||
ManualApplicationCreate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PersonalInfo",
|
||||
"Experience",
|
||||
"Education",
|
||||
"Project",
|
||||
"AdditionalInfo",
|
||||
"SectionType",
|
||||
"SectionMeta",
|
||||
"CustomSectionItem",
|
||||
"CustomSection",
|
||||
"ResumeData",
|
||||
"normalize_resume_data",
|
||||
"RawResume",
|
||||
"ResumeUploadResponse",
|
||||
"ResumeFetchData",
|
||||
"ResumeFetchResponse",
|
||||
"ResumeSummary",
|
||||
"ResumeListResponse",
|
||||
"JobUploadRequest",
|
||||
"JobUploadResponse",
|
||||
"ImproveResumeRequest",
|
||||
"ImproveResumeData",
|
||||
"ImproveResumeConfirmRequest",
|
||||
"ImproveResumeResponse",
|
||||
"ImproveDiffResult",
|
||||
"ImprovementSuggestion",
|
||||
"ResumeChange",
|
||||
"ResumeDiffSummary",
|
||||
"ResumeFieldDiff",
|
||||
"ATSScore",
|
||||
"ATSSubScores",
|
||||
"RefinementStats",
|
||||
"LLMConfigRequest",
|
||||
"LLMConfigResponse",
|
||||
"LanguageConfigRequest",
|
||||
"LanguageConfigResponse",
|
||||
"PromptOption",
|
||||
"PromptConfigRequest",
|
||||
"PromptConfigResponse",
|
||||
"FeatureConfigRequest",
|
||||
"FeatureConfigResponse",
|
||||
"FeaturePromptsRequest",
|
||||
"FeaturePromptsResponse",
|
||||
"InterviewPrepData",
|
||||
"InterviewPrepQuestion",
|
||||
"InterviewPrepSkillGap",
|
||||
"ApiKeyProviderStatus",
|
||||
"ApiKeyStatusResponse",
|
||||
"ApiKeysUpdateRequest",
|
||||
"ApiKeysUpdateResponse",
|
||||
"ResetDatabaseRequest",
|
||||
"UpdateCoverLetterRequest",
|
||||
"UpdateOutreachMessageRequest",
|
||||
"UpdateTitleRequest",
|
||||
"GenerateContentResponse",
|
||||
"GenerateInterviewPrepResponse",
|
||||
"HealthResponse",
|
||||
"StatusResponse",
|
||||
"ApplicationStatus",
|
||||
"APPLICATION_STATUS_ORDER",
|
||||
"ApplicationResponse",
|
||||
"ApplicationDetailResponse",
|
||||
"ApplicationListResponse",
|
||||
"ManualApplicationCreate",
|
||||
"ApplicationUpdate",
|
||||
"BulkStatusUpdate",
|
||||
"BulkDelete",
|
||||
"ApplicationActionResponse",
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Pydantic schemas for the Kanban application tracker."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ApplicationStatus(str, Enum):
|
||||
"""The seven stable tracker columns (decoupled from i18n labels)."""
|
||||
|
||||
saved = "saved"
|
||||
applied = "applied"
|
||||
no_response = "no_response"
|
||||
response = "response"
|
||||
interview = "interview"
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
|
||||
|
||||
# Order the board renders columns in.
|
||||
APPLICATION_STATUS_ORDER: list[str] = [s.value for s in ApplicationStatus]
|
||||
|
||||
|
||||
class ApplicationResponse(BaseModel):
|
||||
"""A single tracker card."""
|
||||
|
||||
application_id: str
|
||||
job_id: str
|
||||
resume_id: str
|
||||
master_resume_id: str | None = None
|
||||
status: ApplicationStatus
|
||||
company: str | None = None
|
||||
role: str | None = None
|
||||
applied_at: str | None = None
|
||||
notes: str | None = None
|
||||
position: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ApplicationDetailResponse(ApplicationResponse):
|
||||
"""A card plus the embedded job description and applied resume.
|
||||
|
||||
``resume`` is null when the referenced resume has been deleted — the modal
|
||||
renders "resume unavailable" rather than 500ing.
|
||||
"""
|
||||
|
||||
job_content: str | None = None
|
||||
resume: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ApplicationListResponse(BaseModel):
|
||||
"""Applications grouped by column. All seven keys are always present."""
|
||||
|
||||
columns: dict[str, list[ApplicationResponse]]
|
||||
|
||||
|
||||
class ManualApplicationCreate(BaseModel):
|
||||
"""Create a card from a pasted JD (no prior tailoring).
|
||||
|
||||
The router creates the job from ``job_description`` then the application.
|
||||
``company``/``role`` are optional overrides; when omitted the router runs a
|
||||
best-effort extraction.
|
||||
"""
|
||||
|
||||
resume_id: str
|
||||
job_description: str = Field(min_length=1)
|
||||
company: str | None = None
|
||||
role: str | None = None
|
||||
status: ApplicationStatus = ApplicationStatus.applied
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ApplicationUpdate(BaseModel):
|
||||
"""Partial update — every field optional."""
|
||||
|
||||
status: ApplicationStatus | None = None
|
||||
position: int | None = None
|
||||
notes: str | None = None
|
||||
company: str | None = None
|
||||
role: str | None = None
|
||||
applied_at: str | None = None
|
||||
|
||||
|
||||
class BulkStatusUpdate(BaseModel):
|
||||
"""Move many cards to one column."""
|
||||
|
||||
application_ids: list[str] = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
|
||||
|
||||
class BulkDelete(BaseModel):
|
||||
"""Delete many cards."""
|
||||
|
||||
application_ids: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class ApplicationActionResponse(BaseModel):
|
||||
"""Generic acknowledgement for bulk/destructive actions."""
|
||||
|
||||
message: str
|
||||
affected: int
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Pydantic models for AI-powered resume enrichment."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EnrichmentItem(BaseModel):
|
||||
"""An item identified by AI as needing enrichment."""
|
||||
|
||||
item_id: str # ID of the experience/project (e.g., "exp_0", "proj_1")
|
||||
item_type: str # "experience" | "project"
|
||||
title: str # Job title or project name
|
||||
subtitle: str | None = None # Company name or project role
|
||||
current_description: list[str] = Field(default_factory=list)
|
||||
weakness_reason: str # Why AI flagged this item
|
||||
|
||||
|
||||
class EnrichmentQuestion(BaseModel):
|
||||
"""A clarifying question generated by AI for an item."""
|
||||
|
||||
question_id: str # Unique question ID (e.g., "q_0")
|
||||
item_id: str # Which item this question relates to
|
||||
question: str # The question text
|
||||
placeholder: str = "" # Hint for the input field
|
||||
|
||||
|
||||
class AnalysisResponse(BaseModel):
|
||||
"""Response from AI resume analysis."""
|
||||
|
||||
items_to_enrich: list[EnrichmentItem] = Field(default_factory=list)
|
||||
questions: list[EnrichmentQuestion] = Field(default_factory=list)
|
||||
analysis_summary: str | None = None # Optional overall summary
|
||||
|
||||
|
||||
class AnswerInput(BaseModel):
|
||||
"""User's answer to a clarifying question."""
|
||||
|
||||
question_id: str
|
||||
answer: str
|
||||
item_id: str | None = None # When provided, skips redundant re-analysis
|
||||
question_text: str | None = None # Original question for prompt context in fast path
|
||||
|
||||
|
||||
class EnhanceRequest(BaseModel):
|
||||
"""Request to generate enhanced descriptions from answers."""
|
||||
|
||||
resume_id: str
|
||||
answers: list[AnswerInput]
|
||||
|
||||
|
||||
class EnhancedDescription(BaseModel):
|
||||
"""AI-generated enhanced description for an item."""
|
||||
|
||||
item_id: str
|
||||
item_type: str # "experience" | "project"
|
||||
title: str # For display purposes
|
||||
original_description: list[str] = Field(default_factory=list)
|
||||
enhanced_description: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EnhancementPreview(BaseModel):
|
||||
"""Preview of all enhancements before applying."""
|
||||
|
||||
enhancements: list[EnhancedDescription] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApplyEnhancementsRequest(BaseModel):
|
||||
"""Request to apply enhancements to the master resume."""
|
||||
|
||||
enhancements: list[EnhancedDescription]
|
||||
|
||||
|
||||
# ============================================
|
||||
# AI Regenerate Feature Schemas
|
||||
# ============================================
|
||||
|
||||
RegenerateItemType = Literal["experience", "project", "skills"]
|
||||
|
||||
|
||||
class RegenerateItemInput(BaseModel):
|
||||
"""Input for a single item to regenerate."""
|
||||
|
||||
item_id: str # "exp_0", "proj_1", "skills"
|
||||
item_type: RegenerateItemType
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
current_content: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RegenerateRequest(BaseModel):
|
||||
"""Request to regenerate selected resume items."""
|
||||
|
||||
resume_id: str
|
||||
items: list[RegenerateItemInput]
|
||||
instruction: str = Field(max_length=2000) # User's feedback/instruction for improvement
|
||||
output_language: str = "en"
|
||||
|
||||
|
||||
class RegeneratedItem(BaseModel):
|
||||
"""Regenerated content for one item."""
|
||||
|
||||
item_id: str
|
||||
item_type: RegenerateItemType
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
original_content: list[str] = Field(default_factory=list)
|
||||
new_content: list[str] = Field(default_factory=list)
|
||||
diff_summary: str = "" # AI-generated summary of changes
|
||||
|
||||
|
||||
class RegenerateItemError(BaseModel):
|
||||
"""A non-fatal error for a single item regeneration request."""
|
||||
|
||||
item_id: str
|
||||
item_type: RegenerateItemType
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class RegenerateResponse(BaseModel):
|
||||
"""Response with all regenerated items."""
|
||||
|
||||
regenerated_items: list[RegeneratedItem] = Field(default_factory=list)
|
||||
errors: list[RegenerateItemError] = Field(default_factory=list)
|
||||
@@ -0,0 +1,862 @@
|
||||
"""Pydantic models matching frontend expectations."""
|
||||
|
||||
import copy
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
_TEXT_VALUE_KEYS = (
|
||||
"text",
|
||||
"summary",
|
||||
"description",
|
||||
"value",
|
||||
"content",
|
||||
"title",
|
||||
"subtitle",
|
||||
"name",
|
||||
"label",
|
||||
)
|
||||
_BULLET_PREFIX_RE = re.compile(r"^\s*(?:[-*•]+|\d+[.)])\s*")
|
||||
|
||||
|
||||
def _extract_text_fragments(
|
||||
value: Any, depth: int = 0, max_depth: int = 10
|
||||
) -> list[str]:
|
||||
"""Extract text-like content from nested list/dict values."""
|
||||
if depth >= max_depth or value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
return [stripped] if stripped else []
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return [str(value)]
|
||||
|
||||
if isinstance(value, list):
|
||||
fragments: list[str] = []
|
||||
for item in value:
|
||||
fragments.extend(_extract_text_fragments(item, depth + 1, max_depth))
|
||||
return fragments
|
||||
|
||||
if isinstance(value, dict):
|
||||
fragments: list[str] = []
|
||||
|
||||
for key in _TEXT_VALUE_KEYS:
|
||||
if key in value:
|
||||
fragments.extend(
|
||||
_extract_text_fragments(value.get(key), depth + 1, max_depth)
|
||||
)
|
||||
|
||||
if fragments:
|
||||
return fragments
|
||||
|
||||
for nested in value.values():
|
||||
fragments.extend(_extract_text_fragments(nested, depth + 1, max_depth))
|
||||
return fragments
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _coerce_text(value: Any, joiner: str = " ") -> str:
|
||||
"""Coerce nested values into a single text string."""
|
||||
return joiner.join(_extract_text_fragments(value)).strip()
|
||||
|
||||
|
||||
def _coerce_optional_text(value: Any) -> str | None:
|
||||
"""Coerce nested values into optional text."""
|
||||
if value is None:
|
||||
return None
|
||||
text = _coerce_text(value)
|
||||
return text or None
|
||||
|
||||
|
||||
def _split_description_lines(value: str) -> list[str]:
|
||||
"""Split a description block into clean bullet lines."""
|
||||
items: list[str] = []
|
||||
for raw_line in re.split(r"\r?\n+", value):
|
||||
line = _BULLET_PREFIX_RE.sub("", raw_line.strip())
|
||||
if line:
|
||||
items.append(line)
|
||||
return items
|
||||
|
||||
|
||||
def _coerce_string_list(value: Any) -> list[str]:
|
||||
"""Coerce nested/string values into a list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
return _split_description_lines(value)
|
||||
|
||||
if isinstance(value, list):
|
||||
items: list[str] = []
|
||||
for entry in value:
|
||||
if isinstance(entry, str):
|
||||
items.extend(_split_description_lines(entry))
|
||||
continue
|
||||
|
||||
coerced = _coerce_text(entry)
|
||||
if coerced:
|
||||
items.append(coerced)
|
||||
return items
|
||||
|
||||
coerced = _coerce_text(value)
|
||||
return [coerced] if coerced else []
|
||||
|
||||
|
||||
# Section Type Enum for dynamic sections
|
||||
class SectionType(str, Enum):
|
||||
"""Types of resume sections."""
|
||||
|
||||
PERSONAL_INFO = "personalInfo" # Special: always first, not reorderable
|
||||
TEXT = "text" # Single text block (like summary)
|
||||
ITEM_LIST = "itemList" # Array of items with fields (like experience)
|
||||
STRING_LIST = "stringList" # Array of strings (like skills)
|
||||
|
||||
|
||||
# Resume Data Models (matching frontend types in resume-component.tsx)
|
||||
class PersonalInfo(BaseModel):
|
||||
"""Personal information section."""
|
||||
|
||||
name: str = ""
|
||||
title: str = ""
|
||||
email: str = ""
|
||||
phone: str = ""
|
||||
location: str = ""
|
||||
website: str | None = None
|
||||
linkedin: str | None = None
|
||||
github: str | None = None
|
||||
|
||||
|
||||
class Experience(BaseModel):
|
||||
"""Work experience entry."""
|
||||
|
||||
id: int = 0
|
||||
title: str = ""
|
||||
company: str = ""
|
||||
location: str | None = None
|
||||
years: str = ""
|
||||
description: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("description", mode="before")
|
||||
@classmethod
|
||||
def _normalize_description(cls, value: Any) -> list[str]:
|
||||
return _coerce_string_list(value)
|
||||
|
||||
|
||||
class Education(BaseModel):
|
||||
"""Education entry."""
|
||||
|
||||
id: int = 0
|
||||
institution: str = ""
|
||||
degree: str = ""
|
||||
years: str = ""
|
||||
description: str | None = None
|
||||
|
||||
@field_validator("description", mode="before")
|
||||
@classmethod
|
||||
def _normalize_description(cls, value: Any) -> str | None:
|
||||
return _coerce_optional_text(value)
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
"""Personal project entry."""
|
||||
|
||||
id: int = 0
|
||||
name: str = ""
|
||||
role: str = ""
|
||||
years: str = ""
|
||||
github: str | None = None
|
||||
website: str | None = None
|
||||
description: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("description", mode="before")
|
||||
@classmethod
|
||||
def _normalize_description(cls, value: Any) -> list[str]:
|
||||
return _coerce_string_list(value)
|
||||
|
||||
|
||||
class AdditionalInfo(BaseModel):
|
||||
"""Additional information section."""
|
||||
|
||||
technicalSkills: list[str] = Field(default_factory=list)
|
||||
languages: list[str] = Field(default_factory=list)
|
||||
certificationsTraining: list[str] = Field(default_factory=list)
|
||||
awards: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator(
|
||||
"technicalSkills",
|
||||
"languages",
|
||||
"certificationsTraining",
|
||||
"awards",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _normalize_string_fields(cls, value: Any) -> list[str]:
|
||||
return _coerce_string_list(value)
|
||||
|
||||
|
||||
# Section Metadata Models for dynamic section management
|
||||
class SectionMeta(BaseModel):
|
||||
"""Metadata for a resume section."""
|
||||
|
||||
id: str # Unique identifier (e.g., "summary", "custom_1")
|
||||
key: str # Data key (matches ResumeData field or customSections key)
|
||||
displayName: str # User-visible name
|
||||
sectionType: SectionType # Type of section
|
||||
isDefault: bool = True # True for built-in sections
|
||||
isVisible: bool = True # Whether to show in resume
|
||||
order: int = 0 # Display order (0 = first after personalInfo)
|
||||
|
||||
|
||||
class CustomSectionItem(BaseModel):
|
||||
"""Generic item for custom item-based sections."""
|
||||
|
||||
id: int = 0
|
||||
title: str = "" # Primary title
|
||||
subtitle: str | None = None # Secondary info (company, institution, etc.)
|
||||
location: str | None = None
|
||||
years: str = ""
|
||||
description: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("description", mode="before")
|
||||
@classmethod
|
||||
def _normalize_description(cls, value: Any) -> list[str]:
|
||||
return _coerce_string_list(value)
|
||||
|
||||
|
||||
class CustomSection(BaseModel):
|
||||
"""Custom section data container."""
|
||||
|
||||
sectionType: SectionType
|
||||
items: list[CustomSectionItem] | None = None # For ITEM_LIST
|
||||
strings: list[str] | None = None # For STRING_LIST
|
||||
text: str | None = None # For TEXT
|
||||
|
||||
@field_validator("items", mode="before")
|
||||
@classmethod
|
||||
def _normalize_items(cls, value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
return value
|
||||
result = []
|
||||
for i, item in enumerate(value):
|
||||
if isinstance(item, str):
|
||||
result.append({"id": i + 1, "title": item})
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@field_validator("strings", mode="before")
|
||||
@classmethod
|
||||
def _normalize_strings(cls, value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _coerce_string_list(value)
|
||||
|
||||
@field_validator("text", mode="before")
|
||||
@classmethod
|
||||
def _normalize_text(cls, value: Any) -> str | None:
|
||||
return _coerce_optional_text(value)
|
||||
|
||||
|
||||
# Default section metadata for backward compatibility
|
||||
DEFAULT_SECTION_META: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "personalInfo",
|
||||
"key": "personalInfo",
|
||||
"displayName": "Personal Info",
|
||||
"sectionType": SectionType.PERSONAL_INFO,
|
||||
"isDefault": True,
|
||||
"isVisible": True,
|
||||
"order": 0,
|
||||
},
|
||||
{
|
||||
"id": "summary",
|
||||
"key": "summary",
|
||||
"displayName": "Summary",
|
||||
"sectionType": SectionType.TEXT,
|
||||
"isDefault": True,
|
||||
"isVisible": True,
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"id": "workExperience",
|
||||
"key": "workExperience",
|
||||
"displayName": "Experience",
|
||||
"sectionType": SectionType.ITEM_LIST,
|
||||
"isDefault": True,
|
||||
"isVisible": True,
|
||||
"order": 2,
|
||||
},
|
||||
{
|
||||
"id": "education",
|
||||
"key": "education",
|
||||
"displayName": "Education",
|
||||
"sectionType": SectionType.ITEM_LIST,
|
||||
"isDefault": True,
|
||||
"isVisible": True,
|
||||
"order": 3,
|
||||
},
|
||||
{
|
||||
"id": "personalProjects",
|
||||
"key": "personalProjects",
|
||||
"displayName": "Projects",
|
||||
"sectionType": SectionType.ITEM_LIST,
|
||||
"isDefault": True,
|
||||
"isVisible": True,
|
||||
"order": 4,
|
||||
},
|
||||
{
|
||||
"id": "additional",
|
||||
"key": "additional",
|
||||
"displayName": "Skills & Awards",
|
||||
"sectionType": SectionType.STRING_LIST,
|
||||
"isDefault": True,
|
||||
"isVisible": True,
|
||||
"order": 5,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def normalize_resume_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Ensure resume data has section metadata (migration helper).
|
||||
|
||||
This function is used for lazy migration of existing resumes
|
||||
that don't have sectionMeta or customSections fields.
|
||||
"""
|
||||
if not data.get("sectionMeta"):
|
||||
# Use deepcopy to avoid shared mutable reference bug
|
||||
# Without this, all resumes would share the same list reference
|
||||
data["sectionMeta"] = copy.deepcopy(DEFAULT_SECTION_META)
|
||||
if "customSections" not in data:
|
||||
data["customSections"] = {}
|
||||
return data
|
||||
|
||||
|
||||
class ResumeData(BaseModel):
|
||||
"""Complete structured resume data."""
|
||||
|
||||
# Existing fields (kept for backward compatibility)
|
||||
personalInfo: PersonalInfo = Field(default_factory=PersonalInfo)
|
||||
summary: str = ""
|
||||
workExperience: list[Experience] = Field(default_factory=list)
|
||||
education: list[Education] = Field(default_factory=list)
|
||||
personalProjects: list[Project] = Field(default_factory=list)
|
||||
additional: AdditionalInfo = Field(default_factory=AdditionalInfo)
|
||||
|
||||
# NEW: Section metadata and custom sections
|
||||
sectionMeta: list[SectionMeta] = Field(default_factory=list)
|
||||
customSections: dict[str, CustomSection] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("summary", mode="before")
|
||||
@classmethod
|
||||
def _normalize_summary(cls, value: Any) -> str:
|
||||
return _coerce_text(value)
|
||||
|
||||
|
||||
# API Response Models
|
||||
class ResumeUploadResponse(BaseModel):
|
||||
"""Response for resume upload."""
|
||||
|
||||
message: str
|
||||
request_id: str
|
||||
resume_id: str
|
||||
processing_status: Literal["pending", "processing", "ready", "failed"] = "pending"
|
||||
is_master: bool = False
|
||||
|
||||
|
||||
class RawResume(BaseModel):
|
||||
"""Raw resume data from database."""
|
||||
|
||||
id: int | None = None
|
||||
content: str
|
||||
content_type: str = "md"
|
||||
created_at: str
|
||||
processing_status: str = "pending" # pending, processing, ready, failed
|
||||
|
||||
|
||||
class InterviewPrepQuestion(BaseModel):
|
||||
"""Interview question grounded in the tailored resume and job context."""
|
||||
|
||||
question: str
|
||||
focus_area: str | None = None
|
||||
suggested_answer_points: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class InterviewPrepSkillGap(BaseModel):
|
||||
"""A preparation target, not a claimed candidate skill."""
|
||||
|
||||
skill: str
|
||||
why_it_matters: str
|
||||
preparation_suggestion: str
|
||||
|
||||
|
||||
class InterviewPrepData(BaseModel):
|
||||
"""Structured interview preparation content for a tailored resume."""
|
||||
|
||||
role_fit_analysis: list[str]
|
||||
resume_questions: list[InterviewPrepQuestion]
|
||||
project_follow_ups: list[InterviewPrepQuestion]
|
||||
skill_gaps: list[InterviewPrepSkillGap]
|
||||
talking_points: list[str]
|
||||
|
||||
|
||||
class ResumeFetchData(BaseModel):
|
||||
"""Data payload for resume fetch response."""
|
||||
|
||||
resume_id: str
|
||||
raw_resume: RawResume
|
||||
processed_resume: ResumeData | None = None
|
||||
cover_letter: str | None = None
|
||||
outreach_message: str | None = None
|
||||
interview_prep: InterviewPrepData | None = None
|
||||
parent_id: str | None = None # For determining if resume is tailored
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class ResumeFetchResponse(BaseModel):
|
||||
"""Response for resume fetch."""
|
||||
|
||||
request_id: str
|
||||
data: ResumeFetchData
|
||||
|
||||
|
||||
class ResumeSummary(BaseModel):
|
||||
"""Summary details for listing resumes."""
|
||||
|
||||
resume_id: str
|
||||
filename: str | None = None
|
||||
is_master: bool = False
|
||||
parent_id: str | None = None
|
||||
processing_status: str = "pending"
|
||||
created_at: str
|
||||
updated_at: str
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class ResumeListResponse(BaseModel):
|
||||
"""Response for resume list."""
|
||||
|
||||
request_id: str
|
||||
data: list[ResumeSummary]
|
||||
|
||||
|
||||
# Job Description Models
|
||||
class JobUploadRequest(BaseModel):
|
||||
"""Request to upload job descriptions."""
|
||||
|
||||
job_descriptions: list[str]
|
||||
resume_id: str | None = None
|
||||
|
||||
|
||||
class JobUploadResponse(BaseModel):
|
||||
"""Response for job upload."""
|
||||
|
||||
message: str
|
||||
job_id: list[str]
|
||||
request: dict[str, Any]
|
||||
|
||||
|
||||
# Improvement Models
|
||||
class ImproveResumeRequest(BaseModel):
|
||||
"""Request to improve/tailor a resume."""
|
||||
|
||||
resume_id: str
|
||||
job_id: str
|
||||
prompt_id: str | None = None
|
||||
|
||||
|
||||
class ImprovementSuggestion(BaseModel):
|
||||
"""Single improvement suggestion."""
|
||||
|
||||
suggestion: str
|
||||
lineNumber: int | None = None
|
||||
|
||||
|
||||
class ResumeFieldDiff(BaseModel):
|
||||
"""Single field change record."""
|
||||
|
||||
field_path: str # Example: "workExperience[0].description[2]"
|
||||
field_type: Literal[
|
||||
"skill",
|
||||
"description",
|
||||
"summary",
|
||||
"certification",
|
||||
"experience",
|
||||
"education",
|
||||
"project",
|
||||
"language",
|
||||
"award",
|
||||
]
|
||||
change_type: Literal["added", "removed", "modified"]
|
||||
original_value: str | None = None
|
||||
new_value: str | None = None
|
||||
confidence: Literal["low", "medium", "high"] = "medium"
|
||||
|
||||
|
||||
class ResumeDiffSummary(BaseModel):
|
||||
"""Change summary stats."""
|
||||
|
||||
total_changes: int
|
||||
skills_added: int
|
||||
skills_removed: int
|
||||
descriptions_modified: int
|
||||
certifications_added: int
|
||||
high_risk_changes: int # High-risk additions
|
||||
|
||||
|
||||
class ATSSubScores(BaseModel):
|
||||
"""Individual component scores that make up the ATS overall score."""
|
||||
|
||||
keyword_match: float = Field(
|
||||
default=0.0, ge=0.0, le=100.0, description="Keyword match % (0–100)"
|
||||
)
|
||||
skills_coverage: float = Field(
|
||||
default=0.0, ge=0.0, le=100.0, description="JD skills matched in resume (0–100)"
|
||||
)
|
||||
section_completeness: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=100.0,
|
||||
description="Key resume sections present (0–100)",
|
||||
)
|
||||
|
||||
|
||||
class ATSScore(BaseModel):
|
||||
"""ATS-style score breakdown for a resume against a job description."""
|
||||
|
||||
overall_score: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=100.0,
|
||||
description="Weighted composite ATS score (0–100)",
|
||||
)
|
||||
sub_scores: ATSSubScores = Field(default_factory=ATSSubScores)
|
||||
missing_keywords: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Job keywords absent from the tailored resume",
|
||||
)
|
||||
injectable_keywords: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Missing keywords that exist in the master resume and can be safely added",
|
||||
)
|
||||
recommendations: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Actionable suggestions to improve the ATS score",
|
||||
)
|
||||
|
||||
|
||||
class RefinementStats(BaseModel):
|
||||
"""Statistics from the multi-pass refinement process."""
|
||||
|
||||
passes_completed: int = Field(default=0, ge=0, description="Number of passes run")
|
||||
keywords_injected: int = Field(
|
||||
default=0, ge=0, description="Number of keywords injected"
|
||||
)
|
||||
ai_phrases_removed: list[str] = Field(
|
||||
default_factory=list, description="List of AI phrases that were removed"
|
||||
)
|
||||
alignment_violations_fixed: int = Field(
|
||||
default=0, ge=0, description="Number of alignment violations corrected"
|
||||
)
|
||||
initial_match_percentage: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=100.0,
|
||||
description="Keyword match before refinement",
|
||||
)
|
||||
final_match_percentage: float = Field(
|
||||
default=0.0, ge=0.0, le=100.0, description="Keyword match after refinement"
|
||||
)
|
||||
|
||||
|
||||
class ImproveResumeData(BaseModel):
|
||||
"""Data payload for improve response."""
|
||||
|
||||
request_id: str
|
||||
resume_id: str | None = Field(
|
||||
default=None,
|
||||
description="Null for preview responses; populated when the tailored resume is persisted.",
|
||||
)
|
||||
job_id: str
|
||||
resume_preview: ResumeData
|
||||
improvements: list[ImprovementSuggestion]
|
||||
markdownOriginal: str | None = None
|
||||
markdownImproved: str | None = None
|
||||
cover_letter: str | None = None
|
||||
outreach_message: str | None = None
|
||||
interview_prep: InterviewPrepData | None = None
|
||||
|
||||
# Diff metadata
|
||||
diff_summary: ResumeDiffSummary | None = None
|
||||
detailed_changes: list[ResumeFieldDiff] | None = None
|
||||
|
||||
# Refinement metadata (multi-pass refinement stats)
|
||||
refinement_stats: "RefinementStats | None" = None
|
||||
|
||||
# ATS score breakdown
|
||||
ats_score: "ATSScore | None" = None
|
||||
|
||||
# Warning and status fields for transparency
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
refinement_attempted: bool = False
|
||||
refinement_successful: bool = False
|
||||
|
||||
|
||||
class ImproveResumeResponse(BaseModel):
|
||||
"""Response for resume improvement."""
|
||||
|
||||
request_id: str
|
||||
data: ImproveResumeData
|
||||
|
||||
|
||||
class ImproveResumeConfirmRequest(BaseModel):
|
||||
"""Request to confirm and save a tailored resume."""
|
||||
|
||||
resume_id: str
|
||||
job_id: str
|
||||
improved_data: ResumeData
|
||||
improvements: list[ImprovementSuggestion]
|
||||
|
||||
|
||||
# Config Models
|
||||
ReasoningEffortLiteral = Literal["minimal", "low", "medium", "high"]
|
||||
|
||||
|
||||
class LLMConfigRequest(BaseModel):
|
||||
"""Request to update LLM configuration."""
|
||||
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
# Optional reasoning-effort override.
|
||||
# - A valid value ("minimal"/"low"/"medium"/"high") updates the setting.
|
||||
# - Empty string clears the field — the server persists "" rather than
|
||||
# removing the key, so the gpt-5 auto-migration does not re-fire.
|
||||
# - None means "don't change this field".
|
||||
# Strictly typed so invalid values are rejected at the boundary (422)
|
||||
# rather than corrupting config.json and crashing later reads.
|
||||
reasoning_effort: Literal["minimal", "low", "medium", "high", ""] | None = None
|
||||
|
||||
|
||||
class LLMConfigResponse(BaseModel):
|
||||
"""Response for LLM configuration."""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
api_key: str # Masked
|
||||
api_base: str | None = None
|
||||
reasoning_effort: ReasoningEffortLiteral | None = None
|
||||
|
||||
|
||||
class FeatureConfigRequest(BaseModel):
|
||||
"""Request to update feature settings."""
|
||||
|
||||
enable_cover_letter: bool | None = None
|
||||
enable_outreach_message: bool | None = None
|
||||
enable_interview_prep: bool | None = None
|
||||
|
||||
|
||||
class FeatureConfigResponse(BaseModel):
|
||||
"""Response for feature settings."""
|
||||
|
||||
enable_cover_letter: bool = False
|
||||
enable_outreach_message: bool = False
|
||||
enable_interview_prep: bool = False
|
||||
|
||||
|
||||
class LanguageConfigRequest(BaseModel):
|
||||
"""Request to update language settings."""
|
||||
|
||||
ui_language: str | None = None # en, es, zh, ja - for interface
|
||||
content_language: str | None = None # en, es, zh, ja - for generated content
|
||||
|
||||
|
||||
class LanguageConfigResponse(BaseModel):
|
||||
"""Response for language settings."""
|
||||
|
||||
ui_language: str = "en" # Interface language
|
||||
content_language: str = "en" # Generated content language
|
||||
supported_languages: list[str] = ["en", "es", "zh", "ja"]
|
||||
|
||||
|
||||
class PromptOption(BaseModel):
|
||||
"""Prompt option for resume tailoring."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
description: str
|
||||
|
||||
|
||||
class PromptConfigRequest(BaseModel):
|
||||
"""Request to update prompt settings."""
|
||||
|
||||
default_prompt_id: str | None = None
|
||||
|
||||
|
||||
class PromptConfigResponse(BaseModel):
|
||||
"""Response for prompt settings."""
|
||||
|
||||
default_prompt_id: str
|
||||
prompt_options: list[PromptOption]
|
||||
|
||||
|
||||
class FeaturePromptsRequest(BaseModel):
|
||||
"""Request to update custom feature prompts.
|
||||
|
||||
``None`` means "don't change this field". An empty string clears the
|
||||
override — the server persists ``""`` so runtime resolution falls back
|
||||
to the built-in default without the key disappearing from config.json.
|
||||
"""
|
||||
|
||||
cover_letter_prompt: str | None = None
|
||||
outreach_message_prompt: str | None = None
|
||||
|
||||
|
||||
class FeaturePromptsResponse(BaseModel):
|
||||
"""Response for custom feature prompts.
|
||||
|
||||
The ``*_default`` fields expose the built-in prompt strings so the UI
|
||||
can render them as placeholder text without duplicating the content
|
||||
across locales.
|
||||
"""
|
||||
|
||||
cover_letter_prompt: str
|
||||
outreach_message_prompt: str
|
||||
cover_letter_default: str
|
||||
outreach_message_default: str
|
||||
|
||||
|
||||
# API Key Management Models
|
||||
class ApiKeyProviderStatus(BaseModel):
|
||||
"""Status of a single API key provider."""
|
||||
|
||||
provider: str # openai, anthropic, google, etc.
|
||||
configured: bool
|
||||
masked_key: str | None = None # Shows last 4 chars if configured
|
||||
|
||||
|
||||
class ApiKeyStatusResponse(BaseModel):
|
||||
"""Response for API key status check."""
|
||||
|
||||
providers: list[ApiKeyProviderStatus]
|
||||
|
||||
|
||||
class ApiKeysUpdateRequest(BaseModel):
|
||||
"""Request to update API keys."""
|
||||
|
||||
openai: str | None = None
|
||||
anthropic: str | None = None
|
||||
google: str | None = None
|
||||
openrouter: str | None = None
|
||||
deepseek: str | None = None
|
||||
groq: str | None = None
|
||||
# Local/self-hosted providers that may sit behind an auth proxy.
|
||||
openai_compatible: str | None = None
|
||||
ollama: str | None = None
|
||||
|
||||
|
||||
class ApiKeysUpdateResponse(BaseModel):
|
||||
"""Response after updating API keys."""
|
||||
|
||||
message: str
|
||||
updated_providers: list[str]
|
||||
|
||||
|
||||
# Update Cover Letter/Outreach Models
|
||||
class UpdateCoverLetterRequest(BaseModel):
|
||||
"""Request to update cover letter content."""
|
||||
|
||||
content: str
|
||||
|
||||
|
||||
class UpdateOutreachMessageRequest(BaseModel):
|
||||
"""Request to update outreach message content."""
|
||||
|
||||
content: str
|
||||
|
||||
|
||||
class UpdateTitleRequest(BaseModel):
|
||||
"""Request to update resume title."""
|
||||
|
||||
title: str
|
||||
|
||||
|
||||
class ResetDatabaseRequest(BaseModel):
|
||||
"""Request to reset database with confirmation."""
|
||||
|
||||
confirm: str | None = None
|
||||
|
||||
|
||||
class GenerateContentResponse(BaseModel):
|
||||
"""Response for on-demand content generation."""
|
||||
|
||||
content: str
|
||||
message: str
|
||||
|
||||
|
||||
class GenerateInterviewPrepResponse(BaseModel):
|
||||
"""Response for on-demand interview preparation generation."""
|
||||
|
||||
interview_prep: InterviewPrepData
|
||||
message: str
|
||||
|
||||
|
||||
# Health/Status Models
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
|
||||
status: str
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
"""Application status response."""
|
||||
|
||||
status: str
|
||||
llm_configured: bool
|
||||
llm_healthy: bool
|
||||
has_master_resume: bool
|
||||
database_stats: dict[str, Any]
|
||||
|
||||
|
||||
# Diff-Based Improvement Models
|
||||
|
||||
|
||||
class ResumeChange(BaseModel):
|
||||
"""A single targeted change the LLM wants to make to the resume."""
|
||||
|
||||
path: str = Field(
|
||||
description="Dot+bracket path, e.g. 'workExperience[0].description[1]'"
|
||||
)
|
||||
action: Literal["replace", "append", "reorder", "add_skill"]
|
||||
original: str | list[str] | None = Field(
|
||||
default=None,
|
||||
description="Current text at path — for verification. May be a list (the "
|
||||
"current items) for the reorder action; only used for text verification of "
|
||||
"replace/append, ignored otherwise.",
|
||||
)
|
||||
value: str | list[str] = Field(description="New content")
|
||||
reason: str = Field(description="Why this change helps match the JD")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _list_original_only_for_reorder(self) -> "ResumeChange":
|
||||
"""A list ``original`` is only meaningful for ``reorder`` (the LLM sends
|
||||
the current items). For the text actions it must stay a string/None — a
|
||||
list there would silently bypass the replace verification gate and crash
|
||||
the invented-metrics check, so reject it at parse time."""
|
||||
if isinstance(self.original, list) and self.action != "reorder":
|
||||
raise ValueError("'original' may be a list only for the reorder action")
|
||||
return self
|
||||
|
||||
|
||||
class ImproveDiffResult(BaseModel):
|
||||
"""LLM output: a list of targeted resume changes."""
|
||||
|
||||
changes: list[ResumeChange] = Field(default_factory=list)
|
||||
strategy_notes: str = Field(default="")
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Pydantic models for multi-pass resume refinement."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RefinementConfig(BaseModel):
|
||||
"""Configuration for refinement passes."""
|
||||
|
||||
enable_keyword_injection: bool = True
|
||||
enable_ai_phrase_removal: bool = True
|
||||
enable_master_alignment_check: bool = True
|
||||
max_refinement_passes: int = Field(default=2, ge=1, le=5)
|
||||
|
||||
|
||||
class KeywordGapAnalysis(BaseModel):
|
||||
"""Result of keyword gap analysis."""
|
||||
|
||||
missing_keywords: list[str] = Field(default_factory=list)
|
||||
injectable_keywords: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Missing keywords that exist in master resume (safe to add)",
|
||||
)
|
||||
non_injectable_keywords: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Missing keywords not in master resume (cannot add truthfully)",
|
||||
)
|
||||
current_match_percentage: float = Field(
|
||||
default=0.0, ge=0.0, le=100.0, description="Current keyword match percentage"
|
||||
)
|
||||
potential_match_percentage: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=100.0,
|
||||
description="Potential match if injectable keywords are added",
|
||||
)
|
||||
|
||||
|
||||
class AlignmentViolation(BaseModel):
|
||||
"""Single alignment violation between tailored and master resume."""
|
||||
|
||||
field_path: str = Field(description="Path to the violated field in resume data")
|
||||
violation_type: str = Field(
|
||||
description="Type: fabricated_skill, skill_variant, fabricated_cert, fabricated_company, invented_content"
|
||||
)
|
||||
value: str = Field(description="The violating value")
|
||||
severity: str = Field(
|
||||
default="warning", description="Severity: critical, warning, or info"
|
||||
)
|
||||
|
||||
|
||||
class AlignmentReport(BaseModel):
|
||||
"""Master resume alignment validation result."""
|
||||
|
||||
is_aligned: bool = Field(
|
||||
default=True, description="True if no critical violations found"
|
||||
)
|
||||
violations: list[AlignmentViolation] = Field(default_factory=list)
|
||||
confidence_score: float = Field(
|
||||
default=1.0,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Alignment confidence (1.0 = perfect alignment)",
|
||||
)
|
||||
|
||||
|
||||
class RefinementStats(BaseModel):
|
||||
"""Statistics from the refinement process for API responses."""
|
||||
|
||||
passes_completed: int = Field(default=0, ge=0, description="Number of passes run")
|
||||
keywords_injected: int = Field(
|
||||
default=0, ge=0, description="Number of keywords injected"
|
||||
)
|
||||
ai_phrases_removed: list[str] = Field(
|
||||
default_factory=list, description="List of AI phrases that were removed"
|
||||
)
|
||||
alignment_violations_fixed: int = Field(
|
||||
default=0, ge=0, description="Number of alignment violations corrected"
|
||||
)
|
||||
initial_match_percentage: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
le=100.0,
|
||||
description="Keyword match before refinement",
|
||||
)
|
||||
final_match_percentage: float = Field(
|
||||
default=0.0, ge=0.0, le=100.0, description="Keyword match after refinement"
|
||||
)
|
||||
|
||||
|
||||
class RefinementResult(BaseModel):
|
||||
"""Complete result from the refinement process."""
|
||||
|
||||
refined_data: dict = Field(
|
||||
default_factory=dict, description="The refined resume data"
|
||||
)
|
||||
passes_completed: int = Field(default=0, ge=0)
|
||||
keyword_analysis: KeywordGapAnalysis | None = None
|
||||
alignment_report: AlignmentReport | None = None
|
||||
ai_phrases_removed: list[str] = Field(default_factory=list)
|
||||
final_match_percentage: float = Field(default=0.0, ge=0.0, le=100.0)
|
||||
|
||||
def to_stats(self, initial_match: float = 0.0) -> RefinementStats:
|
||||
"""Convert to RefinementStats for API response."""
|
||||
return RefinementStats(
|
||||
passes_completed=self.passes_completed,
|
||||
keywords_injected=(
|
||||
len(self.keyword_analysis.injectable_keywords)
|
||||
if self.keyword_analysis and self.keyword_analysis.injectable_keywords
|
||||
else 0
|
||||
),
|
||||
ai_phrases_removed=self.ai_phrases_removed,
|
||||
alignment_violations_fixed=(
|
||||
len(
|
||||
[
|
||||
v
|
||||
for v in self.alignment_report.violations
|
||||
if v.severity == "critical"
|
||||
]
|
||||
)
|
||||
if self.alignment_report and self.alignment_report.violations
|
||||
else 0
|
||||
),
|
||||
initial_match_percentage=initial_match,
|
||||
final_match_percentage=self.final_match_percentage,
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Schemas for the adaptive one-question-at-a-time AI resume wizard."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.models import ResumeData
|
||||
|
||||
ResumeWizardSection = Literal[
|
||||
"intro",
|
||||
"contact",
|
||||
"summary",
|
||||
"workExperience",
|
||||
"internships", # mapped onto workExperience by the service merge layer
|
||||
"education",
|
||||
"personalProjects",
|
||||
"skills",
|
||||
"review",
|
||||
]
|
||||
|
||||
ResumeWizardStep = Literal["intro", "question", "review", "complete"]
|
||||
|
||||
ResumeWizardAction = Literal["start", "answer", "skip", "back", "review"]
|
||||
|
||||
|
||||
class ResumeWizardQuestion(BaseModel):
|
||||
"""A single question the wizard asks."""
|
||||
|
||||
text: str = ""
|
||||
section: ResumeWizardSection = "intro"
|
||||
|
||||
|
||||
class ResumeWizardProgress(BaseModel):
|
||||
"""Server-computed progress for the question card's bar."""
|
||||
|
||||
current: int = 0
|
||||
total: int = 8
|
||||
|
||||
|
||||
class ResumeWizardAnswer(BaseModel):
|
||||
"""User answer for one wizard turn."""
|
||||
|
||||
text: str = Field(min_length=1, max_length=6000)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def _reject_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("answer text must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
class ResumeWizardHistoryEntry(BaseModel):
|
||||
"""One answered question, with a pre-answer draft snapshot for Back."""
|
||||
|
||||
question: str
|
||||
answer: str
|
||||
section: ResumeWizardSection
|
||||
resume_data_before: ResumeData
|
||||
|
||||
|
||||
class ResumeWizardState(BaseModel):
|
||||
"""Complete state that round-trips between client and server."""
|
||||
|
||||
step: ResumeWizardStep = "intro"
|
||||
resume_data: ResumeData = Field(default_factory=ResumeData)
|
||||
current_question: ResumeWizardQuestion = Field(default_factory=ResumeWizardQuestion)
|
||||
history: list[ResumeWizardHistoryEntry] = Field(default_factory=list)
|
||||
asked_count: int = 0
|
||||
inferred_skills: list[str] = Field(default_factory=list)
|
||||
is_complete: bool = False
|
||||
progress: ResumeWizardProgress = Field(default_factory=ResumeWizardProgress)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ResumeWizardTurnRequest(BaseModel):
|
||||
"""Request for one wizard turn."""
|
||||
|
||||
state: ResumeWizardState
|
||||
action: ResumeWizardAction
|
||||
answer: ResumeWizardAnswer | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_answer_present(self) -> "ResumeWizardTurnRequest":
|
||||
if self.action == "answer" and self.answer is None:
|
||||
raise ValueError("answer is required for answer actions")
|
||||
return self
|
||||
|
||||
|
||||
class ResumeWizardTurnResponse(BaseModel):
|
||||
"""Response for one wizard turn."""
|
||||
|
||||
state: ResumeWizardState
|
||||
|
||||
|
||||
class ResumeWizardFinalizeRequest(BaseModel):
|
||||
"""Request to create the master resume from the wizard draft."""
|
||||
|
||||
state: ResumeWizardState
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_ready_to_finalize(self) -> "ResumeWizardFinalizeRequest":
|
||||
if not self.state.resume_data.personalInfo.name.strip():
|
||||
raise ValueError("personalInfo.name is required")
|
||||
return self
|
||||
|
||||
|
||||
class ResumeWizardFinalizeResponse(BaseModel):
|
||||
"""Response after creating the master resume."""
|
||||
|
||||
message: str
|
||||
request_id: str
|
||||
resume_id: str
|
||||
processing_status: Literal["ready"] = "ready"
|
||||
is_master: bool
|
||||
@@ -0,0 +1 @@
|
||||
"""Operational scripts (migrations, maintenance)."""
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Idempotent one-time importer: legacy TinyDB ``database.json`` → SQLite.
|
||||
|
||||
Safe to run repeatedly and on every startup:
|
||||
- no legacy file present → no-op;
|
||||
- SQLite already has rows → skip (assume already migrated);
|
||||
- otherwise → copy resumes/jobs/improvements 1:1 (preserving
|
||||
primary keys and timestamps), enforce the single-master invariant, then
|
||||
rename the legacy file to ``database.json.migrated`` as a rollback artifact.
|
||||
|
||||
Run standalone with ``uv run python -m app.scripts.migrate_tinydb_to_sqlite``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Database, db
|
||||
from app.models import Improvement, Job, Resume, _utcnow_iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JOB_CORE_FIELDS = {"job_id", "content", "resume_id", "created_at"}
|
||||
|
||||
|
||||
def _legacy_path() -> Path:
|
||||
return settings.db_path # data/database.json
|
||||
|
||||
|
||||
async def migrate(database: Database | None = None) -> dict[str, Any]:
|
||||
"""Import the legacy TinyDB file into SQLite if needed.
|
||||
|
||||
Returns a summary dict: ``{"status": ..., counts...}``.
|
||||
"""
|
||||
database = database or db
|
||||
legacy = _legacy_path()
|
||||
|
||||
if not legacy.exists():
|
||||
return {"status": "no_legacy_file"}
|
||||
|
||||
stats = await database.get_stats()
|
||||
if (stats["total_resumes"] or stats["total_jobs"] or stats["total_improvements"]):
|
||||
logger.info("SQLite already populated; skipping TinyDB import.")
|
||||
return {"status": "already_populated"}
|
||||
|
||||
# Gated import — tinydb is only needed for this one-time migration.
|
||||
from tinydb import TinyDB
|
||||
|
||||
tdb = TinyDB(legacy)
|
||||
try:
|
||||
resumes = list(tdb.table("resumes").all())
|
||||
jobs = list(tdb.table("jobs").all())
|
||||
improvements = list(tdb.table("improvements").all())
|
||||
finally:
|
||||
tdb.close()
|
||||
|
||||
# Enforce the single-master invariant: if multiple resumes claim master,
|
||||
# keep the earliest by created_at and demote the rest.
|
||||
masters = [r for r in resumes if r.get("is_master")]
|
||||
if len(masters) > 1:
|
||||
masters.sort(key=lambda r: r.get("created_at", ""))
|
||||
keep = masters[0].get("resume_id")
|
||||
for r in resumes:
|
||||
if r.get("is_master") and r.get("resume_id") != keep:
|
||||
r["is_master"] = False
|
||||
logger.warning(
|
||||
"Legacy DB had %d masters; kept %s, demoted the rest.", len(masters), keep
|
||||
)
|
||||
|
||||
async with database._session() as session:
|
||||
for r in resumes:
|
||||
session.add(
|
||||
Resume(
|
||||
resume_id=r["resume_id"],
|
||||
content=r.get("content", ""),
|
||||
content_type=r.get("content_type", "md"),
|
||||
filename=r.get("filename"),
|
||||
is_master=bool(r.get("is_master", False)),
|
||||
parent_id=r.get("parent_id"),
|
||||
processed_data=r.get("processed_data"),
|
||||
processing_status=r.get("processing_status", "pending"),
|
||||
cover_letter=r.get("cover_letter"),
|
||||
outreach_message=r.get("outreach_message"),
|
||||
interview_prep=r.get("interview_prep"),
|
||||
title=r.get("title"),
|
||||
original_markdown=r.get("original_markdown"),
|
||||
created_at=r.get("created_at") or _utcnow_iso(),
|
||||
updated_at=r.get("updated_at") or r.get("created_at") or _utcnow_iso(),
|
||||
)
|
||||
)
|
||||
for j in jobs:
|
||||
meta = {k: v for k, v in j.items() if k not in _JOB_CORE_FIELDS}
|
||||
session.add(
|
||||
Job(
|
||||
job_id=j["job_id"],
|
||||
content=j.get("content", ""),
|
||||
resume_id=j.get("resume_id"),
|
||||
created_at=j.get("created_at") or _utcnow_iso(),
|
||||
metadata_json=meta,
|
||||
)
|
||||
)
|
||||
for imp in improvements:
|
||||
session.add(
|
||||
Improvement(
|
||||
request_id=imp["request_id"],
|
||||
original_resume_id=imp.get("original_resume_id", ""),
|
||||
tailored_resume_id=imp.get("tailored_resume_id", ""),
|
||||
job_id=imp.get("job_id", ""),
|
||||
improvements=imp.get("improvements", []),
|
||||
created_at=imp.get("created_at") or _utcnow_iso(),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Rename the legacy file so a restart doesn't re-trigger and we keep a
|
||||
# rollback artifact.
|
||||
migrated = legacy.with_suffix(legacy.suffix + ".migrated")
|
||||
try:
|
||||
legacy.rename(migrated)
|
||||
except OSError as e:
|
||||
logger.warning("Could not rename legacy DB file: %s", e)
|
||||
|
||||
summary = {
|
||||
"status": "migrated",
|
||||
"resumes": len(resumes),
|
||||
"jobs": len(jobs),
|
||||
"improvements": len(improvements),
|
||||
}
|
||||
logger.info("TinyDB → SQLite import complete: %s", summary)
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Console entry point for a manual run."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
result = asyncio.run(migrate())
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Business logic services."""
|
||||
|
||||
from app.services.parser import parse_document, parse_resume_to_json
|
||||
from app.services.improver import improve_resume, generate_improvements
|
||||
from app.services.refiner import refine_resume
|
||||
|
||||
__all__ = [
|
||||
"parse_document",
|
||||
"parse_resume_to_json",
|
||||
"improve_resume",
|
||||
"generate_improvements",
|
||||
"refine_resume",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""ATS score computation utilities.
|
||||
|
||||
Calculates an ATS-style breakdown score from already-processed resume and job data:
|
||||
- keyword_match: final keyword match % from the refinement pipeline
|
||||
- skills_coverage: overlap between resume technical skills and JD required skills
|
||||
- section_completeness: presence of essential resume sections (local, no LLM)
|
||||
|
||||
The overall_score is a weighted composite of the three sub-scores.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Weights must sum to 1.0
|
||||
_WEIGHTS = {
|
||||
"keyword_match": 0.55,
|
||||
"skills_coverage": 0.25,
|
||||
"section_completeness": 0.20,
|
||||
}
|
||||
|
||||
# Patterns to detect resume section headings
|
||||
_SECTION_PATTERNS = {
|
||||
"summary": ["summary", "objective", "profile", "about"],
|
||||
"experience": ["experience", "work history", "employment"],
|
||||
"education": ["education", "academic", "degree"],
|
||||
"skills": ["skills", "technologies", "competencies", "technical"],
|
||||
}
|
||||
|
||||
|
||||
def _extract_all_text(data: dict[str, Any]) -> str:
|
||||
"""Flatten all string values from a resume dict into a single text block."""
|
||||
parts: list[str] = []
|
||||
|
||||
def _walk(obj: Any) -> None:
|
||||
if isinstance(obj, str):
|
||||
parts.append(obj)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_walk(item)
|
||||
elif isinstance(obj, dict):
|
||||
for v in obj.values():
|
||||
_walk(v)
|
||||
|
||||
_walk(data)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _keyword_in_text(keyword: str, text_lower: str) -> bool:
|
||||
"""Whole-word match against pre-lowercased text to avoid false positives.
|
||||
|
||||
Args:
|
||||
keyword: The keyword to search for (will be lowercased internally).
|
||||
text_lower: Full text that has already been lowercased by the caller.
|
||||
"""
|
||||
escaped = re.escape(keyword.strip().lower())
|
||||
if not escaped:
|
||||
return False
|
||||
return bool(re.search(rf"(?<!\w){escaped}(?!\w)", text_lower))
|
||||
|
||||
|
||||
def _compute_skills_coverage(
|
||||
resume: dict[str, Any],
|
||||
job_keywords: dict[str, Any],
|
||||
) -> float:
|
||||
"""Return skills coverage score (0–100).
|
||||
|
||||
Checks how many required_skills / preferred_skills from the JD appear
|
||||
in the resume's technicalSkills list (falls back to full-text search).
|
||||
"""
|
||||
jd_skills: list[str] = []
|
||||
jd_skills.extend(job_keywords.get("required_skills", []))
|
||||
jd_skills.extend(job_keywords.get("preferred_skills", []))
|
||||
|
||||
if not jd_skills:
|
||||
return 0.0
|
||||
|
||||
resume_skills: list[str] = (
|
||||
resume.get("additional", {}).get("technicalSkills", []) or []
|
||||
)
|
||||
resume_text = _extract_all_text(resume).lower()
|
||||
resume_skills_lower = {s.lower() for s in resume_skills if isinstance(s, str)}
|
||||
|
||||
matched = 0
|
||||
for skill in jd_skills:
|
||||
if not isinstance(skill, str):
|
||||
continue
|
||||
skill_lower = skill.lower()
|
||||
# Direct skill list match or whole-word text match (resume_text is pre-lowercased)
|
||||
if skill_lower in resume_skills_lower or _keyword_in_text(skill, resume_text):
|
||||
matched += 1
|
||||
|
||||
return min(100.0, (matched / len(jd_skills)) * 100)
|
||||
|
||||
|
||||
def _compute_section_completeness(resume: dict[str, Any]) -> float:
|
||||
"""Return section completeness score (0–100).
|
||||
|
||||
Checks the structured resume dict for the presence of key sections.
|
||||
If no structured sections are detected, falls back to scanning all
|
||||
extracted text for common section heading keywords.
|
||||
"""
|
||||
found = 0
|
||||
|
||||
# Structured-data fast path
|
||||
if resume.get("summary"):
|
||||
found += 1
|
||||
if resume.get("workExperience"):
|
||||
found += 1
|
||||
if resume.get("education"):
|
||||
found += 1
|
||||
skills = resume.get("additional", {}).get("technicalSkills", [])
|
||||
if skills:
|
||||
found += 1
|
||||
|
||||
# If none of the structured checks fired, fall back to text scanning
|
||||
if found == 0:
|
||||
text = _extract_all_text(resume).lower()
|
||||
for patterns in _SECTION_PATTERNS.values():
|
||||
if any(p in text for p in patterns):
|
||||
found += 1
|
||||
|
||||
total = len(_SECTION_PATTERNS) # 4
|
||||
return (found / total) * 100
|
||||
|
||||
|
||||
def _generate_recommendations(
|
||||
keyword_score: float,
|
||||
skills_score: float,
|
||||
section_score: float,
|
||||
missing_keywords: list[str],
|
||||
injectable_keywords: list[str],
|
||||
) -> list[str]:
|
||||
tips: list[str] = []
|
||||
|
||||
if keyword_score < 60 and missing_keywords:
|
||||
top = ", ".join(missing_keywords[:5])
|
||||
tips.append(f"Add these high-priority missing keywords: {top}.")
|
||||
|
||||
if injectable_keywords:
|
||||
top_injectable = ", ".join(injectable_keywords[:5])
|
||||
tips.append(
|
||||
f"The following skills are in your master resume but not in this tailored version — consider adding them: {top_injectable}."
|
||||
)
|
||||
|
||||
if skills_score < 60:
|
||||
tips.append(
|
||||
"Expand your Skills section to include more of the tools and technologies listed in the job description."
|
||||
)
|
||||
|
||||
if section_score < 75:
|
||||
tips.append(
|
||||
"Make sure your resume includes all key sections: Summary, Work Experience, Education, and Skills."
|
||||
)
|
||||
|
||||
if keyword_score >= 80 and skills_score >= 80:
|
||||
tips.append(
|
||||
"Strong keyword and skills alignment. Consider quantifying your achievements with metrics and numbers."
|
||||
)
|
||||
|
||||
if not tips:
|
||||
tips.append(
|
||||
"Your resume is well-aligned with the job description. Review for any niche certifications or tools to add."
|
||||
)
|
||||
|
||||
return tips
|
||||
|
||||
|
||||
def compute_ats_score(
|
||||
refined_resume: dict[str, Any],
|
||||
job_keywords: dict[str, Any],
|
||||
keyword_match_percentage: float,
|
||||
missing_keywords: list[str],
|
||||
injectable_keywords: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Compute the ATS score breakdown dict.
|
||||
|
||||
Args:
|
||||
refined_resume: The fully refined resume data dict.
|
||||
job_keywords: Extracted JD keywords dict (required_skills, preferred_skills, …).
|
||||
keyword_match_percentage: Final keyword match % from refiner.calculate_keyword_match.
|
||||
missing_keywords: Keywords absent from the tailored resume (non-injectable).
|
||||
injectable_keywords: Keywords absent but present in the master resume.
|
||||
|
||||
Returns:
|
||||
Dict with overall_score, sub_scores, missing_keywords,
|
||||
injectable_keywords, and recommendations.
|
||||
"""
|
||||
kw_score = min(100.0, max(0.0, keyword_match_percentage))
|
||||
sk_score = _compute_skills_coverage(refined_resume, job_keywords)
|
||||
sec_score = _compute_section_completeness(refined_resume)
|
||||
|
||||
overall = (
|
||||
kw_score * _WEIGHTS["keyword_match"]
|
||||
+ sk_score * _WEIGHTS["skills_coverage"]
|
||||
+ sec_score * _WEIGHTS["section_completeness"]
|
||||
)
|
||||
|
||||
return {
|
||||
"overall_score": round(overall, 1),
|
||||
"sub_scores": {
|
||||
"keyword_match": round(kw_score, 1),
|
||||
"skills_coverage": round(sk_score, 1),
|
||||
"section_completeness": round(sec_score, 1),
|
||||
},
|
||||
"missing_keywords": missing_keywords[:10],
|
||||
"injectable_keywords": injectable_keywords[:10],
|
||||
"recommendations": _generate_recommendations(
|
||||
kw_score,
|
||||
sk_score,
|
||||
sec_score,
|
||||
missing_keywords,
|
||||
injectable_keywords,
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Cover letter, outreach message, and resume title generation service."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.config import load_config_file
|
||||
from app.llm import complete
|
||||
from app.prompts.templates import (
|
||||
COVER_LETTER_PROMPT,
|
||||
GENERATE_TITLE_PROMPT,
|
||||
OUTREACH_MESSAGE_PROMPT,
|
||||
)
|
||||
from app.prompts import get_language_name
|
||||
|
||||
|
||||
def _resolve_feature_prompt(
|
||||
custom_key: str,
|
||||
default_template: str,
|
||||
) -> tuple[str, bool]:
|
||||
"""Resolve a feature-prompt template at runtime.
|
||||
|
||||
Returns ``(template, is_custom)``. If the stored custom prompt is
|
||||
empty or absent, returns the default template. The ``is_custom`` flag
|
||||
lets callers decide whether to fall back to the default on a format
|
||||
failure (defensive — save-time validation should have caught a
|
||||
malformed custom prompt).
|
||||
"""
|
||||
stored = load_config_file()
|
||||
custom = (stored.get(custom_key) or "").strip()
|
||||
if not custom:
|
||||
return default_template, False
|
||||
return custom, True
|
||||
|
||||
|
||||
async def generate_cover_letter(
|
||||
resume_data: dict[str, Any],
|
||||
job_description: str,
|
||||
language: str = "en",
|
||||
) -> str:
|
||||
"""Generate a cover letter based on resume and job description.
|
||||
|
||||
Args:
|
||||
resume_data: Structured resume data (ResumeData format)
|
||||
job_description: Target job description text
|
||||
language: Output language code (en, es, zh, ja)
|
||||
|
||||
Returns:
|
||||
Generated cover letter as plain text
|
||||
"""
|
||||
output_language = get_language_name(language)
|
||||
|
||||
template, is_custom = _resolve_feature_prompt(
|
||||
"cover_letter_prompt", COVER_LETTER_PROMPT
|
||||
)
|
||||
try:
|
||||
prompt = template.format(
|
||||
job_description=job_description,
|
||||
resume_data=json.dumps(resume_data),
|
||||
output_language=output_language,
|
||||
)
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
# str.format() raises KeyError for unknown placeholders, IndexError for
|
||||
# positional out-of-range, and ValueError for unmatched/invalid braces
|
||||
# (e.g., ``{foo``). If the failing template is the built-in default,
|
||||
# something is broken upstream and the caller should see it — re-raise.
|
||||
# If it's a user-supplied custom prompt, fall back to the default with a
|
||||
# warning so generation doesn't crash on out-of-band disk edits.
|
||||
if not is_custom:
|
||||
raise
|
||||
logging.warning(
|
||||
"Custom cover letter prompt failed to format (%s); falling back to default",
|
||||
e,
|
||||
)
|
||||
prompt = COVER_LETTER_PROMPT.format(
|
||||
job_description=job_description,
|
||||
resume_data=json.dumps(resume_data),
|
||||
output_language=output_language,
|
||||
)
|
||||
|
||||
result = await complete(
|
||||
prompt=prompt,
|
||||
system_prompt="You are a professional career coach and resume writer. Write compelling, personalized cover letters.",
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
return result.strip()
|
||||
|
||||
|
||||
async def generate_outreach_message(
|
||||
resume_data: dict[str, Any],
|
||||
job_description: str,
|
||||
language: str = "en",
|
||||
) -> str:
|
||||
"""Generate a cold outreach message for networking.
|
||||
|
||||
Args:
|
||||
resume_data: Structured resume data (ResumeData format)
|
||||
job_description: Target job description text
|
||||
language: Output language code (en, es, zh, ja)
|
||||
|
||||
Returns:
|
||||
Generated outreach message as plain text
|
||||
"""
|
||||
output_language = get_language_name(language)
|
||||
|
||||
template, is_custom = _resolve_feature_prompt(
|
||||
"outreach_message_prompt", OUTREACH_MESSAGE_PROMPT
|
||||
)
|
||||
try:
|
||||
prompt = template.format(
|
||||
job_description=job_description,
|
||||
resume_data=json.dumps(resume_data),
|
||||
output_language=output_language,
|
||||
)
|
||||
except (KeyError, IndexError, ValueError) as e:
|
||||
# See generate_cover_letter for rationale on the exception set.
|
||||
if not is_custom:
|
||||
raise
|
||||
logging.warning(
|
||||
"Custom outreach message prompt failed to format (%s); falling back to default",
|
||||
e,
|
||||
)
|
||||
prompt = OUTREACH_MESSAGE_PROMPT.format(
|
||||
job_description=job_description,
|
||||
resume_data=json.dumps(resume_data),
|
||||
output_language=output_language,
|
||||
)
|
||||
|
||||
result = await complete(
|
||||
prompt=prompt,
|
||||
system_prompt="You are a professional networking coach. Write genuine, engaging cold outreach messages.",
|
||||
max_tokens=1024,
|
||||
)
|
||||
|
||||
return result.strip()
|
||||
|
||||
|
||||
async def generate_resume_title(
|
||||
job_description: str,
|
||||
language: str = "en",
|
||||
) -> str:
|
||||
"""Generate a short descriptive title from a job description.
|
||||
|
||||
Args:
|
||||
job_description: Target job description text
|
||||
language: Output language code (en, es, zh, ja)
|
||||
|
||||
Returns:
|
||||
Generated title like "Senior Frontend Engineer @ Stripe"
|
||||
"""
|
||||
output_language = get_language_name(language)
|
||||
|
||||
prompt = GENERATE_TITLE_PROMPT.format(
|
||||
job_description=job_description,
|
||||
output_language=output_language,
|
||||
)
|
||||
|
||||
result = await complete(
|
||||
prompt=prompt,
|
||||
system_prompt="You extract job titles and company names from job descriptions.",
|
||||
max_tokens=60,
|
||||
temperature=0.3,
|
||||
)
|
||||
|
||||
# Strip quotes and whitespace, truncate to 80 chars
|
||||
title = result.strip().strip("\"'")
|
||||
return title[:80]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
"""Interview preparation generation service."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.llm import (
|
||||
complete_json,
|
||||
get_llm_config,
|
||||
get_model_name,
|
||||
get_safe_max_tokens,
|
||||
)
|
||||
from app.prompts import INTERVIEW_PREP_PROMPT, get_language_name
|
||||
from app.schemas import InterviewPrepData
|
||||
|
||||
|
||||
_JOB_DESCRIPTION_PROMPT_CHAR_LIMIT = 12_000
|
||||
_RESUME_DATA_PROMPT_CHAR_LIMIT = 30_000
|
||||
_TRUNCATION_NOTICE = (
|
||||
"[Content truncated for prompt length. Use only the visible evidence; "
|
||||
"do not infer or invent omitted details.]"
|
||||
)
|
||||
|
||||
|
||||
def _truncate_text_for_prompt(value: str, max_chars: int) -> str:
|
||||
"""Bound unstructured prompt input while making omissions explicit."""
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return f"{value[:max_chars].rstrip()}\n\n{_TRUNCATION_NOTICE}"
|
||||
|
||||
|
||||
def _truncate_json_value(
|
||||
value: Any,
|
||||
*,
|
||||
max_string_chars: int,
|
||||
max_list_items: int,
|
||||
) -> Any:
|
||||
if isinstance(value, str):
|
||||
return _truncate_text_for_prompt(value, max_string_chars)
|
||||
if isinstance(value, list):
|
||||
truncated = [
|
||||
_truncate_json_value(
|
||||
item,
|
||||
max_string_chars=max_string_chars,
|
||||
max_list_items=max_list_items,
|
||||
)
|
||||
for item in value[:max_list_items]
|
||||
]
|
||||
if len(value) > max_list_items:
|
||||
truncated.append(
|
||||
{
|
||||
"_prompt_truncation_notice": (
|
||||
f"{len(value) - max_list_items} additional items omitted. "
|
||||
"Do not infer omitted details."
|
||||
)
|
||||
}
|
||||
)
|
||||
return truncated
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _truncate_json_value(
|
||||
item,
|
||||
max_string_chars=max_string_chars,
|
||||
max_list_items=max_list_items,
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_resume_data_for_prompt(resume_data: dict[str, Any]) -> str:
|
||||
resume_json = json.dumps(resume_data, ensure_ascii=False)
|
||||
if len(resume_json) <= _RESUME_DATA_PROMPT_CHAR_LIMIT:
|
||||
return resume_json
|
||||
|
||||
for max_string_chars, max_list_items in ((2_000, 30), (1_000, 20), (500, 10)):
|
||||
bounded = _truncate_json_value(
|
||||
resume_data,
|
||||
max_string_chars=max_string_chars,
|
||||
max_list_items=max_list_items,
|
||||
)
|
||||
bounded_json = json.dumps(bounded, ensure_ascii=False)
|
||||
if len(bounded_json) <= _RESUME_DATA_PROMPT_CHAR_LIMIT:
|
||||
return bounded_json
|
||||
|
||||
compact_snapshot = json.dumps(
|
||||
_truncate_json_value(resume_data, max_string_chars=250, max_list_items=5),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"_prompt_truncation_notice": _TRUNCATION_NOTICE,
|
||||
"limited_resume_snapshot": _truncate_text_for_prompt(
|
||||
compact_snapshot,
|
||||
_RESUME_DATA_PROMPT_CHAR_LIMIT - 500,
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
async def generate_interview_prep(
|
||||
resume_data: dict[str, Any],
|
||||
job_description: str,
|
||||
language: str = "en",
|
||||
) -> InterviewPrepData:
|
||||
"""Generate structured interview preparation for a tailored resume."""
|
||||
prompt = INTERVIEW_PREP_PROMPT.format(
|
||||
job_description=_truncate_text_for_prompt(
|
||||
job_description,
|
||||
_JOB_DESCRIPTION_PROMPT_CHAR_LIMIT,
|
||||
),
|
||||
resume_data=_serialize_resume_data_for_prompt(resume_data),
|
||||
output_language=get_language_name(language),
|
||||
)
|
||||
config = get_llm_config()
|
||||
max_tokens = get_safe_max_tokens(get_model_name(config), requested=8192)
|
||||
|
||||
result = await complete_json(
|
||||
prompt=prompt,
|
||||
system_prompt=(
|
||||
"You are a career interview coach. Output truthful, resume-grounded "
|
||||
"interview preparation as JSON only."
|
||||
),
|
||||
max_tokens=max_tokens,
|
||||
schema_type="interview_prep",
|
||||
)
|
||||
|
||||
return InterviewPrepData.model_validate(result)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Document parsing service using markitdown and LLM."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from markitdown import MarkItDown
|
||||
|
||||
from app.llm import complete_json, get_llm_config, get_model_name, get_safe_max_tokens
|
||||
from app.prompts import PARSE_RESUME_PROMPT
|
||||
from app.prompts.templates import RESUME_SCHEMA_EXAMPLE
|
||||
from app.schemas import ResumeData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches date ranges like "Jan 2020 - Dec 2023", "May 2021 - Present",
|
||||
# "January 2020 - Current", and single dates like "Jun 2023".
|
||||
_MD_DATE_RE = re.compile(
|
||||
r"(?:(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?"
|
||||
r"|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?"
|
||||
r"|Dec(?:ember)?)"
|
||||
r"\.?\s+\d{4})"
|
||||
r"(?:\s*[-–—]\s*"
|
||||
r"(?:(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?"
|
||||
r"|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?"
|
||||
r"|Dec(?:ember)?)"
|
||||
r"\.?\s+\d{4}"
|
||||
r"|Present|Current|Now|Ongoing))?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_markdown_dates(markdown: str) -> list[str]:
|
||||
"""Extract all month-inclusive date ranges from markdown text."""
|
||||
return _MD_DATE_RE.findall(markdown)
|
||||
|
||||
|
||||
def restore_dates_from_markdown(
|
||||
parsed_data: dict[str, Any],
|
||||
markdown: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Patch year-only dates in parsed data with month-inclusive dates from markdown.
|
||||
|
||||
The LLM sometimes drops months during parsing (e.g. "Jun 2020 - Aug 2021"
|
||||
becomes "2020 - 2021"). This function extracts all month-inclusive dates
|
||||
from the raw markdown and replaces year-only entries where a match exists.
|
||||
"""
|
||||
md_dates = _extract_markdown_dates(markdown)
|
||||
if not md_dates:
|
||||
return parsed_data
|
||||
|
||||
# Build a lookup: "2020 - 2021" → "Jun 2020 - Aug 2021"
|
||||
year_to_full: dict[str, str] = {}
|
||||
year_only_re = re.compile(r"\d{4}")
|
||||
for md_date in md_dates:
|
||||
years_in_date = year_only_re.findall(md_date)
|
||||
if years_in_date:
|
||||
# Create year-only key like "2020 - 2021" or "2023"
|
||||
year_key = " - ".join(years_in_date)
|
||||
# Keep the first (most specific) match
|
||||
if year_key not in year_to_full:
|
||||
# Normalize separators
|
||||
normalized = re.sub(r"\s*[-–—]\s*", " - ", md_date.strip())
|
||||
year_to_full[year_key] = normalized
|
||||
|
||||
if not year_to_full:
|
||||
return parsed_data
|
||||
|
||||
patched = 0
|
||||
for section_key in ("workExperience", "education", "personalProjects"):
|
||||
for entry in parsed_data.get(section_key, []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
years = entry.get("years", "")
|
||||
if not isinstance(years, str) or not years:
|
||||
continue
|
||||
# Skip if already has months
|
||||
if re.search(
|
||||
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)",
|
||||
years,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
continue
|
||||
# Try to find a matching month-inclusive date
|
||||
if years in year_to_full:
|
||||
entry["years"] = year_to_full[years]
|
||||
patched += 1
|
||||
|
||||
# Custom sections
|
||||
custom = parsed_data.get("customSections", {})
|
||||
if isinstance(custom, dict):
|
||||
for section in custom.values():
|
||||
if not isinstance(section, dict) or section.get("sectionType") != "itemList":
|
||||
continue
|
||||
for item in section.get("items", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
years = item.get("years", "")
|
||||
if not isinstance(years, str) or not years:
|
||||
continue
|
||||
if re.search(
|
||||
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)",
|
||||
years,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
continue
|
||||
if years in year_to_full:
|
||||
item["years"] = year_to_full[years]
|
||||
patched += 1
|
||||
|
||||
if patched:
|
||||
logger.info("Restored months in %d date fields from raw markdown", patched)
|
||||
|
||||
return parsed_data
|
||||
|
||||
|
||||
async def parse_document(content: bytes, filename: str) -> str:
|
||||
"""Convert PDF/DOCX to Markdown using markitdown.
|
||||
|
||||
Args:
|
||||
content: Raw file bytes
|
||||
filename: Original filename for extension detection
|
||||
|
||||
Returns:
|
||||
Markdown text content
|
||||
"""
|
||||
suffix = Path(filename).suffix.lower()
|
||||
|
||||
# Write to temp file for markitdown
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
md = MarkItDown()
|
||||
result = md.convert(str(tmp_path))
|
||||
return result.text_content
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
async def parse_resume_to_json(markdown_text: str) -> dict[str, Any]:
|
||||
"""Parse resume markdown to structured JSON using LLM.
|
||||
|
||||
After LLM parsing, patches any year-only dates with month-inclusive
|
||||
dates extracted from the raw markdown. This ensures months are never
|
||||
lost regardless of LLM behavior.
|
||||
|
||||
Args:
|
||||
markdown_text: Resume content in markdown format
|
||||
|
||||
Returns:
|
||||
Structured resume data matching ResumeData schema
|
||||
"""
|
||||
prompt = PARSE_RESUME_PROMPT.format(
|
||||
schema=RESUME_SCHEMA_EXAMPLE,
|
||||
resume_text=markdown_text,
|
||||
)
|
||||
|
||||
config = get_llm_config()
|
||||
model_name = get_model_name(config)
|
||||
result = await complete_json(
|
||||
prompt=prompt,
|
||||
system_prompt="You are a JSON extraction engine. Output only valid JSON, no explanations.",
|
||||
max_tokens=get_safe_max_tokens(model_name),
|
||||
retries=3,
|
||||
)
|
||||
|
||||
# Patch dates: restore months the LLM may have dropped
|
||||
result = restore_dates_from_markdown(result, markdown_text)
|
||||
|
||||
# Validate against schema
|
||||
validated = ResumeData.model_validate(result)
|
||||
return validated.model_dump()
|
||||
@@ -0,0 +1,702 @@
|
||||
"""Multi-pass resume refinement service.
|
||||
|
||||
This module provides functionality to refine an initially tailored resume through
|
||||
multiple passes:
|
||||
1. Keyword injection - add missing JD keywords where supported by master resume
|
||||
2. AI phrase removal - replace AI-generated buzzwords with simpler alternatives
|
||||
3. Master alignment validation - ensure no fabricated content was added
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from app.llm import complete_json
|
||||
from app.prompts.refinement import (
|
||||
AI_PHRASE_BLACKLIST,
|
||||
AI_PHRASE_REPLACEMENTS,
|
||||
KEYWORD_INJECTION_PROMPT,
|
||||
)
|
||||
from app.schemas.refinement import (
|
||||
AlignmentReport,
|
||||
AlignmentViolation,
|
||||
KeywordGapAnalysis,
|
||||
RefinementConfig,
|
||||
RefinementResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# LLM-012: Job description truncation limits
|
||||
MAX_JD_LENGTH = 2000
|
||||
MIN_TRUNCATION_WARNING_LENGTH = 1500
|
||||
|
||||
|
||||
def _keyword_in_text(keyword: str, text: str) -> bool:
|
||||
"""Check if keyword exists as a whole term in text.
|
||||
|
||||
SVC-010: Uses term boundaries instead of substring matching to avoid
|
||||
false positives like 'python' matching 'pythonic' or 'go' matching 'going'.
|
||||
"""
|
||||
escaped = re.escape(keyword.strip().lower())
|
||||
if not escaped:
|
||||
return False
|
||||
pattern = rf"(?<!\w){escaped}(?!\w)"
|
||||
return bool(re.search(pattern, text.lower()))
|
||||
|
||||
|
||||
def _normalize_skill_key(skill: str) -> str:
|
||||
"""Normalize a skill for case-insensitive comparisons."""
|
||||
return re.sub(r"\s+", " ", skill.strip()).casefold()
|
||||
|
||||
|
||||
def _extract_jd_skill_keys(
|
||||
job_keywords: dict[str, Any],
|
||||
job_description: str,
|
||||
) -> set[str]:
|
||||
"""Extract normalized required/preferred skills present in the raw JD."""
|
||||
keys: set[str] = set()
|
||||
for field in ("required_skills", "preferred_skills"):
|
||||
values = job_keywords.get(field, [])
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for value in values:
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and value.strip()
|
||||
and _keyword_in_text(value, job_description)
|
||||
):
|
||||
keys.add(_normalize_skill_key(value))
|
||||
return keys
|
||||
|
||||
|
||||
async def refine_resume(
|
||||
initial_tailored: dict[str, Any],
|
||||
master_resume: dict[str, Any],
|
||||
job_description: str,
|
||||
job_keywords: dict[str, Any],
|
||||
config: RefinementConfig | None = None,
|
||||
) -> RefinementResult:
|
||||
"""Multi-pass refinement of an initially tailored resume.
|
||||
|
||||
Args:
|
||||
initial_tailored: Output from improve_resume() first pass
|
||||
master_resume: Original master resume data (source of truth)
|
||||
job_description: Raw job description text
|
||||
job_keywords: Extracted job keywords
|
||||
config: Refinement configuration
|
||||
|
||||
Returns:
|
||||
RefinementResult with refined data and analysis
|
||||
"""
|
||||
if config is None:
|
||||
config = RefinementConfig()
|
||||
|
||||
current = _deep_copy(initial_tailored)
|
||||
passes = 0
|
||||
ai_phrases_found: list[str] = []
|
||||
keyword_analysis: KeywordGapAnalysis | None = None
|
||||
alignment: AlignmentReport | None = None
|
||||
|
||||
# Pass 1: Keyword injection (if enabled)
|
||||
if config.enable_keyword_injection:
|
||||
keyword_analysis = analyze_keyword_gaps(job_keywords, current, master_resume)
|
||||
if keyword_analysis.injectable_keywords:
|
||||
logger.info(
|
||||
"Injecting %d keywords: %s",
|
||||
len(keyword_analysis.injectable_keywords),
|
||||
keyword_analysis.injectable_keywords,
|
||||
)
|
||||
try:
|
||||
current = await inject_keywords(
|
||||
current,
|
||||
keyword_analysis.injectable_keywords,
|
||||
master_resume,
|
||||
job_description,
|
||||
)
|
||||
passes += 1
|
||||
except Exception as e:
|
||||
logger.warning("Keyword injection failed: %s", e)
|
||||
|
||||
# Pass 2: AI phrase removal and polish (local, no LLM call)
|
||||
if config.enable_ai_phrase_removal:
|
||||
current, removed = remove_ai_phrases(current, job_description)
|
||||
ai_phrases_found.extend(removed)
|
||||
if removed:
|
||||
logger.info("Removed %d AI phrases: %s", len(removed), removed)
|
||||
passes += 1
|
||||
|
||||
# Pass 3: Master alignment validation
|
||||
# LLM-008: Alignment validation is MANDATORY - not optional fallback
|
||||
if config.enable_master_alignment_check:
|
||||
alignment = validate_master_alignment(
|
||||
current,
|
||||
master_resume,
|
||||
allowed_new_skills=_extract_jd_skill_keys(
|
||||
job_keywords,
|
||||
job_description,
|
||||
),
|
||||
)
|
||||
if not alignment.is_aligned:
|
||||
# Count critical violations
|
||||
critical_violations = [
|
||||
v for v in alignment.violations if v.severity == "critical"
|
||||
]
|
||||
logger.warning(
|
||||
"Alignment violations found: %d total, %d critical",
|
||||
len(alignment.violations),
|
||||
len(critical_violations),
|
||||
)
|
||||
|
||||
if critical_violations:
|
||||
# LLM-008: Remove fabricated content before returning
|
||||
logger.error(
|
||||
"Alignment violations found - removing fabricated content: %s",
|
||||
[v.value for v in critical_violations],
|
||||
)
|
||||
# Fix violations before returning
|
||||
current = fix_alignment_violations(current, alignment.violations)
|
||||
passes += 1
|
||||
else:
|
||||
# Non-critical violations - fix and continue
|
||||
current = fix_alignment_violations(current, alignment.violations)
|
||||
passes += 1
|
||||
|
||||
# Calculate final match percentage
|
||||
final_match = calculate_keyword_match(current, job_keywords)
|
||||
|
||||
return RefinementResult(
|
||||
refined_data=current,
|
||||
passes_completed=passes,
|
||||
keyword_analysis=keyword_analysis,
|
||||
alignment_report=alignment,
|
||||
ai_phrases_removed=ai_phrases_found,
|
||||
final_match_percentage=final_match,
|
||||
)
|
||||
|
||||
|
||||
def analyze_keyword_gaps(
|
||||
jd_keywords: dict[str, Any],
|
||||
tailored: dict[str, Any],
|
||||
master: dict[str, Any],
|
||||
) -> KeywordGapAnalysis:
|
||||
"""Analyze which JD keywords are missing from the tailored resume.
|
||||
|
||||
Args:
|
||||
jd_keywords: Extracted job keywords with required_skills, preferred_skills, etc.
|
||||
tailored: Current tailored resume data
|
||||
master: Master resume data (source of truth)
|
||||
|
||||
Returns:
|
||||
KeywordGapAnalysis with missing, injectable, and non-injectable keywords
|
||||
"""
|
||||
# Extract text content from resumes
|
||||
tailored_text = _extract_all_text(tailored).lower()
|
||||
master_text = _extract_all_text(master).lower()
|
||||
|
||||
# Get all keywords from JD
|
||||
all_jd_keywords: set[str] = set()
|
||||
all_jd_keywords.update(jd_keywords.get("required_skills", []))
|
||||
all_jd_keywords.update(jd_keywords.get("preferred_skills", []))
|
||||
all_jd_keywords.update(jd_keywords.get("keywords", []))
|
||||
|
||||
# Find missing keywords
|
||||
missing: list[str] = []
|
||||
injectable: list[str] = []
|
||||
non_injectable: list[str] = []
|
||||
|
||||
for keyword in all_jd_keywords:
|
||||
if not _keyword_in_text(keyword, tailored_text):
|
||||
missing.append(keyword)
|
||||
if _keyword_in_text(keyword, master_text):
|
||||
injectable.append(keyword)
|
||||
else:
|
||||
non_injectable.append(keyword)
|
||||
|
||||
# Calculate percentages
|
||||
total = len(all_jd_keywords) if all_jd_keywords else 1
|
||||
current_match = (total - len(missing)) / total * 100
|
||||
potential_match = (total - len(non_injectable)) / total * 100
|
||||
|
||||
return KeywordGapAnalysis(
|
||||
missing_keywords=missing,
|
||||
injectable_keywords=injectable,
|
||||
non_injectable_keywords=non_injectable,
|
||||
current_match_percentage=current_match,
|
||||
potential_match_percentage=potential_match,
|
||||
)
|
||||
|
||||
|
||||
def remove_ai_phrases(
|
||||
data: dict[str, Any],
|
||||
job_description: str = "",
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Remove AI-generated phrases from resume content.
|
||||
|
||||
This is a local operation that doesn't require an LLM call.
|
||||
It performs case-insensitive replacement of blacklisted phrases.
|
||||
Phrases that appear in the job description are protected from removal.
|
||||
|
||||
Args:
|
||||
data: Resume data dictionary
|
||||
job_description: Job description text; phrases found here are skipped
|
||||
|
||||
Returns:
|
||||
Tuple of (cleaned data, list of removed phrases)
|
||||
"""
|
||||
# Build set of JD-protected phrases
|
||||
jd_lower = job_description.lower()
|
||||
jd_protected: set[str] = set()
|
||||
for phrase in AI_PHRASE_BLACKLIST:
|
||||
if phrase.lower() in jd_lower:
|
||||
jd_protected.add(phrase.lower())
|
||||
|
||||
if jd_protected:
|
||||
logger.info("JD-protected phrases (skipping removal): %s", jd_protected)
|
||||
|
||||
# Use a set to avoid duplicate tracking
|
||||
removed: set[str] = set()
|
||||
|
||||
def clean_text(text: str) -> str:
|
||||
cleaned = text
|
||||
for phrase in AI_PHRASE_BLACKLIST:
|
||||
# Skip phrases that appear in the job description
|
||||
if phrase.lower() in jd_protected:
|
||||
continue
|
||||
if phrase.lower() in cleaned.lower():
|
||||
removed.add(phrase)
|
||||
replacement = AI_PHRASE_REPLACEMENTS.get(phrase.lower(), "")
|
||||
# Case-insensitive replacement
|
||||
pattern = re.compile(re.escape(phrase), re.IGNORECASE)
|
||||
cleaned = pattern.sub(replacement, cleaned)
|
||||
return cleaned
|
||||
|
||||
def clean_recursive(obj: Any) -> Any:
|
||||
if isinstance(obj, str):
|
||||
return clean_text(obj)
|
||||
elif isinstance(obj, list):
|
||||
return [clean_recursive(item) for item in obj]
|
||||
elif isinstance(obj, dict):
|
||||
return {k: clean_recursive(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
cleaned_data = clean_recursive(data)
|
||||
return cleaned_data, list(removed)
|
||||
|
||||
|
||||
def validate_master_alignment(
|
||||
tailored: dict[str, Any],
|
||||
master: dict[str, Any],
|
||||
allowed_new_skills: set[str] | None = None,
|
||||
) -> AlignmentReport:
|
||||
"""Verify tailored resume doesn't contain fabricated content.
|
||||
|
||||
Checks that all skills, certifications, and work experience companies
|
||||
in the tailored resume exist in the master resume.
|
||||
|
||||
Args:
|
||||
tailored: Tailored resume data
|
||||
master: Master resume data (source of truth)
|
||||
|
||||
Returns:
|
||||
AlignmentReport with violations and confidence score
|
||||
"""
|
||||
violations: list[AlignmentViolation] = []
|
||||
|
||||
# Check skills - use full resume text for broader matching
|
||||
tailored_skills = set(
|
||||
s.lower()
|
||||
for s in tailored.get("additional", {}).get("technicalSkills", [])
|
||||
if isinstance(s, str)
|
||||
)
|
||||
master_skills = set(
|
||||
s.lower()
|
||||
for s in master.get("additional", {}).get("technicalSkills", [])
|
||||
if isinstance(s, str)
|
||||
)
|
||||
allowed_skills = {
|
||||
_normalize_skill_key(skill)
|
||||
for skill in (allowed_new_skills or set())
|
||||
if isinstance(skill, str) and skill.strip()
|
||||
}
|
||||
master_full_text = _extract_all_text(master).lower()
|
||||
|
||||
for skill in tailored_skills - master_skills:
|
||||
if _normalize_skill_key(skill) in allowed_skills:
|
||||
continue
|
||||
# Check substring/containment: e.g. "Python" in "Python 3.x"
|
||||
has_substring_match = any(
|
||||
skill in ms or ms in skill for ms in master_skills if ms
|
||||
)
|
||||
# Check if skill appears anywhere in master resume text
|
||||
found_in_text = _keyword_in_text(skill, master_full_text)
|
||||
|
||||
if has_substring_match or found_in_text:
|
||||
violations.append(
|
||||
AlignmentViolation(
|
||||
field_path="additional.technicalSkills",
|
||||
violation_type="skill_variant",
|
||||
value=skill,
|
||||
severity="info",
|
||||
)
|
||||
)
|
||||
else:
|
||||
violations.append(
|
||||
AlignmentViolation(
|
||||
field_path="additional.technicalSkills",
|
||||
violation_type="fabricated_skill",
|
||||
value=skill,
|
||||
severity="critical",
|
||||
)
|
||||
)
|
||||
|
||||
# Check certifications
|
||||
tailored_certs = set(
|
||||
c.lower()
|
||||
for c in tailored.get("additional", {}).get("certificationsTraining", [])
|
||||
if isinstance(c, str)
|
||||
)
|
||||
master_certs = set(
|
||||
c.lower()
|
||||
for c in master.get("additional", {}).get("certificationsTraining", [])
|
||||
if isinstance(c, str)
|
||||
)
|
||||
|
||||
for cert in tailored_certs - master_certs:
|
||||
violations.append(
|
||||
AlignmentViolation(
|
||||
field_path="additional.certificationsTraining",
|
||||
violation_type="fabricated_cert",
|
||||
value=cert,
|
||||
severity="critical",
|
||||
)
|
||||
)
|
||||
|
||||
# Check work experience companies (should not add new companies)
|
||||
tailored_companies = set(
|
||||
exp.get("company", "").lower()
|
||||
for exp in tailored.get("workExperience", [])
|
||||
if isinstance(exp, dict)
|
||||
)
|
||||
master_companies = set(
|
||||
exp.get("company", "").lower()
|
||||
for exp in master.get("workExperience", [])
|
||||
if isinstance(exp, dict)
|
||||
)
|
||||
|
||||
for company in tailored_companies - master_companies:
|
||||
if company: # Skip empty strings
|
||||
violations.append(
|
||||
AlignmentViolation(
|
||||
field_path="workExperience",
|
||||
violation_type="fabricated_company",
|
||||
value=company,
|
||||
severity="critical",
|
||||
)
|
||||
)
|
||||
|
||||
is_aligned = len([v for v in violations if v.severity == "critical"]) == 0
|
||||
confidence = 1.0 - (len(violations) * 0.1) # Decrease confidence per violation
|
||||
|
||||
return AlignmentReport(
|
||||
is_aligned=is_aligned,
|
||||
violations=violations,
|
||||
confidence_score=max(0.0, confidence),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_job_description(job_description: str) -> tuple[str, bool]:
|
||||
"""LLM-012: Prepare job description for prompt, with truncation warning.
|
||||
|
||||
Returns:
|
||||
Tuple of (truncated_text, was_truncated)
|
||||
"""
|
||||
was_truncated = len(job_description) > MAX_JD_LENGTH
|
||||
|
||||
if was_truncated:
|
||||
logger.warning(
|
||||
"Job description truncated from %d to %d characters",
|
||||
len(job_description),
|
||||
MAX_JD_LENGTH,
|
||||
)
|
||||
|
||||
return job_description[:MAX_JD_LENGTH], was_truncated
|
||||
|
||||
|
||||
def _validate_resume_structure(data: dict[str, Any]) -> bool:
|
||||
"""LLM-014: Validate resume maintains required structure after keyword injection.
|
||||
|
||||
Returns:
|
||||
True if structure is valid, False otherwise.
|
||||
"""
|
||||
# Check for required top-level keys
|
||||
required_keys = ["personalInfo"]
|
||||
for key in required_keys:
|
||||
if key not in data:
|
||||
logger.warning("Resume structure invalid: missing '%s'", key)
|
||||
return False
|
||||
|
||||
# Check that arrays are still arrays
|
||||
array_fields = ["workExperience", "education", "personalProjects"]
|
||||
for field in array_fields:
|
||||
if field in data and not isinstance(data[field], list):
|
||||
logger.warning("Resume structure invalid: '%s' is not a list", field)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def inject_keywords(
|
||||
tailored: dict[str, Any],
|
||||
keywords_to_inject: list[str],
|
||||
master: dict[str, Any],
|
||||
job_description: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Use LLM to inject missing keywords into appropriate sections.
|
||||
|
||||
Args:
|
||||
tailored: Current tailored resume
|
||||
keywords_to_inject: Keywords that are in master but missing from tailored
|
||||
master: Master resume (source of truth)
|
||||
job_description: Job description for context
|
||||
|
||||
Returns:
|
||||
Updated resume data with keywords injected
|
||||
|
||||
LLM-012: Truncates job description with warning.
|
||||
LLM-014: Validates result structure before returning.
|
||||
"""
|
||||
# LLM-012: Prepare job description with truncation handling
|
||||
truncated_jd, was_truncated = _prepare_job_description(job_description)
|
||||
if was_truncated:
|
||||
logger.info(
|
||||
"Job description was truncated for keyword injection (original: %d chars)",
|
||||
len(job_description),
|
||||
)
|
||||
|
||||
prompt = KEYWORD_INJECTION_PROMPT.format(
|
||||
keywords_to_inject=json.dumps(keywords_to_inject),
|
||||
current_resume=json.dumps(tailored),
|
||||
master_resume=json.dumps(master),
|
||||
job_description=truncated_jd,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await complete_json(
|
||||
prompt=prompt,
|
||||
system_prompt=(
|
||||
"You are a resume editor. Inject keywords naturally without adding "
|
||||
"fabricated content. Return only valid JSON matching the input schema."
|
||||
),
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
# LLM-014: Validate the result maintains required structure
|
||||
if not isinstance(result, dict):
|
||||
logger.warning("Keyword injection returned non-dict: %s", type(result))
|
||||
return tailored
|
||||
|
||||
if not _validate_resume_structure(result):
|
||||
logger.warning(
|
||||
"Keyword injection corrupted resume structure, using original"
|
||||
)
|
||||
return tailored
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Keyword injection failed: %s", e)
|
||||
return tailored
|
||||
|
||||
|
||||
def fix_alignment_violations(
|
||||
tailored: dict[str, Any],
|
||||
violations: list[AlignmentViolation],
|
||||
) -> dict[str, Any]:
|
||||
"""Remove or correct alignment violations.
|
||||
|
||||
This is a local operation that removes fabricated content.
|
||||
|
||||
Args:
|
||||
tailored: Tailored resume data
|
||||
violations: List of alignment violations to fix
|
||||
|
||||
Returns:
|
||||
Fixed resume data
|
||||
"""
|
||||
fixed = _deep_copy(tailored)
|
||||
|
||||
for violation in violations:
|
||||
if violation.severity != "critical":
|
||||
continue
|
||||
|
||||
if violation.violation_type == "fabricated_skill":
|
||||
skills = fixed.get("additional", {}).get("technicalSkills", [])
|
||||
fixed.setdefault("additional", {})["technicalSkills"] = [
|
||||
s for s in skills if s.lower() != violation.value.lower()
|
||||
]
|
||||
|
||||
elif violation.violation_type == "fabricated_cert":
|
||||
certs = fixed.get("additional", {}).get("certificationsTraining", [])
|
||||
fixed.setdefault("additional", {})["certificationsTraining"] = [
|
||||
c for c in certs if c.lower() != violation.value.lower()
|
||||
]
|
||||
|
||||
elif violation.violation_type == "fabricated_company":
|
||||
# SVC-002: Remove the fabricated work experience entry
|
||||
logger.error("Critical: Fabricated company detected: %s", violation.value)
|
||||
if "workExperience" in fixed:
|
||||
fixed["workExperience"] = [
|
||||
exp
|
||||
for exp in fixed["workExperience"]
|
||||
if exp.get("company", "").lower() != violation.value.lower()
|
||||
]
|
||||
logger.info(
|
||||
"Removed fabricated company '%s' from resume",
|
||||
violation.value,
|
||||
)
|
||||
|
||||
return fixed
|
||||
|
||||
|
||||
def calculate_keyword_match(
|
||||
resume: dict[str, Any],
|
||||
jd_keywords: dict[str, Any],
|
||||
) -> float:
|
||||
"""Calculate keyword match percentage.
|
||||
|
||||
Args:
|
||||
resume: Resume data dictionary
|
||||
jd_keywords: Extracted job keywords
|
||||
|
||||
Returns:
|
||||
Match percentage (0.0 to 100.0)
|
||||
"""
|
||||
resume_text = _extract_all_text(resume).lower()
|
||||
|
||||
all_keywords: set[str] = set()
|
||||
all_keywords.update(jd_keywords.get("required_skills", []))
|
||||
all_keywords.update(jd_keywords.get("preferred_skills", []))
|
||||
all_keywords.update(jd_keywords.get("keywords", []))
|
||||
|
||||
# SVC-009: Return 0% if no keywords (not 100% - that's misleading)
|
||||
if not all_keywords:
|
||||
logger.warning("No keywords found in job description")
|
||||
return 0.0
|
||||
|
||||
# SVC-010: Use word boundary matching instead of substring
|
||||
matched = sum(1 for kw in all_keywords if _keyword_in_text(kw, resume_text))
|
||||
return (matched / len(all_keywords)) * 100
|
||||
|
||||
|
||||
def _extract_all_text(data: dict[str, Any]) -> str:
|
||||
"""Extract all text content from resume data for keyword matching.
|
||||
|
||||
SVC-011: Uses caching to avoid repeated extraction on same resume data.
|
||||
|
||||
Args:
|
||||
data: Resume data dictionary
|
||||
|
||||
Returns:
|
||||
Concatenated text from all resume sections
|
||||
"""
|
||||
# Create a cache key from the data
|
||||
data_json = json.dumps(data, sort_keys=True, default=str)
|
||||
return _extract_all_text_cached(data_json)
|
||||
|
||||
|
||||
@lru_cache(maxsize=100)
|
||||
def _extract_all_text_cached(data_json: str) -> str:
|
||||
"""Cached implementation of text extraction.
|
||||
|
||||
SVC-011: LRU cache avoids re-extracting text from the same resume
|
||||
multiple times during a single refinement pass.
|
||||
"""
|
||||
data = json.loads(data_json)
|
||||
parts: list[str] = []
|
||||
|
||||
# Summary
|
||||
if data.get("summary"):
|
||||
parts.append(str(data["summary"]))
|
||||
|
||||
# Work experience
|
||||
for exp in data.get("workExperience", []):
|
||||
if isinstance(exp, dict):
|
||||
parts.append(str(exp.get("title", "")))
|
||||
parts.append(str(exp.get("company", "")))
|
||||
desc = exp.get("description", [])
|
||||
if isinstance(desc, list):
|
||||
parts.extend(str(d) for d in desc)
|
||||
|
||||
# Education
|
||||
for edu in data.get("education", []):
|
||||
if isinstance(edu, dict):
|
||||
parts.append(str(edu.get("degree", "")))
|
||||
parts.append(str(edu.get("institution", "")))
|
||||
if edu.get("description"):
|
||||
parts.append(str(edu["description"]))
|
||||
|
||||
# Projects
|
||||
for proj in data.get("personalProjects", []):
|
||||
if isinstance(proj, dict):
|
||||
parts.append(str(proj.get("name", "")))
|
||||
parts.append(str(proj.get("role", "")))
|
||||
desc = proj.get("description", [])
|
||||
if isinstance(desc, list):
|
||||
parts.extend(str(d) for d in desc)
|
||||
|
||||
# Additional
|
||||
additional = data.get("additional", {})
|
||||
if isinstance(additional, dict):
|
||||
skills = additional.get("technicalSkills", [])
|
||||
if isinstance(skills, list):
|
||||
parts.extend(str(s) for s in skills)
|
||||
certs = additional.get("certificationsTraining", [])
|
||||
if isinstance(certs, list):
|
||||
parts.extend(str(c) for c in certs)
|
||||
languages = additional.get("languages", [])
|
||||
if isinstance(languages, list):
|
||||
parts.extend(str(lang) for lang in languages)
|
||||
awards = additional.get("awards", [])
|
||||
if isinstance(awards, list):
|
||||
parts.extend(str(a) for a in awards)
|
||||
|
||||
# Custom sections
|
||||
custom_sections = data.get("customSections", {})
|
||||
if isinstance(custom_sections, dict):
|
||||
for section in custom_sections.values():
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
section_type = section.get("sectionType", "")
|
||||
if section_type == "itemList":
|
||||
for item in section.get("items", []):
|
||||
if isinstance(item, dict):
|
||||
parts.append(str(item.get("title", "")))
|
||||
parts.append(str(item.get("subtitle", "")))
|
||||
desc = item.get("description", [])
|
||||
if isinstance(desc, list):
|
||||
parts.extend(str(d) for d in desc)
|
||||
elif isinstance(desc, str):
|
||||
parts.append(desc)
|
||||
elif section_type == "text":
|
||||
text = section.get("text", "")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
elif section_type == "stringList":
|
||||
items = section.get("strings", [])
|
||||
if isinstance(items, list):
|
||||
parts.extend(str(i) for i in items)
|
||||
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _deep_copy(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a deep copy of a dictionary.
|
||||
|
||||
Uses copy.deepcopy for reliability. JSON serialization is avoided
|
||||
because it can't handle all Python types and loses type information.
|
||||
"""
|
||||
return copy.deepcopy(data)
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Service helpers for the adaptive resume wizard."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.config_cache import get_content_language
|
||||
from app.llm import _scrub_secrets, complete_json
|
||||
from app.prompts.resume_wizard import RESUME_WIZARD_TURN_PROMPT
|
||||
from app.prompts.templates import get_language_name
|
||||
from app.services.improver import _sanitize_user_input
|
||||
from app.schemas.models import (
|
||||
Education,
|
||||
Experience,
|
||||
Project,
|
||||
ResumeData,
|
||||
normalize_resume_data,
|
||||
)
|
||||
from app.schemas.resume_wizard import (
|
||||
ResumeWizardHistoryEntry,
|
||||
ResumeWizardProgress,
|
||||
ResumeWizardQuestion,
|
||||
ResumeWizardState,
|
||||
)
|
||||
|
||||
RESUME_WIZARD_MAX_QUESTIONS = 15
|
||||
_PROGRESS_BASELINE = 8
|
||||
|
||||
_VALID_SECTIONS = {
|
||||
"intro",
|
||||
"contact",
|
||||
"summary",
|
||||
"workExperience",
|
||||
"internships",
|
||||
"education",
|
||||
"personalProjects",
|
||||
"skills",
|
||||
"review",
|
||||
}
|
||||
|
||||
_INTRO_QUESTION = (
|
||||
"Hi — I'll help you build your master resume. "
|
||||
"What's your name, and what kind of role are you going for?"
|
||||
)
|
||||
|
||||
_SECTION_PROMPTS = {
|
||||
"intro": _INTRO_QUESTION,
|
||||
"contact": "What's the best email, phone, or links (LinkedIn / GitHub / site) to include?",
|
||||
"summary": "In a sentence or two, how would you describe yourself professionally?",
|
||||
"workExperience": (
|
||||
"Tell me about one role: title, company, dates, what you did, and any measurable impact."
|
||||
),
|
||||
"internships": (
|
||||
"Tell me about one internship: title, company, dates, what you worked on, "
|
||||
"and what changed because of it."
|
||||
),
|
||||
"education": (
|
||||
"Tell me about your education: school, degree, dates, and any honors or standout coursework."
|
||||
),
|
||||
"personalProjects": (
|
||||
"Tell me about one project: what you built, why it mattered, the tech you used, and any results."
|
||||
),
|
||||
"skills": "What tools, technologies, or skills do you want on your resume?",
|
||||
"review": "Let's review what's here before we create your master resume.",
|
||||
}
|
||||
|
||||
# The keyword ("my name", "name") may be lower- or upper-cased, but the captured
|
||||
# name must start uppercase — so we case the keyword explicitly with [Mm]/[Nn]
|
||||
# instead of re.IGNORECASE (which would let the [A-Z] capture match lowercase
|
||||
# words and produce false positives like "domain name facebook is" -> "facebook is").
|
||||
_INTRO_NAME_PATTERNS = (
|
||||
re.compile(r"\bI(?:'| a)m\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)?)"),
|
||||
re.compile(r"\b[Mm]y name is\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)?)"),
|
||||
re.compile(r"\b[Nn]ame(?:'s| is)?\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)?)"),
|
||||
)
|
||||
|
||||
|
||||
def section_prompt(section: str) -> str:
|
||||
"""Deterministic fallback question text for a section."""
|
||||
return _SECTION_PROMPTS.get(section, "What would you like to add next?")
|
||||
|
||||
|
||||
def valid_section(section: str) -> str:
|
||||
"""Clamp an LLM-provided section to a known value (defaults to review)."""
|
||||
return section if section in _VALID_SECTIONS else "review"
|
||||
|
||||
|
||||
def build_initial_wizard_state() -> ResumeWizardState:
|
||||
"""Build the first state shown to a user entering the wizard."""
|
||||
return ResumeWizardState(
|
||||
step="intro",
|
||||
resume_data=ResumeData(),
|
||||
current_question=ResumeWizardQuestion(text=_INTRO_QUESTION, section="intro"),
|
||||
progress=ResumeWizardProgress(current=0, total=_PROGRESS_BASELINE),
|
||||
)
|
||||
|
||||
|
||||
def extract_intro_name(answer: str) -> str:
|
||||
"""Extract a likely user name from the intro answer."""
|
||||
for pattern in _INTRO_NAME_PATTERNS:
|
||||
match = pattern.search(answer)
|
||||
if match:
|
||||
return match.group(1).strip().rstrip(".")
|
||||
return ""
|
||||
|
||||
|
||||
def merge_unique_skills(existing: list[str], inferred: list[str]) -> list[str]:
|
||||
"""Merge skills while preserving first-seen casing and order."""
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in [*existing, *inferred]:
|
||||
skill = item.strip()
|
||||
key = skill.casefold()
|
||||
if skill and key not in seen:
|
||||
merged.append(skill)
|
||||
seen.add(key)
|
||||
return merged
|
||||
|
||||
|
||||
def build_review_warnings(data: ResumeData) -> list[str]:
|
||||
"""Deterministic, gentle notes about useful resume facts that are missing."""
|
||||
warnings: list[str] = []
|
||||
info = data.personalInfo
|
||||
# Name is the one HARD requirement for finalize (the request 422s without it),
|
||||
# so surface it at review rather than letting the user hit a generic failure.
|
||||
if not info.name.strip():
|
||||
warnings.append("Add your name — it's required to create your resume.")
|
||||
contact = [
|
||||
info.email,
|
||||
info.phone,
|
||||
info.linkedin or "",
|
||||
info.github or "",
|
||||
info.website or "",
|
||||
]
|
||||
if not any(value.strip() for value in contact):
|
||||
warnings.append("Add at least one contact method (email, phone, or a link).")
|
||||
if not data.workExperience and not data.personalProjects:
|
||||
warnings.append("Add at least one experience, internship, or project.")
|
||||
if not data.education:
|
||||
warnings.append("Education is empty — skip only if that's intentional.")
|
||||
if not data.additional.technicalSkills:
|
||||
warnings.append("Skills are empty — add tools or technologies you've used.")
|
||||
return warnings
|
||||
|
||||
|
||||
def compute_progress(asked_count: int, is_complete: bool) -> ResumeWizardProgress:
|
||||
"""Server-side progress so the bar never trusts the model."""
|
||||
total = min(
|
||||
RESUME_WIZARD_MAX_QUESTIONS,
|
||||
max(_PROGRESS_BASELINE, asked_count + (0 if is_complete else 2)),
|
||||
)
|
||||
return ResumeWizardProgress(current=min(asked_count, total), total=total)
|
||||
|
||||
|
||||
def normalize_wizard_resume_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize wizard resume data through the shared resume schema."""
|
||||
normalized = normalize_resume_data(copy.deepcopy(data))
|
||||
return ResumeData.model_validate(normalized).model_dump()
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
"""Return string items from a list-like LLM field."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, str)]
|
||||
|
||||
|
||||
def _next_gap_section(data: ResumeData) -> str:
|
||||
"""Pick the next obviously-empty section, else review."""
|
||||
if not data.workExperience:
|
||||
return "workExperience"
|
||||
if not data.education:
|
||||
return "education"
|
||||
if not data.personalProjects:
|
||||
return "personalProjects"
|
||||
if not data.additional.technicalSkills:
|
||||
return "skills"
|
||||
return "review"
|
||||
|
||||
|
||||
def _merge_entries[T](
|
||||
existing: list[T],
|
||||
updated: list[T],
|
||||
key: Callable[[T], tuple[str, ...]],
|
||||
) -> list[T]:
|
||||
"""Union list entries by identity signature.
|
||||
|
||||
A partial model reply (e.g. it echoes only the role the user just described
|
||||
instead of the full list) must NOT erase earlier entries. So: existing
|
||||
entries the model omits are kept, entries it echoes (same signature) are
|
||||
replaced in place, and genuinely new entries are appended. Signatures are
|
||||
content-based rather than ``id``-based because wizard entry ids default to 0.
|
||||
"""
|
||||
result = list(existing)
|
||||
index: dict[tuple[str, ...], int] = {}
|
||||
for position, item in enumerate(result):
|
||||
index.setdefault(key(item), position)
|
||||
for item in updated:
|
||||
signature = key(item)
|
||||
if signature in index:
|
||||
result[index[signature]] = item
|
||||
else:
|
||||
index[signature] = len(result)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _experience_key(item: Experience) -> tuple[str, ...]:
|
||||
return (
|
||||
item.title.strip().casefold(),
|
||||
item.company.strip().casefold(),
|
||||
item.years.strip().casefold(),
|
||||
)
|
||||
|
||||
|
||||
def _education_key(item: Education) -> tuple[str, ...]:
|
||||
return (
|
||||
item.institution.strip().casefold(),
|
||||
item.degree.strip().casefold(),
|
||||
item.years.strip().casefold(),
|
||||
)
|
||||
|
||||
|
||||
def _project_key(item: Project) -> tuple[str, ...]:
|
||||
return (item.name.strip().casefold(), item.years.strip().casefold())
|
||||
|
||||
|
||||
def _merge_section(
|
||||
*,
|
||||
existing: ResumeData,
|
||||
updated: ResumeData,
|
||||
raw_updated: dict[str, Any],
|
||||
section: str,
|
||||
inferred_skills: list[str],
|
||||
) -> ResumeData:
|
||||
"""Merge LLM output ONLY into the active section, never clobbering the rest."""
|
||||
merged = existing.model_copy(deep=True)
|
||||
|
||||
if section in {"intro", "contact"}:
|
||||
if isinstance(raw_updated.get("personalInfo"), dict):
|
||||
for field in ("name", "title", "email", "phone", "location"):
|
||||
new_val = getattr(updated.personalInfo, field)
|
||||
if isinstance(new_val, str) and new_val.strip():
|
||||
setattr(merged.personalInfo, field, new_val)
|
||||
for field in ("website", "linkedin", "github"):
|
||||
new_val = getattr(updated.personalInfo, field)
|
||||
if new_val:
|
||||
setattr(merged.personalInfo, field, new_val)
|
||||
return merged
|
||||
|
||||
if section == "summary":
|
||||
if "summary" in raw_updated and updated.summary.strip():
|
||||
merged.summary = updated.summary
|
||||
return merged
|
||||
|
||||
if section in {"workExperience", "internships"}:
|
||||
if "workExperience" in raw_updated:
|
||||
merged.workExperience = _merge_entries(
|
||||
merged.workExperience, updated.workExperience, _experience_key
|
||||
)
|
||||
return merged
|
||||
|
||||
if section == "education":
|
||||
if "education" in raw_updated:
|
||||
merged.education = _merge_entries(
|
||||
merged.education, updated.education, _education_key
|
||||
)
|
||||
return merged
|
||||
|
||||
if section == "personalProjects":
|
||||
if "personalProjects" in raw_updated:
|
||||
merged.personalProjects = _merge_entries(
|
||||
merged.personalProjects, updated.personalProjects, _project_key
|
||||
)
|
||||
return merged
|
||||
|
||||
if section == "skills":
|
||||
raw_additional = raw_updated.get("additional")
|
||||
if isinstance(raw_additional, dict):
|
||||
if "technicalSkills" in raw_additional:
|
||||
merged.additional.technicalSkills = merge_unique_skills(
|
||||
merged.additional.technicalSkills,
|
||||
updated.additional.technicalSkills,
|
||||
)
|
||||
if "languages" in raw_additional:
|
||||
merged.additional.languages = merge_unique_skills(
|
||||
merged.additional.languages, updated.additional.languages
|
||||
)
|
||||
if "certificationsTraining" in raw_additional:
|
||||
merged.additional.certificationsTraining = merge_unique_skills(
|
||||
merged.additional.certificationsTraining,
|
||||
updated.additional.certificationsTraining,
|
||||
)
|
||||
if "awards" in raw_additional:
|
||||
merged.additional.awards = merge_unique_skills(
|
||||
merged.additional.awards, updated.additional.awards
|
||||
)
|
||||
merged.additional.technicalSkills = merge_unique_skills(
|
||||
merged.additional.technicalSkills, inferred_skills
|
||||
)
|
||||
return merged
|
||||
|
||||
# Unknown / review section: never mutate resume_data.
|
||||
return merged
|
||||
|
||||
|
||||
def _assign_entry_ids(data: ResumeData) -> None:
|
||||
"""Give every list entry a unique 1-based id (in place).
|
||||
|
||||
The LLM omits ``id`` (the wizard prompt's schema doesn't request it), so
|
||||
entries default to ``id=0``. Downstream consumers — the live preview's React
|
||||
keys and the builder's ``Math.max(...ids)+1`` add logic — assume unique ids,
|
||||
so renumber them deterministically by position (order is append-stable).
|
||||
"""
|
||||
for index, item in enumerate(data.workExperience, start=1):
|
||||
item.id = index
|
||||
for index, item in enumerate(data.education, start=1):
|
||||
item.id = index
|
||||
for index, item in enumerate(data.personalProjects, start=1):
|
||||
item.id = index
|
||||
|
||||
|
||||
def _next_question(result: dict[str, Any], data: ResumeData) -> ResumeWizardQuestion:
|
||||
"""Use the model's next_question, or fall back to the next empty section."""
|
||||
candidate = result.get("next_question")
|
||||
if isinstance(candidate, dict):
|
||||
text = candidate.get("text")
|
||||
section = candidate.get("section")
|
||||
if isinstance(text, str) and text.strip() and isinstance(section, str):
|
||||
return ResumeWizardQuestion(text=text.strip(), section=valid_section(section))
|
||||
gap = _next_gap_section(data)
|
||||
return ResumeWizardQuestion(text=section_prompt(gap), section=gap)
|
||||
|
||||
|
||||
async def run_ai_turn(
|
||||
state: ResumeWizardState,
|
||||
answer_text: str,
|
||||
*,
|
||||
skip: bool,
|
||||
) -> ResumeWizardState:
|
||||
"""Run one adaptive AI turn (answer or skip) and validate the result."""
|
||||
section = state.current_question.section
|
||||
resume_json = json.dumps(state.resume_data.model_dump(mode="json"), ensure_ascii=False)
|
||||
prompt_answer = (
|
||||
"(The user skipped this question. Do NOT modify resume_data. "
|
||||
"Ask the next most useful question for a different section.)"
|
||||
if skip
|
||||
# Strip prompt-injection patterns AND redact credential-like tokens
|
||||
# (sk-…/AIza…/Bearer …) before the answer reaches the LLM.
|
||||
else _scrub_secrets(_sanitize_user_input(answer_text))
|
||||
)
|
||||
prompt = RESUME_WIZARD_TURN_PROMPT.format(
|
||||
output_language=get_language_name(get_content_language()),
|
||||
current_section=section,
|
||||
resume_json=resume_json,
|
||||
answer_text=prompt_answer,
|
||||
)
|
||||
result = await complete_json(prompt, max_tokens=8192, schema_type="resume")
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError("Resume wizard LLM response must be a JSON object.")
|
||||
|
||||
raw_resume = result.get("resume_data")
|
||||
inferred = _string_list(result.get("inferred_skills"))
|
||||
|
||||
if skip or not isinstance(raw_resume, dict):
|
||||
data = state.resume_data.model_copy(deep=True)
|
||||
else:
|
||||
updated = ResumeData.model_validate(normalize_wizard_resume_data(raw_resume))
|
||||
data = _merge_section(
|
||||
existing=state.resume_data,
|
||||
updated=updated,
|
||||
raw_updated=raw_resume,
|
||||
section=section,
|
||||
inferred_skills=inferred,
|
||||
)
|
||||
|
||||
if section == "intro" and not data.personalInfo.name.strip():
|
||||
fallback = extract_intro_name(answer_text)
|
||||
if fallback:
|
||||
data.personalInfo.name = fallback
|
||||
|
||||
# Entries from the LLM default to id=0; give them unique ids so the preview
|
||||
# keys and the builder's id-based logic work on a finalized wizard resume.
|
||||
_assign_entry_ids(data)
|
||||
|
||||
asked_count = state.asked_count + 1
|
||||
# `is_complete` is a SUGGESTION to surface "Review & finish" — the step stays
|
||||
# "question" and never auto-finalizes. The client decides when to call /review.
|
||||
is_complete = bool(result.get("is_complete")) or asked_count >= RESUME_WIZARD_MAX_QUESTIONS
|
||||
|
||||
history = list(state.history)
|
||||
history.append(
|
||||
ResumeWizardHistoryEntry(
|
||||
question=state.current_question.text,
|
||||
answer="" if skip else answer_text,
|
||||
section=section,
|
||||
resume_data_before=state.resume_data,
|
||||
)
|
||||
)
|
||||
|
||||
return ResumeWizardState(
|
||||
step="question",
|
||||
resume_data=data,
|
||||
current_question=_next_question(result, data),
|
||||
history=history,
|
||||
asked_count=asked_count,
|
||||
inferred_skills=inferred,
|
||||
is_complete=is_complete,
|
||||
progress=compute_progress(asked_count, is_complete),
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
|
||||
def apply_back(state: ResumeWizardState) -> ResumeWizardState:
|
||||
"""Deterministically restore the previous question + draft snapshot."""
|
||||
if not state.history:
|
||||
return state.model_copy(deep=True)
|
||||
history = list(state.history)
|
||||
last = history.pop()
|
||||
asked_count = max(0, state.asked_count - 1)
|
||||
# Derive step from the restored question itself, not just the count, so a
|
||||
# restored non-intro question never renders under the intro step (which hides
|
||||
# the question-step actions).
|
||||
return ResumeWizardState(
|
||||
step="intro" if last.section == "intro" else "question",
|
||||
resume_data=last.resume_data_before,
|
||||
current_question=ResumeWizardQuestion(text=last.question, section=last.section),
|
||||
history=history,
|
||||
asked_count=asked_count,
|
||||
inferred_skills=[],
|
||||
is_complete=False,
|
||||
progress=compute_progress(asked_count, False),
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
|
||||
def apply_review(state: ResumeWizardState) -> ResumeWizardState:
|
||||
"""Move to the review step (no LLM call) and compute gentle warnings."""
|
||||
next_state = state.model_copy(deep=True)
|
||||
next_state.step = "review"
|
||||
next_state.current_question = ResumeWizardQuestion(
|
||||
text=section_prompt("review"), section="review"
|
||||
)
|
||||
next_state.warnings = build_review_warnings(next_state.resume_data)
|
||||
return next_state
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: monitor-e2e
|
||||
description: Run + judge the Resume-Matcher agentic end-to-end monitor. Drives the real app (master resume → 3–4 tailored variations → PDFs), captures an evidence bundle, then renders an evidence-cited verdict on output quality, flow/render integrity, and provider reality vs a committed baseline. Maintainer-only; makes real, billed LLM calls — do NOT run proactively.
|
||||
---
|
||||
|
||||
# monitor-e2e — agentic end-to-end monitor
|
||||
|
||||
You drive Resume-Matcher end to end, then judge the result. You are a REPORT, never a gate: you never block anything, never modify app code, and never refresh the baseline.
|
||||
|
||||
## 0. Preconditions (refuse otherwise)
|
||||
- This makes REAL, BILLED LLM calls and boots servers. Only run when the maintainer explicitly asks.
|
||||
- Requires `RM_E2E_MONITOR=1` and a configured LLM key (the harness gate enforces both — if it refuses, surface the message and stop).
|
||||
- Run from `apps/backend`. The optional PDF text probe needs `uv sync --extra dev --extra e2e-monitor` (keep `dev` so test deps aren't removed).
|
||||
|
||||
## 1. Run the sweep
|
||||
```
|
||||
cd apps/backend && RM_E2E_MONITOR=1 uv run python -m e2e_monitor sweep
|
||||
```
|
||||
Note the printed `bundle: artifacts/e2e-monitor/<run-id>/`. Everything you judge lives there.
|
||||
|
||||
## 2. Orient (cheap, first)
|
||||
Read `summary.json` and `baseline-diff.json` first: provider, variation count, `flow_all_passed`, `renders_non_blank`, `min_judge_score`, and any regressions vs the committed baseline.
|
||||
|
||||
## 3. Judge the three jobs — cite the artifact for every claim
|
||||
**A. Output quality** — for each `variations/<jd>/`: read `scores.json` (structural floor — `fabricated_employers` MUST be `[]`, `personal_info_unchanged` MUST be true, plus sections_preserved / is_valid_resume / jd_keyword_coverage) and `judge.json` (1–5 rubric). Open `tailored.json` vs `job_description.txt` and read for what a fixed rubric misses — is it a strong, TRUTHFUL tailoring for THIS jd? **JD-keyword policy (maintainer, 2026-06):** incorporating job-description keywords/skills the master lacked is EXPECTED ATS tailoring, not fabrication, up to ~`JD_KEYWORD_TOLERANCE` (see `judge.py`, currently 20%) of resume content — do not flag it. What stays a defect: invented employers, fabricated titles/dates, or a wholesale change of profession beyond that tolerance. The `product-manager` jd is the truthfulness stress test: a little PM-flavored wording is fine, but the tailoring must NOT manufacture a PM career the master never had (career-changer framing is the honest outcome).
|
||||
**B. Flow + render integrity** — read `flow-trace.json` (did every stage pass?) and each `render.json` (`non_blank`?). Then GREP `logs/backend.log` for `Traceback`, `ERROR`, ` 500 `, `TimeoutError`, `wait_for`, and swallowed exceptions. A 200 response can hide a broken PDF — trust the log + the non-blank check.
|
||||
**C. Provider reality** — note provider+model from `manifest.json`. Grep `logs/backend.log` for local-provider struggle fingerprints: JSON-mode fallback, truncation / `_appears_truncated`, content-quality retries, timeout escalation, retry exhaustion, Ollama `/api/show`. Even when output squeaks through, these show the provider straining. (To compare providers, the maintainer re-runs with config pointed at another provider and you diff the two bundles.)
|
||||
|
||||
## 4. Investigate anomalies
|
||||
For anything that looks off (a low judge score, a blank render, a log error), open that variation's files and the logs and dig in before concluding.
|
||||
|
||||
## 5. Write the report
|
||||
Write `report.md` INTO the bundle dir, plus a short session summary. Structure: a verdict per job (quality / flow-render / provider), regressions vs baseline with evidence citations (artifact paths inside the bundle), reproduction notes, and recommended fixes. Be specific; cite artifacts.
|
||||
|
||||
## Hard rules
|
||||
- NEVER modify app code or tests.
|
||||
- NEVER run `update-baseline` yourself — refreshing the golden is a deliberate maintainer commit.
|
||||
- You are advisory. Your output informs; it does not gate.
|
||||
@@ -0,0 +1,88 @@
|
||||
# e2e_monitor — agentic end-to-end monitor
|
||||
|
||||
An **opt-in, on-demand** harness that drives the real Resume-Matcher app end to end, captures a durable evidence bundle, and has a Claude Code skill judge it. It is a **report, never a gate** — it informs; it never blocks a push and is never wired into CI.
|
||||
|
||||
- Design spec: [`docs/superpowers/specs/2026-06-01-agentic-e2e-monitor-design.md`](../../../docs/superpowers/specs/2026-06-01-agentic-e2e-monitor-design.md)
|
||||
- Implementation plan: [`docs/superpowers/plans/2026-06-01-agentic-e2e-monitor.md`](../../../docs/superpowers/plans/2026-06-01-agentic-e2e-monitor.md)
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
uv sync --extra dev --extra e2e-monitor # keep dev so test deps / the pre-push gate keep working
|
||||
```
|
||||
|
||||
The `e2e-monitor` extra is only needed for the PDF text probe (pypdf-based non-blank check). The harness runs without it, degrading the non-blank check to a header+size heuristic. It is **not** part of the default `uv sync` or `--extra dev` (so random clones are unaffected) — sync it *alongside* `dev`, as above, so opting in doesn't remove your test deps (a bare `uv sync --extra e2e-monitor` would, and then the pre-push gate can't run pytest).
|
||||
|
||||
---
|
||||
|
||||
## Enable + run
|
||||
|
||||
```bash
|
||||
export RM_E2E_MONITOR=1
|
||||
cd apps/backend
|
||||
uv run python -m e2e_monitor sweep
|
||||
```
|
||||
|
||||
The harness gate requires **both** `RM_E2E_MONITOR=1` and a configured LLM key (via `LLM_API_KEY` or the project's config). If either is absent the gate refuses with an explanatory message — nothing runs.
|
||||
|
||||
The sweep:
|
||||
1. Seeds an isolated `DATA_DIR` (your real SQLite DB is never touched).
|
||||
2. Boots the backend in-process.
|
||||
3. Tailors the master resume against 3–4 bundled JDs.
|
||||
4. Attempts PDF renders (skipped/degraded if `node` or the frontend is absent).
|
||||
5. Scores each variation with the structural eval scorers.
|
||||
6. Calls the configured LLM as judge for rubric scoring.
|
||||
7. Writes a self-contained evidence bundle to `artifacts/e2e-monitor/<run-id>/`.
|
||||
8. Diffs against `baseline/baseline.json` and writes `baseline-diff.json`.
|
||||
|
||||
**The sweep only *captures* the bundle — it does not produce the verdict.** It's the deterministic half. The **agent in the loop** — a Claude Code instance, via the `/monitor-e2e` skill below or by just asking any Claude Code session to *"judge the latest e2e-monitor bundle"* — reads the bundle + logs, separates real issues from noise, and writes `report.md`. The sweep's terminal output narrates each move and points you to this handoff.
|
||||
|
||||
In practice the front door is **`/monitor-e2e`** (it runs the sweep *and* judges in one step); the bare CLI is the plumbing the agent drives — handy for a quick capture, or a background / cron run that an AI agent later picks up to debug while you work on the app as normal.
|
||||
|
||||
---
|
||||
|
||||
## Install the agent skill
|
||||
|
||||
The `monitor-e2e` Claude Code skill lives at `.claude/skills/monitor-e2e/SKILL.md` — **gitignored, never shipped to other clones**. Its committed source of truth is [`AGENT_PLAYBOOK.md`](AGENT_PLAYBOOK.md) in this directory. Install once per clone:
|
||||
|
||||
```bash
|
||||
bash apps/backend/e2e_monitor/install_skill.sh
|
||||
```
|
||||
|
||||
This copies `AGENT_PLAYBOOK.md` → `.claude/skills/monitor-e2e/SKILL.md`. Then invoke the `monitor-e2e` skill in Claude Code to get the judged report: it reads the bundle, judges the three runtime jobs (output quality, flow/render integrity, provider reality), and writes `report.md` into the bundle directory.
|
||||
|
||||
---
|
||||
|
||||
## Refresh the baseline
|
||||
|
||||
After a sweep whose output you are satisfied with, commit the new golden:
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
uv run python -m e2e_monitor update-baseline artifacts/e2e-monitor/<run-id>
|
||||
# review the diff, then:
|
||||
git add apps/backend/e2e_monitor/baseline/baseline.json
|
||||
git commit -m "chore(e2e-monitor): update baseline after <run-id>"
|
||||
```
|
||||
|
||||
**This is a deliberate, reviewed human action.** The agent skill never runs `update-baseline` — refreshing the golden is always a maintainer commit.
|
||||
|
||||
---
|
||||
|
||||
## OSS-safety model
|
||||
|
||||
This harness is designed so that cloning the repo and running normal workflows is completely unaffected:
|
||||
|
||||
| Mechanism | Effect |
|
||||
|---|---|
|
||||
| Optional extra (`--extra e2e-monitor`) | Not pulled in by `uv sync` or `--extra dev` |
|
||||
| `RM_E2E_MONITOR=1` opt-in | Every entry point checks the gate; inert without the env var |
|
||||
| No import side effects | The package is not imported by `app/`, `tests/`, or any other module |
|
||||
| Not in the pre-push hook | `.githooks/pre-push` runs `pytest` only — no e2e sweep |
|
||||
| Gitignored skill | `.claude/skills/monitor-e2e/` is gitignored; the playbook source is committed but the runnable skill is local-only |
|
||||
| Isolated `DATA_DIR` | The sweep never reads or writes the developer's real database |
|
||||
|
||||
Result: approximately zero random cloners' agents will ever run or even see the monitor.
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Agentic end-to-end monitor harness (opt-in, on-demand).
|
||||
|
||||
This package is INERT by default: it has no import side effects, is never
|
||||
imported by ``app/*`` or by the default test suite, and every expensive move
|
||||
refuses to run unless explicitly enabled (see ``e2e_monitor.gate``). See
|
||||
``docs/superpowers/specs/2026-06-01-agentic-e2e-monitor-design.md``.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
# Single source of truth for the backend API base the monitor drives. Defined
|
||||
# here as an inert, side-effect-free constant so render.py / flow.py / servers.py
|
||||
# don't each hard-code the URL and drift apart.
|
||||
API_BASE = "http://127.0.0.1:8000/api/v1"
|
||||
@@ -0,0 +1,247 @@
|
||||
"""CLI: ``uv run python -m e2e_monitor <move> [args]`` (run from apps/backend).
|
||||
|
||||
Every move calls ``ensure_enabled()`` first. ``sweep`` pre-seeds the isolated DB
|
||||
with a known master, boots the servers, tailors N variations, judges + (when the
|
||||
frontend is up) renders each, then writes the bundle (flow-trace, summary, and a
|
||||
baseline-diff when a committed baseline exists).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from e2e_monitor.baseline import diff_against_baseline, summary_to_baseline
|
||||
from e2e_monitor.bundle import Bundle
|
||||
from e2e_monitor.collect import build_flow_trace, build_summary
|
||||
from e2e_monitor.flow import seed_master_db, tailor
|
||||
from e2e_monitor.gate import MonitorDisabled, ensure_enabled
|
||||
from e2e_monitor.judge import judge_variation
|
||||
from e2e_monitor.manifest import build_manifest
|
||||
from e2e_monitor.render import render_variation
|
||||
from e2e_monitor.servers import Servers
|
||||
|
||||
_BACKEND = Path(__file__).resolve().parents[1]
|
||||
_PKG = Path(__file__).resolve().parent
|
||||
_ARTIFACTS = _BACKEND.parents[1] / "artifacts" / "e2e-monitor"
|
||||
_FIXTURES = _PKG / "fixtures"
|
||||
_BASELINE = _PKG / "baseline" / "baseline.json"
|
||||
|
||||
_STOPWORDS = frozenset({
|
||||
"we", "you", "our", "your", "the", "a", "an", "and", "or", "for", "with",
|
||||
"to", "of", "in", "on", "is", "are", "as", "at", "be", "by", "this", "that",
|
||||
})
|
||||
|
||||
|
||||
def _git_sha() -> str:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"], cwd=_BACKEND, text=True
|
||||
).strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _run_id() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def _jds() -> list[tuple[str, str]]:
|
||||
return sorted(
|
||||
(p.stem, p.read_text(encoding="utf-8")) for p in (_FIXTURES / "jds").glob("*.txt")
|
||||
)
|
||||
|
||||
|
||||
def _say(msg: str) -> None:
|
||||
"""Print live loop narration to stderr.
|
||||
|
||||
Progress/handoff text goes to stderr so the machine-readable ``bundle: <path>``
|
||||
line stays alone on stdout for scripts/agents that parse it.
|
||||
"""
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def cmd_sweep(_: argparse.Namespace) -> int:
|
||||
ensure_enabled()
|
||||
from app.config import load_config_file
|
||||
|
||||
bundle = Bundle(root=_ARTIFACTS, run_id=_run_id())
|
||||
bundle.ensure()
|
||||
config = load_config_file()
|
||||
bundle.write_json(
|
||||
bundle.dir / "manifest.json",
|
||||
build_manifest(run_id=bundle.run_id, git_sha=_git_sha(), config=config, started_at=_now_iso()),
|
||||
)
|
||||
|
||||
_say("")
|
||||
_say(" e2e-monitor · driving the real app end to end")
|
||||
_say(f" provider {config.get('provider', '?')}/{config.get('model', '?')} · run {bundle.run_id}")
|
||||
_say(" Captures an evidence bundle for an AI agent to JUDGE — built to run (or via")
|
||||
_say(" the /monitor-e2e skill) while you work on the app as normal.")
|
||||
_say("")
|
||||
|
||||
# Pre-seed the isolated DB with a known master BEFORE booting the server.
|
||||
# Canonicalize to the exact ResumeData round-trip the app stores, so every
|
||||
# optional field is present. Otherwise improve/preview hashes the raw
|
||||
# (field-missing) dict while improve/confirm hashes the schema-defaulted
|
||||
# round-trip — a mismatch the app rejects with 400 (its preview/confirm
|
||||
# hash gate). See _hash_improved_data in app/routers/resumes.py.
|
||||
from app.schemas import ResumeData
|
||||
|
||||
raw_master = json.loads((_FIXTURES / "master.json").read_text(encoding="utf-8"))
|
||||
master = ResumeData.model_validate(raw_master).model_dump()
|
||||
resume_id = seed_master_db(bundle.data_dir, master)
|
||||
bundle.write_json(bundle.master_dir / "processed_data.json", master)
|
||||
_say(" ✓ seed-master canonical master → isolated DB (your real DB is untouched)")
|
||||
|
||||
steps: list[dict[str, Any]] = []
|
||||
# seed-master ran above (BEFORE boot) — record it first so flow-trace.json
|
||||
# ordering matches actual execution.
|
||||
steps.append({"stage": "seed-master", "ok": True, "ms": 0, "detail": {"resume_id": resume_id}})
|
||||
servers = Servers(bundle=bundle)
|
||||
variations: list[dict[str, Any]] = []
|
||||
try:
|
||||
_say(" ▶ boot spawning backend :8000 + frontend :3000 …")
|
||||
boot = servers.boot()
|
||||
steps.append({"stage": "boot", "ok": True, "ms": 0, "detail": boot})
|
||||
_say(" ✓ boot backend up" + (" + frontend up" if boot.get("frontend_up") else " (frontend off — renders degrade to header+size)"))
|
||||
|
||||
for jd_key, jd_text in _jds():
|
||||
vdir = bundle.variation_dir(jd_key)
|
||||
(vdir / "job_description.txt").write_text(jd_text, encoding="utf-8")
|
||||
keywords = [
|
||||
kw for kw in (w.strip(":,.();") for w in jd_text.split())
|
||||
if kw.istitle() and kw.lower() not in _STOPWORDS
|
||||
][:8]
|
||||
|
||||
_say(f" ▶ {jd_key:<16} tailor → judge → render …")
|
||||
try:
|
||||
t = tailor(resume_id, jd_text, keywords, master)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
steps.append({"stage": f"tailor:{jd_key}", "ok": False, "ms": 0, "error": str(exc)})
|
||||
_say(f" ✗ {jd_key:<16} tailor FAILED: {str(exc)[:90]}")
|
||||
continue
|
||||
bundle.write_json(vdir / "tailored.json", t["tailored"])
|
||||
bundle.write_json(vdir / "scores.json", t["scores"])
|
||||
steps.append({"stage": f"tailor:{jd_key}", "ok": True, "ms": 0})
|
||||
|
||||
try:
|
||||
judge = asyncio.run(judge_variation(jd_text, t["tailored"]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
judge = {"score": None, "reasons": f"judge failed: {exc}"}
|
||||
steps.append({"stage": f"judge:{jd_key}", "ok": False, "ms": 0, "error": str(exc)})
|
||||
bundle.write_json(vdir / "judge.json", judge)
|
||||
|
||||
render: dict[str, Any] = {"non_blank": None}
|
||||
render_status = "skipped" # frontend down or no tailored id
|
||||
if servers.frontend_up and t["tailored_resume_id"]:
|
||||
try:
|
||||
pdf, render = render_variation(t["tailored_resume_id"])
|
||||
(vdir / "resume.pdf").write_bytes(pdf)
|
||||
bundle.write_json(vdir / "render.json", render)
|
||||
steps.append({"stage": f"render:{jd_key}", "ok": bool(render["non_blank"]), "ms": 0})
|
||||
render_status = "non-blank" if render["non_blank"] else "BLANK!"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
steps.append({"stage": f"render:{jd_key}", "ok": False, "ms": 0, "error": str(exc)})
|
||||
render_status = "FAILED"
|
||||
variations.append({"jd_key": jd_key, "scores": t["scores"], "judge": judge, "render": render})
|
||||
|
||||
# ✓ only when the judge produced a score AND the render didn't fail/blank;
|
||||
# otherwise ⚠ — the marker must never claim success over a caught failure.
|
||||
variation_ok = (judge or {}).get("score") is not None and render_status in ("non-blank", "skipped")
|
||||
_say(
|
||||
f" {'✓' if variation_ok else '⚠'} {jd_key:<16} "
|
||||
f"judge={(judge or {}).get('score')} "
|
||||
f"kw={t['scores']['jd_keyword_coverage']} "
|
||||
f"render={render_status} "
|
||||
f"fabricated={len(t['scores']['fabricated_employers'])}"
|
||||
)
|
||||
finally:
|
||||
servers.teardown()
|
||||
|
||||
flow = build_flow_trace(steps)
|
||||
bundle.write_json(bundle.dir / "flow-trace.json", flow)
|
||||
summary = build_summary(flow=flow, variations=variations, provider=config.get("provider", ""))
|
||||
bundle.write_json(bundle.dir / "summary.json", summary)
|
||||
baseline_line = ""
|
||||
if _BASELINE.exists():
|
||||
current = {
|
||||
v["jd_key"]: {
|
||||
"jd_keyword_coverage": v["scores"]["jd_keyword_coverage"],
|
||||
"judge_score": (v.get("judge") or {}).get("score"),
|
||||
"non_blank": (v.get("render") or {}).get("non_blank"),
|
||||
}
|
||||
for v in variations
|
||||
}
|
||||
diff = diff_against_baseline(current, bundle.read_json(_BASELINE))
|
||||
bundle.write_json(bundle.dir / "baseline-diff.json", diff)
|
||||
baseline_line = (
|
||||
" · no regression vs baseline"
|
||||
if not diff["regressed"]
|
||||
else f" · REGRESSED ({len(diff['regressions'])} — see baseline-diff.json)"
|
||||
)
|
||||
|
||||
_say("")
|
||||
_say(" ──────────────────────────────────────────────────────────────")
|
||||
_say(
|
||||
f" sweep complete · {summary['variations']} variations · "
|
||||
f"flow {'all passed' if summary['flow_all_passed'] else 'HAD FAILURES'} · "
|
||||
f"renders {summary['renders_non_blank']}/{summary['variations']} non-blank"
|
||||
+ baseline_line
|
||||
)
|
||||
_say("")
|
||||
_say(" ↳ NEXT — this is captured EVIDENCE, not a verdict. Hand it to an AI agent:")
|
||||
_say(" • In Claude Code, invoke the /monitor-e2e skill, or just say")
|
||||
_say(' "judge the latest e2e-monitor bundle".')
|
||||
_say(" The agent reads the logs + artifacts, separates real issues from noise,")
|
||||
_say(" and writes report.md. This harness is built to be DRIVEN BY an AI agent")
|
||||
_say(" debugging in the background while you build the app as normal.")
|
||||
_say("")
|
||||
print(f"bundle: {bundle.dir}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_update_baseline(args: argparse.Namespace) -> int:
|
||||
ensure_enabled(require_key=False)
|
||||
run_dir = Path(args.run_dir)
|
||||
variations: list[dict[str, Any]] = []
|
||||
for vdir in sorted((run_dir / "variations").glob("*")):
|
||||
variations.append({
|
||||
"jd_key": vdir.name,
|
||||
"scores": Bundle.read_json(vdir / "scores.json"),
|
||||
"judge": Bundle.read_json(vdir / "judge.json") if (vdir / "judge.json").exists() else {},
|
||||
"render": Bundle.read_json(vdir / "render.json") if (vdir / "render.json").exists() else {},
|
||||
})
|
||||
_BASELINE.parent.mkdir(parents=True, exist_ok=True)
|
||||
Bundle.write_json(_BASELINE, summary_to_baseline(variations))
|
||||
print(f"baseline updated from {run_dir} -> {_BASELINE} (review + commit it)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="e2e_monitor")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("sweep").set_defaults(func=cmd_sweep)
|
||||
ub = sub.add_parser("update-baseline")
|
||||
ub.add_argument("run_dir")
|
||||
ub.set_defaults(func=cmd_update_baseline)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except MonitorDisabled as exc:
|
||||
print(f"e2e-monitor: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Baseline diff (regression detector) + baseline construction.
|
||||
|
||||
An absolute ``floor`` is the hard fail; on top, a per-metric drop beyond
|
||||
``judge_tolerance`` flags drift even while still above the floor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def diff_against_baseline(
|
||||
current: dict[str, dict[str, Any]], baseline: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Compare this run's per-variation metrics against the committed baseline."""
|
||||
floor = baseline.get("floor", {})
|
||||
tol = baseline.get("judge_tolerance", 1)
|
||||
base_vars = baseline.get("variations", {})
|
||||
regressions: list[dict[str, Any]] = []
|
||||
|
||||
for jd_key, cur in current.items():
|
||||
base = base_vars.get(jd_key, {})
|
||||
cov = cur.get("jd_keyword_coverage")
|
||||
judge = cur.get("judge_score")
|
||||
non_blank = cur.get("non_blank")
|
||||
|
||||
if cov is not None and cov < floor.get("min_keyword_coverage", 0.0):
|
||||
regressions.append({"jd_key": jd_key, "kind": "keyword_floor", "value": cov})
|
||||
if judge is None and base.get("judge_score") is not None:
|
||||
# The judge produced a score for this variation at baseline but nothing
|
||||
# now (e.g. it errored) — worse than any low score, so flag it.
|
||||
regressions.append({
|
||||
"jd_key": jd_key,
|
||||
"kind": "judge_missing",
|
||||
"baseline_value": base.get("judge_score"),
|
||||
})
|
||||
elif judge is not None and judge < floor.get("min_judge_score", 0):
|
||||
regressions.append({"jd_key": jd_key, "kind": "judge_floor", "value": judge})
|
||||
if non_blank is False:
|
||||
regressions.append({"jd_key": jd_key, "kind": "blank_render", "value": False})
|
||||
base_judge = base.get("judge_score")
|
||||
if (
|
||||
isinstance(judge, int) and not isinstance(judge, bool)
|
||||
and isinstance(base_judge, int) and not isinstance(base_judge, bool)
|
||||
and (base_judge - judge) > tol
|
||||
):
|
||||
regressions.append(
|
||||
{"jd_key": jd_key, "kind": "judge_drop", "from": base_judge, "to": judge}
|
||||
)
|
||||
|
||||
for jd_key in base_vars:
|
||||
if jd_key not in current:
|
||||
regressions.append({"jd_key": jd_key, "kind": "missing_variation"})
|
||||
|
||||
return {"regressed": bool(regressions), "regressions": regressions}
|
||||
|
||||
|
||||
def summary_to_baseline(variations: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Build a baseline ``variations`` block from a run's variation results."""
|
||||
out: dict[str, Any] = {
|
||||
"variations": {},
|
||||
# Floors are the absolute "this is broken" bar; per-variation drift
|
||||
# (judge_tolerance) catches regressions above the floor. The fixture set
|
||||
# deliberately includes JDs far from the master (frontend/ML/PM) whose
|
||||
# truthful tailoring legitimately scores ~2 — so the judge floor sits at
|
||||
# 2, not 3, to avoid false-positives on those honest-but-weak variations.
|
||||
"floor": {"min_judge_score": 2, "min_keyword_coverage": 0.5},
|
||||
"judge_tolerance": 1,
|
||||
}
|
||||
for v in variations:
|
||||
scores = v.get("scores", {})
|
||||
out["variations"][v["jd_key"]] = {
|
||||
"jd_keyword_coverage": scores.get("jd_keyword_coverage"),
|
||||
"judge_score": (v.get("judge") or {}).get("score"),
|
||||
"non_blank": (v.get("render") or {}).get("non_blank"),
|
||||
}
|
||||
return out
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"variations": {
|
||||
"backend-eng": {
|
||||
"jd_keyword_coverage": 0.5,
|
||||
"judge_score": 4,
|
||||
"non_blank": true
|
||||
},
|
||||
"frontend-eng": {
|
||||
"jd_keyword_coverage": 0.75,
|
||||
"judge_score": 2,
|
||||
"non_blank": true
|
||||
},
|
||||
"ml-eng": {
|
||||
"jd_keyword_coverage": 0.625,
|
||||
"judge_score": 2,
|
||||
"non_blank": true
|
||||
},
|
||||
"product-manager": {
|
||||
"jd_keyword_coverage": 0.625,
|
||||
"judge_score": 2,
|
||||
"non_blank": true
|
||||
}
|
||||
},
|
||||
"floor": {
|
||||
"min_judge_score": 2,
|
||||
"min_keyword_coverage": 0.5
|
||||
},
|
||||
"judge_tolerance": 1
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Evidence-bundle directory layout + JSON helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bundle:
|
||||
"""One run's evidence bundle under ``artifacts/e2e-monitor/<run-id>/``."""
|
||||
|
||||
root: Path # artifacts/e2e-monitor
|
||||
run_id: str
|
||||
|
||||
@property
|
||||
def dir(self) -> Path:
|
||||
return self.root / self.run_id
|
||||
|
||||
@property
|
||||
def logs_dir(self) -> Path:
|
||||
return self.dir / "logs"
|
||||
|
||||
@property
|
||||
def data_dir(self) -> Path:
|
||||
return self.dir / "data"
|
||||
|
||||
@property
|
||||
def master_dir(self) -> Path:
|
||||
return self.dir / "master"
|
||||
|
||||
def variation_dir(self, jd_key: str) -> Path:
|
||||
d = self.dir / "variations" / jd_key
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
def ensure(self) -> None:
|
||||
for d in (self.dir, self.logs_dir, self.data_dir, self.master_dir):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def write_json(path: Path, obj: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def read_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Flow-trace and summary roll-ups (pure functions over recorded step results)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_flow_trace(steps: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Summarize per-stage status/timing into ``flow-trace.json`` shape."""
|
||||
failed = sum(1 for s in steps if not s.get("ok"))
|
||||
return {
|
||||
"total": len(steps),
|
||||
"failed": failed,
|
||||
"all_passed": failed == 0,
|
||||
"stages": list(steps),
|
||||
}
|
||||
|
||||
|
||||
def build_summary(
|
||||
*, flow: dict[str, Any], variations: list[dict[str, Any]], provider: str
|
||||
) -> dict[str, Any]:
|
||||
"""The cheap orientation object the agent reads first."""
|
||||
judge_scores = [
|
||||
v["judge"]["score"] for v in variations
|
||||
if isinstance(v.get("judge"), dict) and isinstance(v["judge"].get("score"), int)
|
||||
and not isinstance(v["judge"].get("score"), bool)
|
||||
]
|
||||
renders_non_blank = sum(
|
||||
1 for v in variations if isinstance(v.get("render"), dict) and v["render"].get("non_blank")
|
||||
)
|
||||
return {
|
||||
"provider": provider,
|
||||
"variations": len(variations),
|
||||
"flow_all_passed": flow.get("all_passed", False),
|
||||
"flow_failed_stages": [s["stage"] for s in flow.get("stages", []) if not s.get("ok")],
|
||||
"renders_non_blank": renders_non_blank,
|
||||
"min_judge_score": min(judge_scores) if judge_scores else None,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
We are looking for a Senior Backend Engineer to join our platform team. You will design and build
|
||||
high-throughput Python services using FastAPI and Django REST Framework, owning the full lifecycle
|
||||
from schema design to production deployment. Our stack runs on AWS (ECS, RDS, ElastiCache), and
|
||||
you will be expected to make sound architectural decisions around microservices decomposition,
|
||||
event-driven messaging with Kafka, and relational data modeling in PostgreSQL.
|
||||
|
||||
Requirements: 4+ years of professional Python experience; deep knowledge of async programming
|
||||
with asyncio; experience with containerization via Docker and orchestration with Kubernetes or
|
||||
ECS; strong grasp of SQL query optimization, indexing strategies, and Redis caching patterns;
|
||||
familiarity with CI/CD pipelines (GitHub Actions or CircleCI) and observability tooling
|
||||
(Datadog, OpenTelemetry). You take ownership of reliability — on-call rotations, runbooks, and
|
||||
postmortems are part of the role.
|
||||
@@ -0,0 +1,11 @@
|
||||
We are hiring a Senior Frontend Engineer to lead the development of our consumer-facing web
|
||||
application. You will build performant, accessible React and Next.js interfaces backed by a
|
||||
TypeScript-first codebase, working closely with product designers and a GraphQL API team.
|
||||
|
||||
Requirements: 4+ years building production React applications; expert-level TypeScript; strong
|
||||
understanding of Next.js 14+ App Router, server components, and streaming SSR; proficiency with
|
||||
Tailwind CSS and CSS-in-JS patterns; hands-on experience with WCAG 2.1 AA accessibility
|
||||
compliance, including screen-reader testing and keyboard navigation; familiarity with Storybook
|
||||
for component documentation, Vitest or Jest for unit tests, and Playwright for end-to-end
|
||||
testing. You will own Core Web Vitals (LCP, CLS, INP), collaborate on our design-system token
|
||||
library, and drive frontend performance budgets across our three product surfaces.
|
||||
@@ -0,0 +1,13 @@
|
||||
We are seeking a Machine Learning Engineer to join our AI platform team. You will own the
|
||||
end-to-end lifecycle of production ML systems: data pipeline construction, model training and
|
||||
evaluation, and scalable inference deployment. Day-to-day work involves large-scale tabular and
|
||||
unstructured data using Spark or Ray, experiment tracking in MLflow, and training PyTorch models
|
||||
for classification and ranking tasks.
|
||||
|
||||
Requirements: 3+ years of applied ML engineering experience; strong Python skills with expertise
|
||||
in PyTorch, scikit-learn, and Hugging Face Transformers; hands-on experience with feature stores
|
||||
(Feast or Tecton), model registries, and A/B testing frameworks; familiarity with deploying
|
||||
models via Triton Inference Server or SageMaker endpoints; understanding of ML monitoring
|
||||
concepts (data drift, prediction drift, concept drift) using tools such as Evidently or
|
||||
WhyLogs; experience with SQL and distributed compute (Spark, Dask). A track record of taking
|
||||
models from notebook prototypes to latency-sensitive production services is essential.
|
||||
@@ -0,0 +1,15 @@
|
||||
We are looking for a Senior Product Manager to drive the strategy and execution of our
|
||||
B2B SaaS analytics suite. You will own the product roadmap across three workstreams, working
|
||||
alongside engineering, design, data science, and go-to-market teams to deliver measurable
|
||||
business outcomes.
|
||||
|
||||
Responsibilities: Define and prioritize the product backlog using evidence from customer
|
||||
discovery interviews, NPS surveys, Mixpanel funnel data, and competitive analysis; author
|
||||
detailed PRDs and acceptance criteria; facilitate sprint planning and align stakeholders on
|
||||
quarterly OKRs; present roadmap updates to C-suite executives and enterprise customers.
|
||||
|
||||
Requirements: 4+ years of product management experience in a SaaS environment; demonstrated
|
||||
ability to translate ambiguous business problems into clear product requirements; proficiency
|
||||
with analytics tools (Amplitude, Mixpanel, or Looker) and SQL for ad-hoc analysis; experience
|
||||
with A/B testing and experimentation frameworks; excellent written communication and stakeholder
|
||||
management skills. Technical background appreciated but not required — you will not write code.
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"personalInfo": {
|
||||
"name": "Jane Doe",
|
||||
"title": "Senior Backend Engineer",
|
||||
"email": "jane@example.com",
|
||||
"phone": "+1-555-0100",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://janedoe.dev",
|
||||
"linkedin": "linkedin.com/in/janedoe",
|
||||
"github": "github.com/janedoe"
|
||||
},
|
||||
"summary": "Backend engineer with 6 years of experience building scalable Python APIs and microservices.",
|
||||
"workExperience": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Senior Backend Engineer",
|
||||
"company": "Acme Corp",
|
||||
"location": "San Francisco, CA",
|
||||
"years": "Jan 2021 - Present",
|
||||
"description": [
|
||||
"Built REST APIs serving 50K requests/day using Python and FastAPI",
|
||||
"Led migration from monolith to microservices architecture",
|
||||
"Mentored 3 junior developers on backend best practices"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Software Engineer",
|
||||
"company": "StartupCo",
|
||||
"location": "New York, NY",
|
||||
"years": "Jun 2018 - Dec 2020",
|
||||
"description": [
|
||||
"Developed payment processing system handling $2M monthly",
|
||||
"Wrote unit and integration tests improving coverage from 40% to 85%"
|
||||
]
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"id": 1,
|
||||
"institution": "MIT",
|
||||
"degree": "B.S. Computer Science",
|
||||
"years": "2014 - 2018",
|
||||
"description": "Graduated with honors, Dean's List"
|
||||
}
|
||||
],
|
||||
"personalProjects": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "OpenAPI Generator",
|
||||
"role": "Creator & Maintainer",
|
||||
"years": "Mar 2021 - Present",
|
||||
"description": [
|
||||
"CLI tool generating API clients from OpenAPI specs",
|
||||
"500+ GitHub stars, used by 30+ companies"
|
||||
]
|
||||
}
|
||||
],
|
||||
"additional": {
|
||||
"technicalSkills": ["Python", "FastAPI", "Docker", "AWS", "PostgreSQL", "Redis"],
|
||||
"languages": ["English (Native)", "Spanish (Conversational)"],
|
||||
"certificationsTraining": ["AWS Solutions Architect Associate"],
|
||||
"awards": ["Employee of the Year 2022"]
|
||||
},
|
||||
"customSections": {},
|
||||
"sectionMeta": []
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Flow moves (seed-master, tailor) + the pure scorer-runner.
|
||||
|
||||
The scorer-runner wraps the deterministic scorers already proven in
|
||||
``tests/evals/scorers.py`` so the harness and the eval suite agree on what
|
||||
"a good tailoring" means. (The HTTP moves are appended to this module in a
|
||||
later task.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from e2e_monitor import API_BASE
|
||||
from tests.evals.scorers import (
|
||||
is_valid_resume,
|
||||
jd_keywords_present,
|
||||
no_fabricated_employers,
|
||||
personal_info_unchanged,
|
||||
sections_preserved,
|
||||
)
|
||||
|
||||
|
||||
def score_tailoring(
|
||||
original: dict[str, Any], tailored: dict[str, Any], keywords: list[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Run every structural scorer over an (original, tailored) pair."""
|
||||
return {
|
||||
"sections_preserved": sections_preserved(original, tailored),
|
||||
"fabricated_employers": no_fabricated_employers(original, tailored),
|
||||
"personal_info_unchanged": personal_info_unchanged(original, tailored),
|
||||
"is_valid_resume": is_valid_resume(tailored),
|
||||
"jd_keyword_coverage": jd_keywords_present(tailored, keywords),
|
||||
}
|
||||
|
||||
|
||||
def seed_master_db(data_dir: Path, master: dict[str, Any]) -> str:
|
||||
"""Pre-seed the isolated DB with a known master BEFORE the server boots.
|
||||
|
||||
The upload endpoint only accepts documents (and runs a non-deterministic LLM
|
||||
parse), so for a controlled, deterministic master we write it straight into
|
||||
the isolated TinyDB file via app.database.Database — the same file the server
|
||||
opens once booted with DATA_DIR=<data_dir>. Returns the master's resume_id.
|
||||
"""
|
||||
from app.database import Database
|
||||
|
||||
db = Database(db_path=data_dir / "database.json")
|
||||
try:
|
||||
doc = db.create_resume(
|
||||
content="(seeded master resume)",
|
||||
content_type="md",
|
||||
is_master=True,
|
||||
processed_data=master,
|
||||
processing_status="ready",
|
||||
)
|
||||
return doc["resume_id"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def tailor(
|
||||
resume_id: str, jd_text: str, keywords: list[str], original: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""jobs/upload -> improve/preview -> improve/confirm; returns tailored + scores."""
|
||||
jobs_resp = httpx.post(
|
||||
f"{API_BASE}/jobs/upload",
|
||||
json={"job_descriptions": [jd_text], "resume_id": resume_id},
|
||||
timeout=120,
|
||||
)
|
||||
jobs_resp.raise_for_status()
|
||||
job_ids = jobs_resp.json().get("job_id", [])
|
||||
if not job_ids:
|
||||
raise RuntimeError("jobs/upload returned no job_id")
|
||||
job_id = job_ids[0]
|
||||
|
||||
preview_resp = httpx.post(
|
||||
f"{API_BASE}/resumes/improve/preview",
|
||||
json={"resume_id": resume_id, "job_id": job_id},
|
||||
timeout=240,
|
||||
)
|
||||
preview_resp.raise_for_status()
|
||||
data = preview_resp.json()["data"]
|
||||
tailored = data["resume_preview"]
|
||||
improvements = data["improvements"]
|
||||
|
||||
confirm_resp = httpx.post(
|
||||
f"{API_BASE}/resumes/improve/confirm",
|
||||
json={"resume_id": resume_id, "job_id": job_id,
|
||||
"improved_data": tailored, "improvements": improvements},
|
||||
timeout=240,
|
||||
)
|
||||
confirm_resp.raise_for_status()
|
||||
confirm = confirm_resp.json()
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"tailored": tailored,
|
||||
"tailored_resume_id": confirm["data"].get("resume_id"),
|
||||
"keywords": keywords,
|
||||
"scores": score_tailoring(original, tailored, keywords),
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"""The opt-in gate — every expensive move calls ``ensure_enabled()`` first.
|
||||
|
||||
Two independent locks must both be open:
|
||||
1. ``RM_E2E_MONITOR=1`` in the environment (deliberate enable), and
|
||||
2. a usable LLM key/provider is configured (same rule the eval harness uses).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class MonitorDisabled(RuntimeError):
|
||||
"""Raised when a move is invoked without the monitor being enabled."""
|
||||
|
||||
|
||||
def _key_is_configured() -> bool:
|
||||
"""True when a usable key/provider is set (mirrors the eval ``_needs_key``)."""
|
||||
try:
|
||||
from app.llm import get_llm_config
|
||||
|
||||
cfg = get_llm_config()
|
||||
except Exception:
|
||||
return False
|
||||
return bool(cfg.api_key) or cfg.provider in ("ollama", "openai_compatible")
|
||||
|
||||
|
||||
def ensure_enabled(*, require_key: bool = True) -> None:
|
||||
"""Raise ``MonitorDisabled`` unless both locks are open.
|
||||
|
||||
When ``require_key=False`` the LLM key check is skipped (use for offline
|
||||
moves such as ``update-baseline`` that need no LLM calls).
|
||||
"""
|
||||
if os.environ.get("RM_E2E_MONITOR") != "1":
|
||||
raise MonitorDisabled(
|
||||
"e2e monitor is disabled by default — it makes real, billed LLM calls "
|
||||
"and boots servers. Set RM_E2E_MONITOR=1 to enable."
|
||||
)
|
||||
if require_key and not _key_is_configured():
|
||||
raise MonitorDisabled(
|
||||
"no usable LLM key/provider configured (set one in data/config.json or "
|
||||
"the Settings UI, or point at a local provider)."
|
||||
)
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the (gitignored) monitor-e2e Claude Code skill from its committed
|
||||
# source-of-truth playbook. Run once per clone if you want the agent layer.
|
||||
set -euo pipefail
|
||||
root="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
dest="$root/.claude/skills/monitor-e2e"
|
||||
mkdir -p "$dest"
|
||||
cp "$root/apps/backend/e2e_monitor/AGENT_PLAYBOOK.md" "$dest/SKILL.md"
|
||||
echo "installed monitor-e2e skill -> $dest/SKILL.md (gitignored)"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""LLM-judge move — reuses the eval rubric via app.llm.complete_json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Share of the tailored resume that may incorporate job-description keywords/skills
|
||||
# the master resume lacked before the judge should treat it as fabrication rather
|
||||
# than legitimate ATS tailoring. Maintainer policy (2026-06): surfacing JD keywords
|
||||
# IS the product's job, so a moderate amount is expected and must not be scored as a
|
||||
# truthfulness violation. This knob ONLY softens the judge's (LLM, qualitative)
|
||||
# truthfulness lens — the hard structural guards in flow.score_tailoring
|
||||
# (no_fabricated_employers, personal_info_unchanged) stay strict and are NOT affected.
|
||||
# Trade-off (flagged at review): a higher value buys ATS match at the cost of letting
|
||||
# more JD-sourced claims through; employers, titles, dates, and overall profession
|
||||
# stay inviolate regardless. Dial this down to tighten truthfulness.
|
||||
JD_KEYWORD_TOLERANCE = 0.20
|
||||
|
||||
_RUBRIC = ( # diverges from tests/evals/test_tailoring_eval.py by design — adds the JD tolerance
|
||||
"You are a strict but fair technical recruiter grading how well a resume was "
|
||||
"tailored to a job description on RELEVANCE, TRUTHFULNESS, and FORMATTING. "
|
||||
"Incorporating job-description keywords and skills into the resume is EXPECTED, "
|
||||
"legitimate tailoring (ATS optimization), not fabrication: do NOT lower the score "
|
||||
f"when up to ~{int(JD_KEYWORD_TOLERANCE * 100)}% of the resume's content is "
|
||||
"JD-sourced wording the master lacked, PROVIDED the candidate's employers, job "
|
||||
"titles, dates, and overall profession remain unchanged. DO still penalize invented "
|
||||
"employers, fabricated titles or dates, and a wholesale change of profession the "
|
||||
"master never supported (e.g. a backend engineer rewritten as a career frontend dev). "
|
||||
'Return ONLY JSON {"score": <int 1-5>, "reasons": "<one or two sentences>"}.'
|
||||
)
|
||||
|
||||
|
||||
def _normalize_score(raw: Any) -> int | None:
|
||||
"""Coerce a judge score to an int in 1-5, or None. Rejects bools, non-finite, junk.
|
||||
|
||||
The whole conversion is wrapped in one try/except so a huge int (``float()``
|
||||
OverflowError), ``inf``/``nan`` (``int()`` on a non-finite), or junk string
|
||||
(``float()`` ValueError) all fail closed to ``None``. Uses round-half-up
|
||||
(``int(x + 0.5)``) rather than ``round()``'s banker's rounding, since scores
|
||||
are small positive integers.
|
||||
"""
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
if isinstance(raw, (int, float)):
|
||||
candidate = float(raw)
|
||||
elif isinstance(raw, str):
|
||||
candidate = float(raw.strip())
|
||||
else:
|
||||
return None
|
||||
value = int(candidate + 0.5) # round half up; raises on inf/nan
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
return value if 1 <= value <= 5 else None
|
||||
|
||||
|
||||
async def judge_variation(job_description: str, tailored: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Score one (JD, tailored) pair 1-5. Caller must be past the opt-in gate."""
|
||||
from app.llm import complete_json
|
||||
|
||||
prompt = (
|
||||
f"{_RUBRIC}\n\n=== JOB DESCRIPTION ===\n{job_description}\n\n"
|
||||
f"=== TAILORED RESUME (JSON) ===\n{json.dumps(tailored, ensure_ascii=False, indent=2)}\n"
|
||||
)
|
||||
result = await complete_json(
|
||||
prompt,
|
||||
system_prompt="You are an impartial resume-tailoring evaluator.",
|
||||
max_tokens=512,
|
||||
schema_type="keywords", # "keywords" skips truncation heuristics; judge dict is accepted on the first call
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
return {"score": None, "reasons": str(result)}
|
||||
return {"score": _normalize_score(result.get("score")), "reasons": str(result.get("reasons", ""))}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Build the run manifest (provider/model/git SHA + scrubbed config)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from e2e_monitor.scrub import scrub_config
|
||||
|
||||
|
||||
def build_manifest(
|
||||
*, run_id: str, git_sha: str, config: dict[str, Any], started_at: str
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble the manifest dict. ``config`` is the real ``data/config.json``;
|
||||
only non-secret fields surface, the rest are redacted."""
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"started_at": started_at,
|
||||
"git_sha": git_sha,
|
||||
"provider": config.get("provider", ""),
|
||||
"model": config.get("model", ""),
|
||||
"config_snapshot": scrub_config(config),
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""PDF render move + a non-blank heuristic that does not need a browser.
|
||||
|
||||
The decision is split into a pure ``_verdict`` (unit-tested for every signal
|
||||
combination) and a ``check_pdf_bytes`` wrapper that runs an optional ``pypdf``
|
||||
text/page probe — present only with the ``e2e-monitor`` extra, and degrading to
|
||||
header+size checks when absent. The render HTTP move is added in a later task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from e2e_monitor import API_BASE
|
||||
|
||||
_MIN_BYTES = 1000 # a real one-page resume PDF is comfortably larger than this
|
||||
|
||||
|
||||
def _verdict(*, is_pdf: bool, size: int, pages: int | None, has_text: bool | None) -> bool:
|
||||
"""Pure non-blank decision. ``pages``/``has_text`` may be ``None`` when the
|
||||
optional probe is unavailable — ``None`` must not veto an otherwise-real PDF."""
|
||||
return bool(is_pdf and size >= _MIN_BYTES and has_text is not False and pages != 0)
|
||||
|
||||
|
||||
def check_pdf_bytes(data: bytes) -> dict[str, Any]:
|
||||
"""Classify PDF bytes. ``non_blank`` is the load-bearing verdict."""
|
||||
is_pdf = data[:5] == b"%PDF-"
|
||||
size = len(data)
|
||||
pages: int | None = None
|
||||
has_text: bool | None = None
|
||||
|
||||
if is_pdf:
|
||||
try:
|
||||
import io
|
||||
|
||||
from pypdf import PdfReader # only present with the e2e-monitor extra
|
||||
|
||||
reader = PdfReader(io.BytesIO(data))
|
||||
pages = len(reader.pages)
|
||||
has_text = any((p.extract_text() or "").strip() for p in reader.pages)
|
||||
except ModuleNotFoundError:
|
||||
pages = None
|
||||
has_text = None # probe unavailable; fall back to header+size
|
||||
except Exception:
|
||||
pages = 0
|
||||
has_text = False
|
||||
|
||||
return {
|
||||
"is_pdf": is_pdf,
|
||||
"size": size,
|
||||
"pages": pages,
|
||||
"has_text": has_text,
|
||||
"non_blank": _verdict(is_pdf=is_pdf, size=size, pages=pages, has_text=has_text),
|
||||
}
|
||||
|
||||
|
||||
def render_variation(
|
||||
tailored_resume_id: str, *, lang: str | None = None
|
||||
) -> tuple[bytes, dict[str, Any]]:
|
||||
"""GET the PDF for a tailored resume; return (bytes, non-blank verdict)."""
|
||||
params: dict[str, str] = {"template": "swiss-single", "pageSize": "A4"}
|
||||
if lang:
|
||||
params["lang"] = lang
|
||||
resp = httpx.get(
|
||||
f"{API_BASE}/resumes/{tailored_resume_id}/pdf", params=params, timeout=120
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content, check_pdf_bytes(resp.content)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Redact secrets before they reach the (gitignored) evidence bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
|
||||
# Order matters: more specific patterns first.
|
||||
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"sk-[A-Za-z0-9_\-]{8,}"), # OpenAI/Anthropic-style keys
|
||||
re.compile(r"eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"), # JWTs
|
||||
re.compile(r"\b[0-9a-fA-F]{32,}\b"), # long hex blobs (generic keys)
|
||||
re.compile(r"AIza[0-9A-Za-z_\-]{35}"), # Google API keys
|
||||
re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*"), # Authorization: Bearer <token>
|
||||
)
|
||||
|
||||
# Config keys whose values are secrets regardless of shape.
|
||||
_SECRET_CONFIG_KEYS = frozenset({"api_key", "api_keys", "llm_api_key", "authorization"})
|
||||
|
||||
|
||||
def scrub_text(text: str) -> str:
|
||||
"""Replace anything that looks like a credential with ``[REDACTED]``."""
|
||||
out = text
|
||||
for pattern in _SECRET_PATTERNS:
|
||||
out = pattern.sub(_REDACTED, out)
|
||||
return out
|
||||
|
||||
|
||||
def scrub_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a deep copy of ``config`` with secret-bearing keys redacted.
|
||||
|
||||
``provider`` / ``model`` / ``api_base`` are preserved (needed in the manifest,
|
||||
not secrets); ``api_key`` and every entry under ``api_keys`` are replaced.
|
||||
"""
|
||||
out = copy.deepcopy(config)
|
||||
for key in list(out.keys()):
|
||||
if key in _SECRET_CONFIG_KEYS:
|
||||
value = out[key]
|
||||
if isinstance(value, dict):
|
||||
out[key] = {k: _REDACTED for k in value}
|
||||
else:
|
||||
out[key] = _REDACTED
|
||||
return out
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Boot/teardown the backend (+ optional frontend) for a run.
|
||||
|
||||
The backend is spawned with DATA_DIR pointed at the bundle's ``data/`` dir, so
|
||||
the dev's real database.json and uploads are never touched, and the DATA_DIR-
|
||||
aware reads (feature flags / content language, via ``config_cache``) use the
|
||||
bundle's copied ``config.json``.
|
||||
|
||||
The LLM key/provider is resolved separately, via ``app.config.load_config_file``
|
||||
which reads the repo's real ``apps/backend/data/config.json`` (a hardcoded path,
|
||||
NOT the bundle copy). That is intentional: the run uses the dev's configured
|
||||
provider, and the opt-in gate (``e2e_monitor.gate``) has already verified that
|
||||
real config carries a usable key before any move runs.
|
||||
|
||||
Process stdout/stderr stream into the bundle's log files — a durable log trail
|
||||
with no change to app/ logging.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from e2e_monitor import API_BASE
|
||||
from e2e_monitor.bundle import Bundle
|
||||
|
||||
BACKEND_HEALTH = f"{API_BASE}/health"
|
||||
FRONTEND_URL = "http://127.0.0.1:3000/"
|
||||
|
||||
|
||||
def _port_is_free(port: int) -> bool:
|
||||
"""True if nothing is listening on 127.0.0.1:<port>."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
return sock.connect_ex(("127.0.0.1", port)) != 0
|
||||
|
||||
|
||||
_REPO_BACKEND = Path(__file__).resolve().parents[1] # apps/backend
|
||||
_REPO_ROOT = _REPO_BACKEND.parents[1] # repo root
|
||||
_REAL_CONFIG = _REPO_BACKEND / "data" / "config.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Servers:
|
||||
bundle: Bundle
|
||||
procs: list[subprocess.Popen] = field(default_factory=list)
|
||||
log_files: list[Any] = field(default_factory=list)
|
||||
frontend_up: bool = False
|
||||
|
||||
def _wait(self, url: str, timeout_s: float) -> bool:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if httpx.get(url, timeout=2.0).status_code < 500:
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
return False
|
||||
|
||||
def boot(self, *, with_frontend: bool = True) -> dict[str, bool]:
|
||||
self.bundle.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
if _REAL_CONFIG.exists():
|
||||
shutil.copy2(_REAL_CONFIG, self.bundle.data_dir / "config.json")
|
||||
|
||||
if not _port_is_free(8000):
|
||||
raise RuntimeError(
|
||||
"port 8000 is already in use — stop any running backend so the "
|
||||
"monitor can bind its own isolated instance (DATA_DIR isolation "
|
||||
"depends on owning the port)."
|
||||
)
|
||||
|
||||
be_log = (self.bundle.logs_dir / "backend.log").open("w")
|
||||
self.log_files.append(be_log)
|
||||
env = {
|
||||
"DATA_DIR": str(self.bundle.data_dir),
|
||||
"PORT": "8000",
|
||||
"HOST": "127.0.0.1",
|
||||
"RELOAD": "false",
|
||||
"FRONTEND_BASE_URL": FRONTEND_URL.rstrip("/"),
|
||||
}
|
||||
self.procs.append(subprocess.Popen(
|
||||
["uv", "run", "app"],
|
||||
cwd=_REPO_BACKEND,
|
||||
stdout=be_log,
|
||||
stderr=subprocess.STDOUT,
|
||||
env={**os.environ, **env},
|
||||
))
|
||||
if not self._wait(BACKEND_HEALTH, timeout_s=60):
|
||||
raise RuntimeError("backend did not become healthy on :8000")
|
||||
|
||||
if with_frontend and shutil.which("node") and shutil.which("npm"):
|
||||
if not _port_is_free(3000):
|
||||
# Something is on :3000 — require a 200 from the root before trusting
|
||||
# it as the frontend (it proxies to our :8000). _wait() accepts any
|
||||
# <500 (incl. 404), so an unrelated HTTP service squatting the port
|
||||
# would be mistaken for a frontend; demand 200. Any failure leaves
|
||||
# frontend_up False and renders just skip.
|
||||
try:
|
||||
self.frontend_up = httpx.get(FRONTEND_URL, timeout=5.0).status_code == 200
|
||||
except httpx.HTTPError:
|
||||
self.frontend_up = False
|
||||
else:
|
||||
fe_log = (self.bundle.logs_dir / "frontend.log").open("w")
|
||||
self.log_files.append(fe_log)
|
||||
self.procs.append(subprocess.Popen(
|
||||
["npm", "run", "dev"], cwd=_REPO_ROOT / "apps" / "frontend",
|
||||
stdout=fe_log, stderr=subprocess.STDOUT, env={**os.environ},
|
||||
))
|
||||
self.frontend_up = self._wait(FRONTEND_URL, timeout_s=120)
|
||||
return {"frontend_up": self.frontend_up}
|
||||
|
||||
def teardown(self) -> None:
|
||||
for p in reversed(self.procs):
|
||||
p.terminate()
|
||||
for p in reversed(self.procs):
|
||||
try:
|
||||
p.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
self.procs.clear()
|
||||
for f in self.log_files:
|
||||
try:
|
||||
f.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.log_files.clear()
|
||||
@@ -0,0 +1,66 @@
|
||||
[project]
|
||||
name = "rm-backend"
|
||||
version = "1.2.0"
|
||||
description = "Resume Matcher Backend API"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi==0.128.4",
|
||||
"uvicorn==0.40.0",
|
||||
"python-multipart==0.0.31",
|
||||
"pydantic==2.12.5",
|
||||
"pydantic-settings==2.14.2",
|
||||
"tinydb==4.8.2",
|
||||
"sqlalchemy[asyncio]==2.0.36",
|
||||
"aiosqlite==0.20.0",
|
||||
"cryptography==48.0.1",
|
||||
"litellm==1.86.2",
|
||||
"markitdown[docx]==0.1.4",
|
||||
"pdfminer.six==20260107",
|
||||
"playwright==1.58.0",
|
||||
"python-docx==1.2.0",
|
||||
"python-dotenv==1.2.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
app = "app.main:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.28.0",
|
||||
"respx>=0.21.1",
|
||||
]
|
||||
e2e-monitor = [
|
||||
"pypdf>=4.0.0", # PDF text probe for the non-blank render check
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = [
|
||||
"--strict-markers",
|
||||
"-v",
|
||||
# Exclude LLM-as-judge evals from the default run (they may call a real
|
||||
# provider and are non-deterministic). Run them on demand with `-m eval`.
|
||||
"-m",
|
||||
"not eval",
|
||||
]
|
||||
markers = [
|
||||
"unit: pure function tests, no external dependencies",
|
||||
"service: service layer tests with mocked LLM",
|
||||
"integration: API endpoint tests with httpx AsyncClient",
|
||||
"eval: prompt-quality evaluations; may call a real LLM, skipped without a configured key",
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
fastapi==0.128.4
|
||||
uvicorn==0.40.0
|
||||
python-multipart==0.0.31
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.14.2
|
||||
tinydb==4.8.2
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
aiosqlite==0.20.0
|
||||
cryptography==48.0.1
|
||||
litellm==1.86.2
|
||||
markitdown[docx]==0.1.4
|
||||
python-docx==1.2.0
|
||||
playwright==1.58.0
|
||||
python-dotenv==1.2.2
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Shared test fixtures for Resume Matcher backend tests."""
|
||||
|
||||
import copy
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sample resume data — full ResumeData-compatible dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def sample_resume() -> dict:
|
||||
"""A realistic resume dict matching the ResumeData schema."""
|
||||
return {
|
||||
"personalInfo": {
|
||||
"name": "Jane Doe",
|
||||
"title": "Senior Backend Engineer",
|
||||
"email": "jane@example.com",
|
||||
"phone": "+1-555-0100",
|
||||
"location": "San Francisco, CA",
|
||||
"website": "https://janedoe.dev",
|
||||
"linkedin": "linkedin.com/in/janedoe",
|
||||
"github": "github.com/janedoe",
|
||||
},
|
||||
"summary": "Backend engineer with 6 years of experience building scalable Python APIs and microservices.",
|
||||
"workExperience": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Senior Backend Engineer",
|
||||
"company": "Acme Corp",
|
||||
"location": "San Francisco, CA",
|
||||
"years": "Jan 2021 - Present",
|
||||
"description": [
|
||||
"Built REST APIs serving 50K requests/day using Python and FastAPI",
|
||||
"Led migration from monolith to microservices architecture",
|
||||
"Mentored 3 junior developers on backend best practices",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Software Engineer",
|
||||
"company": "StartupCo",
|
||||
"location": "New York, NY",
|
||||
"years": "Jun 2018 - Dec 2020",
|
||||
"description": [
|
||||
"Developed payment processing system handling $2M monthly",
|
||||
"Wrote unit and integration tests improving coverage from 40% to 85%",
|
||||
],
|
||||
},
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"id": 1,
|
||||
"institution": "MIT",
|
||||
"degree": "B.S. Computer Science",
|
||||
"years": "2014 - 2018",
|
||||
"description": "Graduated with honors, Dean's List",
|
||||
}
|
||||
],
|
||||
"personalProjects": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "OpenAPI Generator",
|
||||
"role": "Creator & Maintainer",
|
||||
"years": "Mar 2021 - Present",
|
||||
"description": [
|
||||
"CLI tool generating API clients from OpenAPI specs",
|
||||
"500+ GitHub stars, used by 30+ companies",
|
||||
],
|
||||
}
|
||||
],
|
||||
"additional": {
|
||||
"technicalSkills": ["Python", "FastAPI", "Docker", "AWS", "PostgreSQL", "Redis"],
|
||||
"languages": ["English (Native)", "Spanish (Conversational)"],
|
||||
"certificationsTraining": ["AWS Solutions Architect Associate"],
|
||||
"awards": ["Employee of the Year 2022"],
|
||||
},
|
||||
"customSections": {},
|
||||
"sectionMeta": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_resume_copy(sample_resume) -> dict:
|
||||
"""Deep copy of sample_resume for mutation-safe tests."""
|
||||
return copy.deepcopy(sample_resume)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job-related fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def sample_job_keywords() -> dict:
|
||||
"""Extracted job keywords matching the LLM output format."""
|
||||
return {
|
||||
"required_skills": ["Python", "FastAPI", "Docker", "Kubernetes"],
|
||||
"preferred_skills": ["AWS", "Terraform", "GraphQL"],
|
||||
"experience_requirements": ["5+ years backend development"],
|
||||
"education_requirements": ["Bachelor's in CS or equivalent"],
|
||||
"key_responsibilities": [
|
||||
"Design and build scalable APIs",
|
||||
"Lead technical architecture decisions",
|
||||
],
|
||||
"keywords": ["microservices", "CI/CD", "agile", "REST API"],
|
||||
"experience_years": 5,
|
||||
"seniority_level": "senior",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_job_description() -> str:
|
||||
"""A realistic job description text."""
|
||||
return (
|
||||
"Senior Backend Engineer at TechCorp\n\n"
|
||||
"We are looking for a Senior Backend Engineer to join our platform team. "
|
||||
"You will design and build scalable APIs using Python and FastAPI. "
|
||||
"Experience with Docker, Kubernetes, and AWS is required. "
|
||||
"Terraform and GraphQL experience is a plus.\n\n"
|
||||
"Requirements:\n"
|
||||
"- 5+ years backend development experience\n"
|
||||
"- Strong Python skills with FastAPI or similar frameworks\n"
|
||||
"- Experience with microservices architecture\n"
|
||||
"- Familiarity with CI/CD pipelines and agile methodologies\n"
|
||||
"- Bachelor's degree in CS or equivalent\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Master resume — used for alignment validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def master_resume(sample_resume) -> dict:
|
||||
"""Master resume (source of truth) — same as sample_resume by default."""
|
||||
return copy.deepcopy(sample_resume)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResumeChange fixtures for diff-based tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def sample_changes():
|
||||
"""A set of ResumeChange dicts covering all action types."""
|
||||
from app.schemas.models import ResumeChange
|
||||
|
||||
return [
|
||||
ResumeChange(
|
||||
path="summary",
|
||||
action="replace",
|
||||
original="Backend engineer with 6 years of experience building scalable Python APIs and microservices.",
|
||||
value="Senior backend engineer with 6 years building scalable Python APIs, microservices, and cloud infrastructure on AWS.",
|
||||
reason="Added cloud/AWS keywords from JD",
|
||||
),
|
||||
ResumeChange(
|
||||
path="workExperience[0].description[0]",
|
||||
action="replace",
|
||||
original="Built REST APIs serving 50K requests/day using Python and FastAPI",
|
||||
value="Designed and built REST APIs serving 50K requests/day using Python, FastAPI, and Docker",
|
||||
reason="Added Docker keyword from JD",
|
||||
),
|
||||
ResumeChange(
|
||||
path="workExperience[0].description",
|
||||
action="append",
|
||||
original=None,
|
||||
value="Implemented CI/CD pipelines with GitHub Actions reducing deploy time by 40%",
|
||||
reason="Added CI/CD keyword from JD",
|
||||
),
|
||||
ResumeChange(
|
||||
path="additional.technicalSkills",
|
||||
action="reorder",
|
||||
original=None,
|
||||
value=["Python", "FastAPI", "Docker", "AWS", "PostgreSQL", "Redis"],
|
||||
reason="Already in good order, no change needed",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolated database — swap the global TinyDB singleton for a temp-file DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
async def isolated_db(tmp_path, monkeypatch):
|
||||
"""Replace the global ``db`` singleton with a disposable temp-file SQLite DB
|
||||
across ``app.database`` and every router module that imported it.
|
||||
|
||||
Lets endpoint / e2e tests run against a REAL (but isolated) database instead
|
||||
of a MagicMock, so persistence, the master-resume invariant, and CRUD are
|
||||
actually exercised — without touching the developer's real database. A
|
||||
temp **file** (not ``:memory:``) is required: SQLite's connection pool gives
|
||||
each connection its own in-memory DB, so the async + sync engines would not
|
||||
share state.
|
||||
"""
|
||||
import app.database as database_module
|
||||
from app.database import Database
|
||||
|
||||
test_db = Database(db_path=tmp_path / "isolated_db.db")
|
||||
monkeypatch.setattr(database_module, "db", test_db)
|
||||
for router_name in (
|
||||
"resumes",
|
||||
"jobs",
|
||||
"enrichment",
|
||||
"config",
|
||||
"health",
|
||||
"applications",
|
||||
"resume_wizard",
|
||||
):
|
||||
try:
|
||||
module = importlib.import_module(f"app.routers.{router_name}")
|
||||
except ModuleNotFoundError:
|
||||
continue
|
||||
if hasattr(module, "db"):
|
||||
monkeypatch.setattr(module, "db", test_db)
|
||||
try:
|
||||
yield test_db
|
||||
finally:
|
||||
await test_db.close()
|
||||
@@ -0,0 +1,95 @@
|
||||
# Eval harness — "did the prompt change make tailoring _better_?"
|
||||
|
||||
Deterministic tests answer *"is the plumbing correct?"* They can't answer
|
||||
*"did this prompt edit make the tailored resume better or worse?"* — that needs
|
||||
**evals**. This directory holds the eval harness for the Resume-Matcher
|
||||
backend, in two deliberately separate layers.
|
||||
|
||||
See [`docs/agent/testing-strategy.md`](../../../../docs/agent/testing-strategy.md)
|
||||
§3 (Phase 5) for the full rationale.
|
||||
|
||||
---
|
||||
|
||||
## The two layers
|
||||
|
||||
### 1. Structural scorers — deterministic, free, run everywhere
|
||||
|
||||
Pure functions in [`scorers.py`](./scorers.py) that check invariants which must
|
||||
hold no matter how the LLM worded things. **No LLM, no network, no disk.** They
|
||||
form the cheap first line of defence: most "a prompt change broke something"
|
||||
regressions are caught here for free.
|
||||
|
||||
| Scorer | What it checks |
|
||||
|--------|----------------|
|
||||
| `sections_preserved(original, tailored) -> bool` | No populated top-level section (work experience, education, …) vanishes during tailoring. |
|
||||
| `no_fabricated_employers(original, tailored) -> list[str]` | Company names in the tailored work history that were **not** in the original — i.e. invented employers. Empty list = truthful. |
|
||||
| `jd_keywords_present(tailored, keywords) -> float` | Fraction (0–1) of the JD's keywords that actually appear (case-insensitive) in the tailored resume. |
|
||||
| `is_valid_resume(data) -> bool` | The result still validates against `ResumeData`. |
|
||||
| `personal_info_unchanged(original, tailored) -> bool` | The candidate's identity block (`personalInfo`) is byte-for-byte unchanged. |
|
||||
|
||||
Their tests live in [`test_scorers.py`](./test_scorers.py) and prove **each
|
||||
scorer fires on a known-bad input** (drop a section → `False`, invent a company
|
||||
→ it's returned, change the name → `False`, …). That's the anti-theater proof
|
||||
that the scorers detect real violations rather than always saying "OK".
|
||||
|
||||
### 2. LLM-as-judge — real model, scores quality, run on demand
|
||||
|
||||
[`test_tailoring_eval.py`](./test_tailoring_eval.py) sends a golden tailored
|
||||
resume + its JD to a **real LLM** and asks it to grade tailoring quality on a
|
||||
rubric (relevance / truthfulness / formatting), returning
|
||||
`{"score": 1-5, "reasons": "…"}`, then asserts `score >= 3`.
|
||||
|
||||
- Marked `@pytest.mark.eval` (the `eval` marker is declared in `pyproject.toml`).
|
||||
- Uses the **developer's own configured key/provider** via `app.llm`.
|
||||
- **Skips cleanly when no key is configured** — the key check (`_needs_key()`)
|
||||
is the first line of the test, so a keyless environment never makes an
|
||||
ungated real call. It is never part of a keyless CI gate.
|
||||
|
||||
---
|
||||
|
||||
## How to run
|
||||
|
||||
From `apps/backend`:
|
||||
|
||||
```bash
|
||||
# Structural scorers only — runs everywhere, no key needed, free & fast.
|
||||
uv run pytest tests/evals
|
||||
|
||||
# Add the LLM-as-judge eval — only meaningful with a configured key.
|
||||
# Skips (does not error) when no key is present.
|
||||
uv run pytest tests/evals -m eval
|
||||
```
|
||||
|
||||
A clean keyless run shows the scorer tests passing and the one judge test
|
||||
**skipped**. To actually exercise the judge, configure a provider/key (env or
|
||||
the Settings UI → `data/config.json`) the same way you would to run the app,
|
||||
then re-run with `-m eval`.
|
||||
|
||||
---
|
||||
|
||||
## Adding a golden fixture
|
||||
|
||||
Golden fixtures live in [`golden/cases.py`](./golden/cases.py) as the
|
||||
`GOLDEN_CASES` list. Each entry is a plain dict:
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "short_id",
|
||||
"original": { ... }, # master resume (ResumeData-compatible)
|
||||
"job_description": "…", # the target JD text
|
||||
"jd_keywords": ["…", "…"], # keywords the tailoring should surface
|
||||
"tailored_good": { ... }, # faithful tailoring — passes every scorer
|
||||
"tailored_bad": { ... }, # broken tailoring — must trip the scorers
|
||||
}
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Keep `original` and `tailored_good` **valid against `ResumeData`** (so
|
||||
`is_valid_resume` stays meaningful) and make sure every `jd_keywords` entry
|
||||
truly appears in `tailored_good` (the structural test asserts a perfect 1.0).
|
||||
- Make `tailored_bad` violate at least one invariant on purpose — drop a
|
||||
section, invent an employer, or rewrite the name — so the scorer tests keep
|
||||
proving detection works.
|
||||
- Append; don't rewrite existing cases. The parametrized tests in
|
||||
`test_scorers.py` pick up new cases automatically.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user