chore: import upstream snapshot with attribution
Publish Docker Image / publish (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:36 +08:00
commit 5bdf4cc89a
423 changed files with 88197 additions and 0 deletions
+208
View File
@@ -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)
+27
View File
@@ -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:*)"
]
}
}
+62
View File
@@ -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
+57
View File
@@ -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.
+70
View File
@@ -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
+132
View File
@@ -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
+207
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
github: srbhr
custom: ["https://github.com/sponsors/srbhr"]
+42
View File
@@ -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 -->
+59
View File
@@ -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
+15
View File
@@ -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.
+12
View File
@@ -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
+50
View File
@@ -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
+9
View File
@@ -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.
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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 3060 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, 12px 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.53× 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
View File
@@ -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"]
+201
View File
@@ -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
View File
@@ -0,0 +1,282 @@
<div align="center">
[![Resume Matcher](assets/header.png)](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**.
![Resume Matcher Demo](assets/Resume_Matcher_Demo_2.gif)
</div>
<br>
<div align="center">
![Stars](https://img.shields.io/github/stars/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)
![Apache 2.0](https://img.shields.io/github/license/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8) ![Forks](https://img.shields.io/github/forks/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8) ![version](https://img.shields.io/badge/Version-1.2%20Nightvision-FFF?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)
[![Discord](https://img.shields.io/discord/1122069176962531400?labelColor=F0F0E8&logo=discord&logoColor=1d4ed8&style=for-the-badge&color=1d4ed8)](https://dsc.gg/resume-matcher) [![Website](https://img.shields.io/badge/website-Resume%20Matcher-FFF?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)](https://resumematcher.fyi) [![LinkedIn](https://img.shields.io/badge/LinkedIn-Resume%20Matcher-FFF?labelColor=F0F0E8&logo=LinkedIn&style=for-the-badge&color=1d4ed8)](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>
![Vercel OSS Program](https://vercel.com/oss/program-badge.svg)
</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">
[![Sponsor on GitHub](https://img.shields.io/github/sponsors/srbhr?style=for-the-badge&label=Sponsor&color=1d4ed8&labelColor=F0F0E8&logo=github&logoColor=black)](https://github.com/sponsors/srbhr) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&color=1d4ed8&labelColor=F0F0E8&logoColor=black)](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
[![Discord](assets/resume_matcher_discord.png)](https://dsc.gg/resume-matcher)
Únete a nuestro [Discord](https://dsc.gg/resume-matcher) para discusiones, solicitudes de funcionalidades y soporte de la comunidad.
[![LinkedIn](assets/resume_matcher_linkedin.png)](https://www.linkedin.com/company/resume-matcher/)
Síguenos en [LinkedIn](https://www.linkedin.com/company/resume-matcher/) para actualizaciones.
![Star Resume Matcher](assets/star_resume_matcher.png)
Dale una estrella al repositorio para apoyar el desarrollo y recibir notificaciones de nuevas versiones.
<a id="sponsors"></a>
## Patrocinadores
![sponsors](assets/sponsors.png)
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
![donate](assets/supporting_resume_matcher.png)
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 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/srbhr?style=for-the-badge&color=1d4ed8&labelColor=F0F0E8&logo=github&logoColor=black)](https://github.com/sponsors/srbhr) |
| Buy Me a Coffee | [![BuyMeACoffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&color=1d4ed8&labelColor=F0F0E8&logoColor=black)](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
![resume_matcher_features](assets/features.png)
### Funciones principales
**Currículum maestro**: crea un currículum maestro completo a partir de tu currículum actual.
![Job Description Input](assets/step_2.png)
### Constructor de currículum
![Resume Builder](assets/step_5.png)
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.
![Cover Letter](assets/cover_letter_es.png)
### 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.
![Resume Scoring and Keyword Highlight](assets/keyword_highlighter.png)
### 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)** | ![Classic Template](assets/pdf-templates/single-column.jpg) | Diseño tradicional y limpio, adecuado para la mayoría de industrias. [Ver PDF](assets/pdf-templates/single-column.pdf) |
| **Moderna (una columna)** | ![Modern Template](assets/pdf-templates/modern-single-column.jpg) | Diseño contemporáneo enfocado en legibilidad y estética. [Ver PDF](assets/pdf-templates/modern-single-column.pdf) |
| **Clásica (dos columnas)** | ![Classic Two Column Template](assets/pdf-templates/two-column.jpg) | Estructura que separa secciones para mayor claridad. [Ver PDF](assets/pdf-templates/two-column.pdf) |
| **Moderna (dos columnas)** | ![Modern Two Column Template](assets/pdf-templates/modern-two-column.jpg) | 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
![Instalación](assets/how_to_install_resumematcher.png)
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
![Cómo contribuir](assets/how_to_contribute.png)
¡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
![Colaboradores](assets/contributors.png)
<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)
![Vercel OSS Program](https://vercel.com/oss/program-badge.svg)
+282
View File
@@ -0,0 +1,282 @@
<div align="center">
[![Resume Matcher](assets/header.png)](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 プロバイダに接続することも可能です。
![Resume Matcher Demo](assets/Resume_Matcher_Demo_2.gif)
</div>
<br>
<div align="center">
![Stars](https://img.shields.io/github/stars/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)
![Apache 2.0](https://img.shields.io/github/license/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8) ![Forks](https://img.shields.io/github/forks/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8) ![version](https://img.shields.io/badge/Version-1.2%20Nightvision%20-FFF?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)
[![Discord](https://img.shields.io/discord/1122069176962531400?labelColor=F0F0E8&logo=discord&logoColor=1d4ed8&style=for-the-badge&color=1d4ed8)](https://dsc.gg/resume-matcher) [![Website](https://img.shields.io/badge/website-Resume%20Matcher-FFF?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)](https://resumematcher.fyi) [![LinkedIn](https://img.shields.io/badge/LinkedIn-Resume%20Matcher-FFF?labelColor=F0F0E8&logo=LinkedIn&style=for-the-badge&color=1d4ed8)](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>
![Vercel OSS Program](https://vercel.com/oss/program-badge.svg)
</div>
> \[!IMPORTANT]
>
> 本プロジェクトには皆さまの支援が必要です。少額でもご寄付いただけると、Resume Matcher の開発と改善を続ける力になります。
<div align="center">
[![Sponsor on GitHub](https://img.shields.io/github/sponsors/srbhr?style=for-the-badge&label=Sponsor&color=1d4ed8&labelColor=F0F0E8&logo=github&logoColor=black)](https://github.com/sponsors/srbhr) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&color=1d4ed8&labelColor=F0F0E8&logoColor=black)](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 を出力
### コミュニティ
[![Discord](assets/resume_matcher_discord.png)](https://dsc.gg/resume-matcher)
ディスカッション、要望、サポートは [Discord](https://dsc.gg/resume-matcher) へ。
[![LinkedIn](assets/resume_matcher_linkedin.png)](https://www.linkedin.com/company/resume-matcher/)
最新情報は [LinkedIn](https://www.linkedin.com/company/resume-matcher/) でも発信しています。
![Star Resume Matcher](assets/star_resume_matcher.png)
Star を付けていただけると開発の励みになります(リリース通知も受け取れます)。
<a id="sponsors"></a>
## スポンサー
![sponsors](assets/sponsors.png)
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>
### 個人として支援する
![donate](assets/supporting_resume_matcher.png)
少額の支援でも Resume Matcher を無料に保ち、新機能の開発を支えます —— ReadME とウェブサイトにて感謝の意をお伝えします。
| プラットフォーム | リンク |
|------------------|--------|
| GitHub | [![GitHub Sponsors](https://img.shields.io/github/sponsors/srbhr?style=for-the-badge&color=1d4ed8&labelColor=F0F0E8&logo=github&logoColor=black)](https://github.com/sponsors/srbhr) |
| Buy Me a Coffee | [![BuyMeACoffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&color=1d4ed8&labelColor=F0F0E8&logoColor=black)](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)
## 主な機能
![resume_matcher_features](assets/features.png)
### コア機能
**マスター履歴書(Master Resume**:既存の履歴書から、再利用できる包括的なマスター履歴書を作成します。
![Job Description Input](assets/step_2.png)
### 履歴書ビルダー
![Resume Builder](assets/step_5_ja.png)
求人票を貼り付けると、その職種に合わせた AI 提案の履歴書を生成します。
できること:
- 提案内容の編集
- セクションの追加/削除
- ドラッグ&ドロップで順序変更
- 複数テンプレートから選択
### カバーレター&メール生成
求人票と履歴書に基づき、カスタマイズされたカバーレターとメール文面を生成します。
![Cover Letter](assets/cover_letter_ja.png)
### 履歴書スコアリング(開発中)
履歴書と求人票を比較して、マッチスコアと改善提案を出す機能を開発中です。
![Resume Scoring and Keyword Highlight](assets/keyword_highlighter_ja.png)
### PDF 出力
最適化した履歴書とカバーレターを PDF として出力できます。
### テンプレート
| テンプレート名 | プレビュー | 説明 |
|---------------|-----------|------|
| **クラシック(1 カラム)** | ![Classic Template](assets/pdf-templates/single-column.jpg) | 伝統的でクリーンなレイアウト。多くの業種に適しています。[PDF を見る](assets/pdf-templates/single-column.pdf) |
| **モダン(1 カラム)** | ![Modern Template](assets/pdf-templates/modern-single-column.jpg) | 可読性と美しさを重視した現代的なデザイン。[PDF を見る](assets/pdf-templates/modern-single-column.pdf) |
| **クラシック(2 カラム)** | ![Classic Two Column Template](assets/pdf-templates/two-column.jpg) | セクションを分けて見やすく整理します。[PDF を見る](assets/pdf-templates/two-column.pdf) |
| **モダン(2 カラム)** | ![Modern Two Column Template](assets/pdf-templates/modern-two-column.jpg) | 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>
## インストール方法
![Installation](assets/how_to_install_resumematcher.png)
詳細なセットアップ手順は **[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 |
## 参加・コントリビュート
![how to contribute](assets/how_to_contribute.png)
どなたでもコントリビュート歓迎です。開発者・デザイナー・ユーザーを問わず、協力してくれる方を募集しています。コントリビューター一覧は、公式サイトの [about ページ](https://resumematcher.fyi/about) と GitHub README に掲載されています。
ロードマップも参考にしてください。提案や機能要望があれば、GitHub で Issue を作成し、[Discord](https://dsc.gg/resume-matcher) でも議論できます。
<a id="contributors"></a>
## コントリビューター
![Contributors](assets/contributors.png)
<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) の一部です
![Vercel OSS Program](https://vercel.com/oss/program-badge.svg)
+301
View File
@@ -0,0 +1,301 @@
<div align="center">
[![Resume Matcher](assets/header.png)](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.
![Resume Matcher Demo](assets/Resume_Matcher_Demo_2.gif)
</div>
<br>
<div align="center">
![Stars](https://img.shields.io/github/stars/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)
![Apache 2.0](https://img.shields.io/github/license/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8) ![Forks](https://img.shields.io/github/forks/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8) ![version](https://img.shields.io/badge/Version-1.2%20Nightvision%20-FFF?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)
[![Discord](https://img.shields.io/discord/1122069176962531400?labelColor=F0F0E8&logo=discord&logoColor=1d4ed8&style=for-the-badge&color=1d4ed8)](https://dsc.gg/resume-matcher) [![Website](https://img.shields.io/badge/website-Resume%20Matcher-FFF?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)](https://resumematcher.fyi) [![LinkedIn](https://img.shields.io/badge/LinkedIn-Resume%20Matcher-FFF?labelColor=F0F0E8&logo=LinkedIn&style=for-the-badge&color=1d4ed8)](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>
![Vercel OSS Program](https://vercel.com/oss/program-badge.svg)
</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">
[![Sponsor on GitHub](https://img.shields.io/github/sponsors/srbhr?style=for-the-badge&label=Sponsor&color=1d4ed8&labelColor=F0F0E8&logo=github&logoColor=black)](https://github.com/sponsors/srbhr) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&color=1d4ed8&labelColor=F0F0E8&logoColor=black)](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
[![Discord](assets/resume_matcher_discord.png)](https://dsc.gg/resume-matcher)
Join our [Discord](https://dsc.gg/resume-matcher) for discussions, feature requests, and community support.
[![LinkedIn](assets/resume_matcher_linkedin.png)](https://www.linkedin.com/company/resume-matcher/)
Follow us on [LinkedIn](https://www.linkedin.com/company/resume-matcher/) for updates.
![Star Resume Matcher](assets/star_resume_matcher.png)
Star the repo to support development and get notified of new releases.
## Sponsors
![sponsors](assets/sponsors.png)
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
![donate](assets/supporting_resume_matcher.png)
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 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/srbhr?style=for-the-badge&color=1d4ed8&labelColor=F0F0E8&logo=github&logoColor=black)](https://github.com/sponsors/srbhr) |
| Buy Me a Coffee | [![BuyMeACoffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&color=1d4ed8&labelColor=F0F0E8&logoColor=black)](https://www.buymeacoffee.com/srbhr) |
## Creators' Note
[![srbhr](assets/creators_note.png)](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
![resume_matcher_features](assets/features.png)
### Core Features
**Master Resume**: Create a comprehensive master resume to draw from your existing one.
![Job Description Input](assets/step_2.png)
### Resume Builder
![Resume Builder](assets/step_5.png)
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.
![Cover Letter](assets/cover_letter.png)
### 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.
![Resume Scoring and Keyword Highlight](assets/keyword_highlighter.png)
### PDF Export
Export your tailored resume and cover letter in PDF.
### Templates
| Template Name | Preview | Description |
|---------------|---------|-------------|
| **Classic Single Column** | ![Classic Template](assets/pdf-templates/single-column.jpg) | A traditional and clean layout suitable for most industries. [𝐕𝐢𝐞𝐰 𝐏𝐃𝐅](assets/pdf-templates/single-column.pdf) |
| **Modern Single Column** | ![Modern Template](assets/pdf-templates/modern-single-column.jpg) | A contemporary design with a focus on readability and aesthetics. [𝐕𝐢𝐞𝐰 𝐏𝐃𝐅](assets/pdf-templates/modern-single-column.pdf)|
| **Classic Two Column** | ![Classic Two Column Template](assets/pdf-templates/two-column.jpg) | A structured layout that separates sections for clarity. [𝐕𝐢𝐞𝐰 𝐏𝐃𝐅](assets/pdf-templates/two-column.pdf)|
| **Modern Two Column** | ![Modern Two Column Template](assets/pdf-templates/modern-two-column.jpg) | 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
![Installation](assets/how_to_install_resumematcher.png)
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
![how to contribute](assets/how_to_contribute.png)
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
![Contributors](assets/contributors.png)
<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)
![Vercel OSS Program](https://vercel.com/oss/program-badge.svg)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`srbhr/Resume-Matcher`
- 原始仓库:https://github.com/srbhr/Resume-Matcher
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+282
View File
@@ -0,0 +1,282 @@
<div align="center">
[![Resume Matcher](assets/header.png)](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 提供商。
![Resume Matcher Demo](assets/Resume_Matcher_Demo_2.gif)
</div>
<br>
<div align="center">
![Stars](https://img.shields.io/github/stars/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)
![Apache 2.0](https://img.shields.io/github/license/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8) ![Forks](https://img.shields.io/github/forks/srbhr/Resume-Matcher?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8) ![version](https://img.shields.io/badge/Version-1.2%20Nightvision%20-FFF?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)
[![Discord](https://img.shields.io/discord/1122069176962531400?labelColor=F0F0E8&logo=discord&logoColor=1d4ed8&style=for-the-badge&color=1d4ed8)](https://dsc.gg/resume-matcher) [![Website](https://img.shields.io/badge/website-Resume%20Matcher-FFF?labelColor=F0F0E8&style=for-the-badge&color=1d4ed8)](https://resumematcher.fyi) [![LinkedIn](https://img.shields.io/badge/LinkedIn-Resume%20Matcher-FFF?labelColor=F0F0E8&logo=LinkedIn&style=for-the-badge&color=1d4ed8)](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>
![Vercel OSS Program](https://vercel.com/oss/program-badge.svg)
</div>
> \[!IMPORTANT]
>
> 本项目需要你的帮助与支持。如果你能捐赠一点点,就能帮助我持续开发和改进 Resume Matcher。
<div align="center">
[![Sponsor on GitHub](https://img.shields.io/github/sponsors/srbhr?style=for-the-badge&label=Sponsor&color=1d4ed8&labelColor=F0F0E8&logo=github&logoColor=black)](https://github.com/sponsors/srbhr) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&color=1d4ed8&labelColor=F0F0E8&logoColor=black)](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
### 保持联系
[![Discord](assets/resume_matcher_discord.png)](https://dsc.gg/resume-matcher)
加入我们的 [Discord](https://dsc.gg/resume-matcher),参与讨论、功能需求与社区支持。
[![LinkedIn](assets/resume_matcher_linkedin.png)](https://www.linkedin.com/company/resume-matcher/)
关注我们的 [LinkedIn](https://www.linkedin.com/company/resume-matcher/) 获取更新。
![Star Resume Matcher](assets/star_resume_matcher.png)
给仓库点 Star 来支持开发,并及时获取新版本通知。
<a id="sponsors"></a>
## 赞助商
![sponsors](assets/sponsors.png)
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>
### 以个人身份支持
![donate](assets/supporting_resume_matcher.png)
每一份支持都让 Resume Matcher 保持免费,并资助新功能的开发 —— 你也会在 ReadME 和我们的网站上获得鸣谢。
| 平台 | 链接 |
|------|------|
| GitHub | [![GitHub Sponsors](https://img.shields.io/github/sponsors/srbhr?style=for-the-badge&color=1d4ed8&labelColor=F0F0E8&logo=github&logoColor=black)](https://github.com/sponsors/srbhr) |
| Buy Me a Coffee | [![BuyMeACoffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&color=1d4ed8&labelColor=F0F0E8&logoColor=black)](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)
## 主要功能
![resume_matcher_features](assets/features.png)
### 核心能力
**主简历(Master Resume**:基于你现有简历创建一份完整的主简历,后续每次投递都从这份主简历中抽取与定制。
![Job Description Input](assets/step_2_zh-CN.png)
### 简历生成器
![Resume Builder](assets/step_5_zh-CN.png)
粘贴职位描述后,获得针对该岗位定制的 AI 简历建议。
你可以:
- 修改建议内容
- 添加/移除章节
- 通过拖拽调整章节顺序
- 从多种简历模板中选择
### 求职信与邮件生成器
基于职位描述与你的简历,生成定制化的求职信与邮件模板。
![Cover Letter](assets/cover_letter_zh-CN.png)
### 简历评分(开发中功能)
我们正在开发“简历评分”功能:对比你的简历与职位描述,输出匹配分数,并给出改进建议。
![Resume Scoring and Keyword Highlight](assets/keyword_highlighter_zh-CN.png)
### PDF 导出
将定制后的简历与求职信导出为 PDF。
### 模板
| 模板名称 | 预览 | 说明 |
|---------|------|------|
| **经典单栏** | ![Classic Template](assets/pdf-templates/single-column.jpg) | 传统且干净的排版,适用于大多数行业。[查看 PDF](assets/pdf-templates/single-column.pdf) |
| **现代单栏** | ![Modern Template](assets/pdf-templates/modern-single-column.jpg) | 更强调可读性与审美的现代风格。[查看 PDF](assets/pdf-templates/modern-single-column.pdf) |
| **经典双栏** | ![Classic Two Column Template](assets/pdf-templates/two-column.jpg) | 将内容分区展示,更清晰易扫读。[查看 PDF](assets/pdf-templates/two-column.pdf) |
| **现代双栏** | ![Modern Two Column Template](assets/pdf-templates/modern-two-column.jpg) | 利用双栏结构做更强的信息组织。[查看 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>
## 如何安装
![Installation](assets/how_to_install_resumematcher.png)
更详细的安装与配置说明请查看 **[安装文档](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 |
| 数据库 | TinyDBJSON 文件存储) |
| 样式 | Tailwind CSS 4、Swiss International Style |
| PDF | Playwright 驱动的无头 Chromium |
## 参与贡献
![how to contribute](assets/how_to_contribute.png)
我们欢迎所有人的贡献!无论你是开发者、设计师,还是希望帮忙的用户。所有贡献者都会展示在我们官网的 [about 页面](https://resumematcher.fyi/about),也会显示在 GitHub README 中。
如果你希望参与未来规划的功能,可以先看看路线图。若你有建议或功能需求,欢迎在 GitHub 提 Issue,并在我们的 [Discord](https://dsc.gg/resume-matcher) 讨论。
<a id="contributors"></a>
## 贡献者
![Contributors](assets/contributors.png)
<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) 的一部分
![Vercel OSS Program](https://vercel.com/oss/program-badge.svg)
+505
View File
@@ -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
View File
@@ -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 は TinyDBJSON ファイル保存)を使用します。データは `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) をぜひ。
+563
View File
@@ -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
View File
@@ -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) |
### 安装 uvPython 包管理器)
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 使用 TinyDBJSON 文件存储)。数据位于 `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)。
+25
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.13
+171
View File
@@ -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).
+3
View File
@@ -0,0 +1,3 @@
"""Resume Matcher Backend - Lean & Local"""
__version__ = "2.0.0"
+336
View File
@@ -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()
+67
View File
@@ -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"))
+129
View File
@@ -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 ""
+777
View File
@@ -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()
+71
View File
@@ -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
+122
View File
@@ -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()
+137
View File
@@ -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)
+338
View File
@@ -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)
+59
View File
@@ -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",
]
+195
View File
@@ -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"""
+183
View File
@@ -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."""
+52
View File
@@ -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
}}"""
+591
View File
@@ -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"
}}"""
+19
View File
@@ -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",
]
+193
View File
@@ -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 {}
+629
View File
@@ -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"}
+809
View File
@@ -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),
}
+67
View File
@@ -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,
)
+50
View File
@@ -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
+123
View File
@@ -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
+143
View File
@@ -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",
]
+103
View File
@@ -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
+126
View File
@@ -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)
+862
View File
@@ -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 % (0100)"
)
skills_coverage: float = Field(
default=0.0, ge=0.0, le=100.0, description="JD skills matched in resume (0100)"
)
section_completeness: float = Field(
default=0.0,
ge=0.0,
le=100.0,
description="Key resume sections present (0100)",
)
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 (0100)",
)
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="")
+125
View File
@@ -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,
)
+115
View File
@@ -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
+1
View File
@@ -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()
+14
View File
@@ -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",
]
+217
View File
@@ -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 (0100).
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 (0100).
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,
),
}
+168
View File
@@ -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
+128
View File
@@ -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)
+176
View File
@@ -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()
+702
View File
@@ -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)
+447
View File
@@ -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
View File
@@ -0,0 +1,38 @@
---
name: monitor-e2e
description: Run + judge the Resume-Matcher agentic end-to-end monitor. Drives the real app (master resume → 34 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` (15 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.
+88
View File
@@ -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 34 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.
+14
View File
@@ -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"
+247
View File
@@ -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())
+77
View File
@@ -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
}
+50
View File
@@ -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"))
+38
View File
@@ -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": []
}
+102
View File
@@ -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),
}
+43
View File
@@ -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)."
)
+9
View File
@@ -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)"
+74
View File
@@ -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", ""))}
+22
View File
@@ -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),
}
+69
View File
@@ -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)
+46
View File
@@ -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
+133
View File
@@ -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()
+66
View File
@@ -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",
]
+14
View File
@@ -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
View File
+221
View File
@@ -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()
+95
View File
@@ -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 (01) 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