System design guide for contributors. If you want to add a board adapter, fix a bug, or understand how the pieces connect, start here.
This document is the high-level overview. Detail docs cover specific areas:
Doc Scope CONFIG.md Full schema, defaults, and validation rules for all TOML files SCORING_ENGINE.md Fusion formula, compensation parsing, disqualification, dedup RAG_PIPELINE.md Embedding, indexing, retrieval, and ChromaDB collections DATA_FLOW.md End-to-end data lifecycle and persistence points FEEDBACK_LOOP.md Decide → rescore → eval cycle and tuning workflow FAILURE_MODES.md Error catalog, recovery, bot detection, prompt injection EVOLUTION.md How the system grew phase by phase TEAM_SCALING.md What would change with multiple users
- Local-first — All processing (LLM inference, embeddings, vector storage) runs on your machine. Nothing leaves your network.
- Board-agnostic pipeline — Only the adapter layer knows about specific
job boards. Everything downstream (scoring, ranking, export) works against
the
JobListingdata contract. - Tests are the spec — There is no separate requirements document. The test suite (868+ tests across 35 test files) is the living specification. Each test class documents WHO needs it, WHAT it proves, and WHY. If there isn't 100% test coverage, then the implementation is underspecified.
- Actionable errors — Every error carries enough context for the operator (or an AI assistant) to resolve it without searching logs or source code.
CLI (12 subcommands)
│
├── index ──▶ Indexer ──▶ ChromaDB (6 collections)
│
├── search ──▶ Adapter Layer ──▶ Scorer ──▶ Ranker ──▶ Export
│ │ │
│ │ ├── resume → fit_score
│ │ ├── role_archetypes → archetype_score
│ │ ├── decisions → history_score
│ │ ├── negative_signals → negative_score
│ │ ├── global_positive_signals → culture_score
│ │ └── comp_parser → comp_score
│ │
│ ├── ZipRecruiter (Next.js DOM extraction)
│ ├── Indeed
│ ├── WeWorkRemotely
│ └── LinkedIn (overnight / CDP mode)
│
├── rescore ──▶ Rescorer (re-score JDs from disk, no browser)
├── decide ──▶ DecisionRecorder
├── review ──▶ ReviewSession (interactive batch)
├── eval ──▶ EvalRunner (pipeline vs. human verdicts)
└── decisions ──▶ show / remove / audit
- The CLI loads enabled boards from
config/settings.toml - The adapter registry resolves board names to adapter classes (IoC)
- Ollama health check — fail fast if models aren't pulled
- Auto-index if any ChromaDB collection is empty
- Each adapter uses Playwright to navigate search results and extract listings
- The scorer embeds each JD and queries six ChromaDB collections
- The LLM disqualifier screens for structural red flags
- Compensation is parsed via regex and scored against a configurable base salary
- The ranker fuses all six component scores, deduplicates across boards, and applies a minimum score threshold
- Results export as Markdown, CSV, individual JD files, and/or browser tabs
Every adapter produces JobListing instances — the single abstraction that
makes the rest of the pipeline board-agnostic:
@dataclass
class JobListing:
board: str # "ziprecruiter", "linkedin", etc.
external_id: str # Board's own job ID, for deduplication
title: str # Sanitized (filesystem-safe)
company: str # Sanitized
location: str
url: str
full_text: str # Full JD text (max ~250K chars)
posted_at: datetime | None = None
raw_html: str | None = None
comp_min: float | None = None # Parsed compensation range
comp_max: float | None = None
comp_source: str | None = None # "employer" or "estimated"
comp_text: str | None = None # Original matched snippet
metadata: dict[str, str] = field(default_factory=dict)__post_init__ validates full_text length and sanitizes title/company
to strip path-traversal sequences and filesystem-unsafe characters.
All adapters implement this interface:
| Method / Property | Purpose |
|---|---|
board_name |
Unique string identifier (matches settings.toml key) |
authenticate(page) |
Establish session; detect CAPTCHAs and expired cookies |
search(page, query, max_pages) |
Paginate search results → list[JobListing] |
extract_detail(page, listing) |
Navigate to listing URL → populate full_text |
rate_limit_seconds |
(min, max) tuple for random throttle jitter (default 1.5–3.5s) |
Adapters self-register via a decorator. The pipeline runner never imports concrete adapter classes:
@AdapterRegistry.register
class ZipRecruiterAdapter(JobBoardAdapter):
...The runner resolves adapters from config:
enabled = settings["boards"]["enabled"] # ["ziprecruiter", ...]
adapters = [AdapterRegistry.get(name) for name in enabled]Adding a new board adapter requires zero changes to the pipeline, ranker, or export code.
| Adapter | Strategy | Rate Limit |
|---|---|---|
| ZipRecruiter | Next.js DOM extraction (article[id^="job-card-"]) + JSON-LD URLs |
configurable (default 1.5–3.5s) |
| WeWorkRemotely | HTML scraping | configurable (default 1.5–3.5s) |
| CDP mode (system browser), overnight-only, bot-detection checks | configurable (default 8–20s) | |
| Indeed | Stub (not yet implemented) | configurable |
The ZipRecruiter adapter extracts job cards from article[id^="job-card-"]
DOM elements in the Next.js SERP, deduplicates responsive mobile/desktop
duplicates, and resolves canonical URLs from JSON-LD ItemList structured
data. Salary text (e.g., $185K - $240K/yr) is parsed directly from card
DOM. Full JD text comes from clicking each card to populate the detail panel.
Per-board rate limits and throttle parameters are configurable in settings.toml.
SessionManager handles Playwright browser context lifecycle:
- Two launch modes:
- Playwright-managed (default) —
chromium.launch()— simple, but Cloudflare detects automation flags - CDP mode — launches a real system browser (Edge/Chrome) as a subprocess
with
--remote-debugging-port, then connects viaconnect_over_cdp()— no automation flags, bypasses Cloudflare
- Playwright-managed (default) —
- Storage state persistence — Cookies are saved per board to
data/{board}_session.jsonto avoid re-authentication on every run - Stealth patches — Optional
playwright-stealthintegration - Throttling —
throttle()applies random jitter within the adapter'srate_limit_secondsrange between every page navigation
Board-specific detection logic (e.g., LinkedIn's authwall redirects and
challenge interstitials) lives in the adapter module, not in SessionManager.
The session manager stays board-agnostic.
All errors extend ActionableError with structured remediation targeting
three audiences:
| Audience | Field | Example |
|---|---|---|
| Calling code | error_type: ErrorType |
Route recovery logic |
| Human operator | suggestion, troubleshooting |
"Re-run with --login to refresh session" |
| AI agent | ai_guidance: AIGuidance |
action_required, command, checks |
ErrorType categories: AUTHENTICATION, CONFIG, CONNECTION, EMBEDDING,
INDEX, PARSE, DECISION, VALIDATION, UNEXPECTED
Factory classmethods enforce consistent construction:
raise ActionableError.authentication(
board="ziprecruiter",
raw_error=exc,
suggestion="Re-run with --login to refresh session",
)A catch-all from_exception() factory auto-classifies unknown exceptions by
keyword matching (e.g., "timeout" → CONNECTION, "not found" → UNEXPECTED).
Each job description is embedded and scored against six ChromaDB collections:
| Collection | Source | Score | Purpose |
|---|---|---|---|
resume |
data/resume.md, chunked by ## heading |
fit_score |
How well your background matches |
role_archetypes |
config/role_archetypes.toml descriptions + positive signals |
archetype_score |
Does this match what you're targeting? |
decisions |
Past yes/no/maybe verdicts (only yes contributes) |
history_score |
Resembles roles you've approved |
negative_signals |
Global rubric + per-archetype negative signals | negative_score |
Penalty for red-flag patterns |
global_positive_signals |
config/global_rubric.toml dimensions (one doc per dimension) |
culture_score |
Alignment with culture/work-model preferences |
| (inline) | Regex extraction from JD text | comp_score |
Compensation vs. configurable base salary |
The Indexer populates ChromaDB collections from source files:
| Method | Collection(s) | Strategy |
|---|---|---|
index_resume() |
resume |
Split on ## headings → one chunk per section |
index_archetypes() |
role_archetypes |
Synthesize description + signals_positive per archetype |
index_negative_signals() |
negative_signals |
Merge global rubric + per-archetype signals_negative |
index_global_positive_signals() |
global_positive_signals |
One document per rubric dimension |
All operations are idempotent — the collection is reset before re-indexing.
For each JD:
- Chunk long JDs into overlapping segments (2000-char overlap) if they exceed the embedding model's context window
- Embed each chunk via
nomic-embed-textthrough Ollama - Query each collection — keep the best (max) score across all chunks per component
- Distance → score:
max(0.0, min(1.0, 1.0 - cosine_distance)) - Parse compensation via regex (annual ranges, hourly→annual conversion, false-positive screening for employee counts and revenue figures)
- LLM disqualifier (multi-layer defense):
- Layer 1: Prompt-injection screening
- Layer 2: Regex sanitization of JD text
- Layer 3: Disqualifier prompt via
mistral:7b - Safe default: if JSON parsing fails, listing is not disqualified
positive = archetype_weight × archetype_score
+ fit_weight × fit_score
+ history_weight × history_score
+ comp_weight × comp_score
+ culture_weight × culture_score
final_score = max(0.0, positive − negative_weight × negative_score)
if disqualified: final_score = 0.0
All weights are configurable in config/settings.toml. Listings below
min_score_threshold are excluded from output.
The ranker collapses duplicates in two passes:
- Exact — same
(board, external_id) - Near — cosine similarity > 0.95 on
full_textembeddings
The highest-scored instance is kept; other boards are noted in
duplicate_boards.
Listings with an existing decision (yes/no/maybe) are excluded from scoring
on subsequent runs, unless --force-rescore is passed.
PipelineRunner owns all subsystems and coordinates the search flow:
- Start session logging (JSONL with
session_id) - Ollama health check (fail fast before launching browsers)
- Auto-index empty collections
- Search all enabled boards concurrently
- Score, rank, and export
- Emit structured
session_summarywith inference metrics
Returns RunResult with ranked listings, summary statistics, failure
counts, and any errors encountered.
Re-scores previously exported JD files through updated collections without launching a browser. Useful after re-indexing with updated resume, archetypes, or rubric.
ReviewSession presents undecided listings one at a time for batch
verdicts (y/n/m + optional reason). Opens JD files or URLs in the browser
on demand.
EvalRunner compares pipeline scoring against human decisions:
- Agreement rate — fraction where pipeline and human agree
- Precision — of listings the pipeline would surface, how many did you approve?
- Recall — of listings you approved, how many did the pipeline surface?
- Spearman rank correlation — ordinal alignment between verdicts and scores
- Model comparison —
--compare-models A Bruns dual evaluation with delta reporting
Writes a Markdown report to output/ and appends to data/eval_history.jsonl.
Entry point: jobsearch-rag (via __main__.py → cli.py)
| Subcommand | Purpose |
|---|---|
index |
Index resume and/or archetypes into ChromaDB |
search |
Full pipeline: search → score → rank → export |
rescore |
Re-score exported JDs through updated collections |
export |
Re-export last results in a specific format |
decide |
Record your verdict on a specific job (yes/no/maybe) |
review |
Interactive batch review of undecided listings |
decisions |
Manage decisions: show, remove, audit |
eval |
Evaluate scoring pipeline vs. human decisions |
boards |
List registered adapters |
login |
Open headed browser for interactive authentication |
reset |
Reset ChromaDB collections, optionally clear output |
Three TOML files under config/:
| File | Purpose |
|---|---|
settings.toml |
Board configs, scoring weights, Ollama connection, output settings, ChromaDB path |
role_archetypes.toml |
Target role descriptions with positive and negative signals |
global_rubric.toml |
Universal evaluation dimensions (10 dimensions, each with positive/negative signals) |
Key scoring settings:
[scoring]
archetype_weight = 0.5
fit_weight = 0.3
history_weight = 0.2
comp_weight = 0.15
negative_weight = 0.4
culture_weight = 0.2
base_salary = 220000
disqualify_on_llm_flag = true
min_score_threshold = 0.45See CONFIG.md for the full schema, all defaults, and validation rules.
Four output formats, all driven from ranked results:
| Exporter | Output | Notes |
|---|---|---|
| Markdown | output/results.md |
Summary table with score breakdowns |
| CSV | output/results.csv |
All score components; excludes disqualified listings |
| JD files | output/jds/{external_id}_company_title.md |
One file per listing with metadata header and full JD text |
| Browser tabs | (opens in default browser) | Top-N URLs from open_top_n setting |
Structured JSONL session logs under data/logs/:
- One file per run:
session_{id}_{timestamp}.jsonl - Events:
score_computed(per listing),embed_call,disqualifier_call,retrieval_summary(per collection),session_summary InferenceMetricstracks embed/LLM call counts, token totals, latency, and slow-call counts (configurable threshold)session_summaryincludeswall_clock_ms— end-to-end pipeline duration
jobsearch-rag/
├── config/
│ ├── settings.toml # Board config, scoring weights, Ollama settings
│ ├── role_archetypes.toml # Target role descriptions + signals
│ └── global_rubric.toml # Universal evaluation dimensions
├── data/
│ ├── resume.md # Your resume in plain Markdown
│ ├── chroma_db/ # Vector store persistence (git-ignored)
│ ├── decisions/ # Past verdicts as JSONL (git-ignored)
│ └── logs/ # Session JSONL logs (git-ignored)
├── src/jobsearch_rag/
│ ├── __main__.py # Entry point
│ ├── cli.py # Argument parser + handler dispatch
│ ├── config.py # Settings/BoardConfig/ScoringConfig loaders
│ ├── errors.py # ActionableError hierarchy + factories
│ ├── logging.py # File + structured session logging
│ ├── text.py # slugify, text normalization
│ ├── adapters/
│ │ ├── base.py # JobListing dataclass + JobBoardAdapter ABC
│ │ ├── registry.py # AdapterRegistry (decorator-based IoC)
│ │ ├── session.py # SessionManager (Playwright / CDP lifecycle)
│ │ ├── ziprecruiter.py # ZipRecruiter Next.js DOM extraction
│ │ ├── indeed.py # Indeed adapter (stub)
│ │ ├── weworkremotely.py # WeWorkRemotely HTML scraping
│ │ └── linkedin.py # LinkedIn overnight adapter (CDP + stealth)
│ ├── rag/
│ │ ├── embedder.py # Ollama embed + classify + health check
│ │ ├── store.py # ChromaDB wrapper (6 collections)
│ │ ├── indexer.py # Resume/archetype/rubric → ChromaDB
│ │ ├── scorer.py # Semantic scoring + LLM disqualification
│ │ ├── comp_parser.py # Regex compensation extraction + scoring
│ │ └── decisions.py # Verdict recording + audit + removal
│ ├── pipeline/
│ │ ├── runner.py # PipelineRunner orchestration
│ │ ├── ranker.py # Score fusion + dedup + threshold
│ │ ├── rescorer.py # Re-score JDs from disk
│ │ ├── review.py # Interactive batch review session
│ │ └── eval.py # Pipeline evaluation + model comparison
│ └── export/
│ ├── markdown.py # Markdown table export
│ ├── csv_export.py # CSV export
│ ├── jd_files.py # Individual JD file export
│ └── browser_tabs.py # Open top-N URLs in browser
├── tests/
│ ├── conftest.py # Shared fixtures
│ ├── constants.py # Test constants
│ ├── fixtures/ # HTML fixtures, sample JD JSON
└── test_*.py # 868+ BDD-style tests across 35 files
└── docs/
├── ARCHITECTURE.md # ← you are here
├── CONFIG.md # Configuration schema + validation
├── SCORING_ENGINE.md # Scoring model + fusion formula
├── RAG_PIPELINE.md # Embedding, indexing, retrieval
├── DATA_FLOW.md # End-to-end data lifecycle
├── FEEDBACK_LOOP.md # Decide → rescore → eval cycle
├── FAILURE_MODES.md # Error catalog + recovery
├── EVOLUTION.md # System growth over time
└── TEAM_SCALING.md # Multi-user considerations
Tests are organized by behavioral requirement, not by code structure. Each test class documents:
- WHO needs this behavior
- WHAT the behavior is (including failure modes)
- WHY it matters (what breaks if the contract is violated)
class TestAuthenticationFailures:
"""
REQUIREMENT: Authentication failures are detected early and reported clearly.
WHO: The operator running the tool; the pipeline runner
WHAT: Expired sessions are detected before search begins; CAPTCHA
encounters halt the run gracefully
WHY: An unauthenticated scrape returns login-page HTML silently,
producing zero valid listings with no error
"""Key principles:
- Mock I/O boundaries, not implementation — Tests use HTML fixtures, not mocked method calls. When the adapter internals were rewritten from CSS selectors to JSON extraction, zero existing tests changed.
- Failure specs are as important as happy-path specs — An unspecified failure is an unhandled failure.
- Missing spec = missing requirement — When a bug is found, step one is always "add the missing spec."
- Create
src/jobsearch_rag/adapters/yourboard.py - Subclass
JobBoardAdapterand implement all abstract methods - Decorate with
@AdapterRegistry.register - Add a
[boards.yourboard]section toconfig/settings.toml - Add the board name to
boards.enabled - Create test fixtures in
tests/fixtures/ - Write tests organized by behavioral requirement
The adapter must produce JobListing instances with all required fields
populated. The rest of the pipeline will pick it up automatically.
| Layer | Choice | Why |
|---|---|---|
| Language | Python 3.11–3.13 | First-class support for local AI stack; CI tested on 3 OS × 3 versions |
| Browser | Playwright | Async-native, clean cookie persistence, CDP support |
| LLM | Ollama + mistral:7b | Zero-cost, privacy-respecting, no data egress |
| Embeddings | nomic-embed-text via Ollama | Fast local embeddings, consistent with local-first philosophy |
| Vector store | ChromaDB (embedded) | Persistent, no server required, cosine similarity built-in |
| Config | TOML | Human-readable, easy to extend per board |
| Lint/format | ruff | Fast, replaces flake8 + isort + black |
| Type checking | pyright (strict) | Catches contract violations at dev time |
| Testing | pytest + pytest-asyncio | Async adapter tests, BDD-style organization |
| Error framework | actionable-errors | Structured remediation for humans and AI agents |
- CONFIG.md — Configuration schema, defaults, and validation
- SCORING_ENGINE.md — Scoring model, fusion formula, compensation, disqualification
- RAG_PIPELINE.md — Embedding, indexing, retrieval, vector store internals
- DATA_FLOW.md — End-to-end data lifecycle, persistence points, contracts
- FEEDBACK_LOOP.md — Decide → rescore → eval cycle, tuning workflow
- FAILURE_MODES.md — Error catalog, recovery, bot detection, prompt injection defense
- EVOLUTION.md — How the system grew from scraper to scoring pipeline
- TEAM_SCALING.md — What would change with multiple users