Architecture¶
At the end you will know how one request flows from a resume and job links to scored reports, where each responsibility lives in the code, and which rules the design never breaks.
The shape¶
Four entry points share one service layer. Nothing that matters lives in an adapter.
flowchart LR
U([Terminal]) --> CLI["jobmatch CLI"]
PG([Playground]) -->|server-side proxy| API["FastAPI /analyze"]
CB([Chat widget]) -->|REST/SSE| AS["Agent service<br/>LLM-2: orchestration"]
CD([Claude Desktop]) -->|stdio| MCP["MCP server"]
AS -->|MCP tools| MCP
MCP -->|REST| API
PY([Python callers]) --> SVC
CLI --> SVC["Service layer<br/>run_analysis()"]
API --> SVC
SVC --> CORE["Core: fetch, extract, score, report"]
One request, end to end¶
Each job source becomes its own asynchronous task. One job failing never
disturbs the others, and the number running at once is capped by
JOB_FANOUT_CONCURRENCY.
flowchart TD
R["Resume file"] --> RT["Extract resume text once"]
J["Job source: URL or file"] --> F{"Known ATS URL?"}
F -->|"yes: Ashby, Greenhouse, Lever"| A["ATS rules: resolve, one API request, extract"]
F -->|no| H["Page fetch: one request, HTML to text"]
A --> G["Word guard: minimum extractable words"]
H --> G
G -->|too short or failed| X["Typed failure with a reason"]
G --> L["LLM-1: typed extraction with resume quotes as evidence"]
RT --> L
L --> S["Deterministic scoring: 40 / 20 / 20 / 20, then match band"]
S --> O["JobReport: analysis, score, cover letter, recommendation"]
The model never scores. It returns typed matches with exact resume quotes, and plain code computes the number. Results are always a typed array of reports and failures, the same on every surface.
Where each responsibility lives¶
| Concern | Module | Notes |
|---|---|---|
| Run identity and fan-out | pipeline.py |
one run_id per request, no workflow layer |
| Getting job text | fetch.py |
scheme and blocked-host checks, byte cap, word guard, one attempt |
| Job boards with an API | ats.py and ats_rules/ |
routing and field picking as JSON rules, see ATS rules |
| Resume text and identity | resume.py, candidate.py |
pypdf and python-docx, no OCR |
| Extraction (LLM-1) | analyze.py, prompts/ |
typed output, no score field exists to inject into |
| Scoring and banding | scoring.py |
pure functions, fully deterministic |
| Contracts | schemas.py |
every boundary is a Pydantic model |
| Configuration | config.py |
the only place environment is read |
| Telemetry | observability/ |
decorator-driven, sinks chosen by environment |
Rules the design never breaks¶
- Exactly two model calls exist. Extraction in the core and chat orchestration in the agent service (FAQ).
- One fetch attempt per source. No retry loop, and no fallback from an ATS API to its page.
- Configuration comes only from
.env. A missing required value is a startup error, never a default. - Instrumentation is decorator-only. Function bodies carry no tracing calls, and a failing telemetry backend never fails a run.
- The API contract ships with every release as generated OpenAPI.
Deployment¶
The demo stack is five containers on one port series, described in
Getting Started. Every image is built by one workflow
for linux/amd64 and published to the GitHub Container Registry. Details
are in the Runbook.
The reasoning behind each choice is recorded next to the code, in
openspec/adr/ for cross-cutting decisions and in each
openspec/changes/<name>/design.md for individual changes.
Next: ATS rules.