Track job postings, create targeted CVs, render them in LaTeX, achieve constant velocity.
- Uses agentic analysis to
- Create structured data from Job Posting URLs or text files
- Create structured CV data from text CV files
- Optimize CV data for a job posting with relevant experience using RAG
- Generates Markdown versions of domain objects, suitable for Obsidian
- Renders CV data in LaTeX to generate PDFs
-
Agentic analysis with CrewAI
-
Pydantic for domain objects and structured outputs
-
Click CLI
-
Gradio tabbed UI
-
CVs are rendered using Jinja2 with custom delimiters that play well with LaTeX:
customized standard jinja2 Statements (# #){% %}Expressions (( )){{ }}Comments %( )%{# #}Line Comment %%##
Simplified project structure:
.
├── src
│ ├── renderers
│ │ └── latex # LaTeX PDF rendering
│ ├── config
│ ├── crews
│ │ ├── cv_analysis
│ │ ├── cv_optimization
│ │ ├── job_posting_analysis
│ │ └── tools
│ ├── infrastructure
│ ├── models
│ ├── renderers
│ │ └── latex
│ ├── repositories
│ │ └── filesystem.py
│ ├── services
│ │ └── analyzers # crew facades
│ │ └── application.py
│ │ └── converters.py
│ │ └── exporter.py
│ │ └── knowledge_chat.py
│ └── ui
│ │ └── app.py # Gradio
│ │ └── cli.py
└── templates
├── cover-letter.tex
└── cv.texmake installruns uv tool install --editable .
cp sample.env .envSet environment variables in .env:
ANTHROPIC_API_KEY=
DEEPSEEK_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
SERPER_API_KEY=Configure the rag-knowledge MCP server for RAG functions (see below).
Configuration override hierarchy:
src/*/config/settings.yaml(defaults)~/.cv-joint/settings.yaml(user dotfile)src/*/config/settings.local.yaml(machine-specific overrides, gitignored)
Strings beginning with ~/ will undergo tilde expansion.
Example user settings (~/.cv-joint/settings.yaml):
chat:
model: "gpt-4o"
mcpServers:
rag-knowledge: # null in default settings
command: "~/.local/bin/uv"
args:
- "run"
- "--directory"
- "~/code/mcp-server-project"
- "python"
- "rag_knowledge_mcp.py"
env:
LOG_LEVEL: "INFO"
x-tool-name: "rag_search_knowledge"
crews:
cv_analysis:
agents:
cv_analyst:
model: gpt-4o
job_posting_analysis:
agents:
job_analyst:
model: gpt-4o
cv_optimization:
agents:
cv_strategist:
model: claude-sonnet-4-20250514
cv_rewriter:
model: claude-sonnet-4-20250514
repositories:
filesystem:
data_dir: "~/vaults/frobozz/areas/job-search/cv-joint"Data directory structure:
{data_dir}/
├── collections/
│ ├── job-postings.json
│ ├── cvs.json
│ └── optimized-cvs.json
├── job-postings/{identifier}/
│ ├── job-posting.json
│ ├── job-posting.md
│ └── cvs/{identifier}/ # optimized for job posting
│ ├── curriculum-vitae.json
│ ├── curriculum-vitae.md
│ ├── cv-transformation-plan.json
│ └── cv-transformation-plan.md
└── cvs/{identifier}/
├── curriculum-vitae.json
└── curriculum-vitae.md
make testruns uv run pytest tests/ --tb=short
cv-jointLaunches Gradio, runs GRADIO_LAUNCHED_COMMAND, GRADIO_FINISHED_COMMAND after Ctl-C
(I have it run a wrapper app pointing at http://localhost:7860, then close it)
cv-joint openLaunches Gradio then opens the default browser at http://localhost:7860
# UI
cv-joint # serve at http://localhost:7860
cv-joint open # serve and open in browser
# Job postings
cv-joint list job-postings
cv-joint list job-postings --archived
cv-joint list job-postings -q acme
cv-joint archive job-postings/{id}
cv-joint apply job-postings/{id} {cv-id}
cv-joint apply job-postings/{id} {cv-id} --date 2026-05-27
cv-joint reanalyze job-postings/{id}
# CVs
cv-joint list cvs
cv-joint list cvs -q wesley
cv-joint reanalyze cvs/{id} path/to/file
# General
cv-joint remove job-postings/{id}
cv-joint remove job-postings/{id}/cvs/{id}
cv-joint rename job-postings/{id} {new-id}
cv-joint export-markdown
cv-joint export-markdown [job-postings|cvs]
cv-joint show-config