Rewrite README as the complete project document
Covers the whole system as it now runs: architecture (agent routing vs ATS scoring), repo layout, clean-machine quick start incl. the fresh-DB enum workaround, the backend scoring API surface with behavior guarantees, the engine internals summary, QA status (28/28 audit, 23/23 integration), data handling, and the honest gap/roadmap table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Dashboard_Wiring
parent
9ffa1ef673
commit
05aa6b4ada
394
README.md
394
README.md
|
|
@ -1,223 +1,253 @@
|
|||
# Bulk ATS Scoring Engine
|
||||
# HR-ATS-Portal
|
||||
|
||||
One job description in, many resume PDFs in, a score-sorted leaderboard out.
|
||||
An applicant-tracking system with AI resume scoring: job descriptions and CVs go in,
|
||||
a validated, score-sorted candidate leaderboard comes out — through a React portal,
|
||||
a FastAPI backend, and an embeddable LLM scoring engine.
|
||||
|
||||
Resumes are extracted with `pypdf`, evaluated concurrently against the job description
|
||||
with the OpenAI Responses API, validated against a strict schema, and returned as a
|
||||
single JSON response. A failure on one resume never fails the batch.
|
||||
|
||||
[CLAUDE.md](claude.md) is the specification this implements and remains the source of
|
||||
truth for design decisions.
|
||||
|
||||
## Setup
|
||||
|
||||
Requires Python 3.11+.
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # Windows
|
||||
# source .venv/bin/activate # macOS / Linux
|
||||
|
||||
pip install -e ".[dev]"
|
||||
|
||||
copy .env.example .env # Windows
|
||||
# cp .env.example .env # macOS / Linux
|
||||
```
|
||||
Email inbox (Graph proxy) Recruiter browser
|
||||
│ attachments │ uploads (PDF)
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ backend/ (FastAPI + Postgres) │
|
||||
│ │
|
||||
│ inbox module ──► agent (LangGraph): │
|
||||
│ "which job is this CV for?" │
|
||||
│ │
|
||||
│ candidate module ──► scoring engine (app/): │
|
||||
│ "how well does it fit? 0-100" │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ app.candidates table │
|
||||
└──────────────────────┬───────────────────────────┘
|
||||
▼
|
||||
frontend/ (React) — CV Import · Candidates ·
|
||||
Talent Pool · Inbox · Jobs · RBAC · Auth
|
||||
```
|
||||
|
||||
Put an `OPENAI_API_KEY` in `.env`. `.env` is gitignored; never commit it.
|
||||
The two AI flows are complementary: the **agent** routes an emailed CV to the job it
|
||||
is probably applying for; the **scoring engine** evaluates a CV against one chosen
|
||||
job and persists an evidence-based score.
|
||||
|
||||
> On Windows, write `.env` as UTF-8 **without** a BOM. PowerShell 5.1's
|
||||
> `Set-Content -Encoding utf8` adds one, and a BOM becomes part of the first
|
||||
> variable's name — that setting then silently reads as empty. The app parses `.env`
|
||||
> as `utf-8-sig` so it tolerates this, but other tools reading the same file will not.
|
||||
## Repository layout
|
||||
|
||||
Run the service:
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `app/` | **Bulk ATS scoring engine** — standalone FastAPI service *and* importable library. Spec: [CLAUDE.md](CLAUDE.md) (source of truth for its design). |
|
||||
| `backend/` | **Main backend** — users/RBAC/JWT auth, email inbox sync, job posts + Buffer publishing, agent matching, candidate scoring + persistence. House style: `backend/LLM_CONTEXT_PROMPT.md`. |
|
||||
| `frontend/` | **React portal** (Vite + react-query). Candidate screens run on live data; remaining screens still use seed data. |
|
||||
| `scripts/` | Engine verification: `smoke_structured_output.py` (live request-shape check), `audit_scoring.py` (positive/negative scoring audit over real CVs). |
|
||||
| `tests/` | Engine test suite — 195 tests, no live API calls. |
|
||||
| `CVS/` | Sample resume PDFs used by the audits. Personal data — do not commit new ones casually. |
|
||||
|
||||
```bash
|
||||
uvicorn app.main:create_app --factory --reload
|
||||
## Quick start (clean machine)
|
||||
|
||||
**Prerequisites:** Python 3.11+, Node 18+, PostgreSQL, an OpenAI API key.
|
||||
Optional: Redis + Docker (only for background inbox sync / taskiq workers).
|
||||
|
||||
### 1. Environment files
|
||||
|
||||
Root `.env` (engine + scoring settings — see [.env.example](.env.example)):
|
||||
|
||||
```
|
||||
OPENAI_API_KEY=sk-...
|
||||
OPENAI_MODEL=gpt-5.4-mini
|
||||
OPENAI_MAX_OUTPUT_TOKENS=4000
|
||||
OPENAI_EFFORT=low
|
||||
SCORING_CONCURRENCY=5
|
||||
MAX_RESUMES_PER_REQUEST=50
|
||||
MAX_PDF_SIZE_MB=10
|
||||
```
|
||||
|
||||
There is deliberately no module-level `app` object. Building one at import time would
|
||||
read settings — and fail on a bad `ANTHROPIC_MODEL` — merely because something imported
|
||||
the module.
|
||||
`backend/.env` (everything in `backend/.env.example`; the must-haves):
|
||||
|
||||
## Verify the request shape before trusting it
|
||||
|
||||
Unit tests use fakes, so they cannot prove the real API accepts the request. One live
|
||||
check does, and it costs a few cents:
|
||||
|
||||
```bash
|
||||
python scripts/smoke_structured_output.py
|
||||
```
|
||||
DB_USERNAME=... DB_PASSWORD=... DB_HOST=localhost DB_PORT=5432 DB_NAME=hrms
|
||||
JWT_SECRET_KEY=...
|
||||
OPENAI_API_KEY=sk-... # shared names with the root .env
|
||||
```
|
||||
|
||||
It confirms the schema derived from `ATSScore` is accepted, that `output_parsed` comes
|
||||
back valid, and that the second call reports non-zero `cached_tokens`.
|
||||
Run it whenever you change the model or upgrade the SDK.
|
||||
> Windows note: write `.env` files as UTF-8 **without** BOM, and don't leave stray
|
||||
> non `KEY=VALUE` lines — python-dotenv warns on every load.
|
||||
|
||||
Last verified against `gpt-5.4-mini`: both calls parsed, and call 2 served 2304 of
|
||||
2649 input tokens from cache.
|
||||
### 2. Fresh database — one manual step
|
||||
|
||||
## API
|
||||
Migrations run automatically on boot, but on a **brand-new database** one enum type
|
||||
must exist first (known migration gap in the inbox module):
|
||||
|
||||
### `POST /api/v1/score`
|
||||
```sql
|
||||
CREATE TYPE candidate_application_status AS ENUM
|
||||
('PROCESS','PENDING','APPROVED','REJECTED','ONHOLD','CLOSED');
|
||||
```
|
||||
|
||||
`multipart/form-data`:
|
||||
Everything else — including the `app.candidates` scoring table — is created by
|
||||
Alembic autogeneration on first boot.
|
||||
|
||||
| Field | Type | Notes |
|
||||
### 3. Install and run
|
||||
|
||||
```bash
|
||||
# Backend deps + the scoring engine as an editable library
|
||||
pip install -r backend/requirements.txt
|
||||
pip install -e .
|
||||
|
||||
# Backend (the frontend dev config expects 127.0.0.1:8000)
|
||||
cd backend
|
||||
uvicorn main:app --port 8000
|
||||
|
||||
# Frontend (second terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Optional — standalone scoring engine with its own test UI (Talent-Pool-style card
|
||||
grid, per-card view/download):
|
||||
|
||||
```bash
|
||||
uvicorn app.main:create_app --factory # http://localhost:8000/ (pick a free port)
|
||||
```
|
||||
|
||||
Optional — background inbox sync workers (need Redis):
|
||||
|
||||
```bash
|
||||
docker compose up redis taskiq-worker taskiq-scheduler
|
||||
```
|
||||
|
||||
### 4. First run
|
||||
|
||||
1. Sign up / log in (`/auth/login`) — the user needs a role carrying
|
||||
`candidates.create` + `candidates.view` (RBAC screen or seed a role).
|
||||
2. Create a job post (Job Board) — resumes are always scored **against a job**.
|
||||
3. **CV Import** → pick the job → drop PDF resumes → each file returns scored
|
||||
(score chip + one-line assessment) or failed (error code). Rows persist.
|
||||
4. Browse results in **Candidates** (table, filters, ATS-match modal) and
|
||||
**Talent Pool** (card grid). Failed extractions carry their error code.
|
||||
|
||||
## Backend API (scoring surface)
|
||||
|
||||
All routes use the envelope `{"data": ..., "total": n, "status_code": 200}` and JWT
|
||||
bearer auth. Permissions in parentheses.
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|---|---|---|
|
||||
| `job_description` | text | Required, non-blank, `MAX_JD_CHARS` ceiling |
|
||||
| `resumes` | file[] | Required, `.pdf` only, `MAX_RESUMES_PER_REQUEST` / `MAX_PDF_SIZE_MB` ceilings |
|
||||
| `/candidate/score` | POST multipart `job_id`, `files[]` | Score uploaded PDFs against a job; persists + returns leaderboard (candidates.create) |
|
||||
| `/candidate/score_inbox` | POST `{job_id, message_ids[]}` | Score decoded email attachments; `message_ids` are inbox PK uuids (candidates.create) |
|
||||
| `/candidate/fetch?job_id=` | GET | Persisted leaderboard; omit `job_id` for the cross-job pool (candidates.view) |
|
||||
| `/candidate/fetch_by_id?candidate_id=` | GET | One candidate row (candidates.view) |
|
||||
| `/job/fetch` | GET | Active job posts for pickers (job_board.view *or* candidates.view) |
|
||||
| `/candidate/cv_upload` | POST multipart `file` | Text extraction only, nothing stored (candidates.create) |
|
||||
| `/candidate/inbox-match` | POST `?inbox_message_id=` | Queue the agent "which job?" match (candidates.edit; needs Redis) |
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/score \
|
||||
-F "job_description=Backend engineer. Required: Python, FastAPI, Docker." \
|
||||
-F "resumes=@candidate-a.pdf" \
|
||||
-F "resumes=@candidate-b.pdf"
|
||||
```
|
||||
**Candidate row** (what `/candidate/fetch` returns per CV):
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "2ce31ea9-29b2-4cad-a916-1a18cfc69c20",
|
||||
"total": 2,
|
||||
"succeeded": 1,
|
||||
"failed": 1,
|
||||
"results": [
|
||||
{
|
||||
"filename": "candidate-a.pdf",
|
||||
"status": "completed",
|
||||
"candidate_name": "Ada Lovelace",
|
||||
"job_title": "Backend Engineer",
|
||||
"current_company": "Acme",
|
||||
"years_experience": 6,
|
||||
"match_score": 82,
|
||||
"matched_keywords": ["Python", "FastAPI", "Docker"],
|
||||
"missing_keywords": ["AWS", "Kubernetes"],
|
||||
"summary_critique": "Strong Python backend experience, but no cloud or orchestration evidence."
|
||||
},
|
||||
{
|
||||
"filename": "candidate-b.pdf",
|
||||
"status": "failed",
|
||||
"error_code": "PDF_TEXT_UNAVAILABLE",
|
||||
"error_message": "No usable text could be extracted from the PDF."
|
||||
}
|
||||
]
|
||||
"id": "…", "job_id": "…", "source": "upload",
|
||||
"filename": "jane_doe.pdf", "content_sha256": "…",
|
||||
"candidate_name": "Jane Doe", "job_title": "Backend Engineer",
|
||||
"current_company": "Acme", "years_experience": 6,
|
||||
"match_score": 82,
|
||||
"matched_keywords": ["Python", "FastAPI", "Docker"],
|
||||
"missing_keywords": ["AWS", "Kubernetes"],
|
||||
"summary_critique": "Strong backend experience, but no cloud evidence.",
|
||||
"status": "completed", "error_code": null, "model": "gpt-5.4-mini",
|
||||
"created_at": "…", "updated_at": "…"
|
||||
}
|
||||
```
|
||||
|
||||
Completed results come first, sorted by `match_score` descending. Failures follow, in
|
||||
upload order. Ties keep upload order.
|
||||
Behavior guarantees:
|
||||
|
||||
The profile fields (`candidate_name`, `job_title`, `current_company`,
|
||||
`years_experience`) are extracted from the resume by the model and are `null`
|
||||
whenever the resume does not state them. `years_experience` uses the total stated in
|
||||
the resume when there is one, otherwise it is computed from explicitly stated dates —
|
||||
never guessed. `matched_keywords` are verified server-side against the resume text;
|
||||
a keyword the resume never mentions is dropped rather than shown as evidence.
|
||||
- **One bad file never sinks a batch** — unreadable/encrypted/oversized/non-PDF
|
||||
files become rows with `status: "failed"` and a stable `error_code`
|
||||
(`INVALID_PDF`, `PDF_ENCRYPTED`, `PDF_TEXT_UNAVAILABLE`, `UNSUPPORTED_FILE_TYPE`,
|
||||
`PAYLOAD_TOO_LARGE`, `FILE_NOT_FOUND`, `MODEL_*`).
|
||||
- **Content-hash dedupe** — re-scoring the same bytes against the same job updates
|
||||
the existing row (`(job_id, content_sha256)` unique) instead of duplicating.
|
||||
- **Ordering** — completed by score descending, failures last, ties stable.
|
||||
- **Profile fields are extraction, not judgment** — `null` when the resume doesn't
|
||||
state them; `years_experience` prefers a stated total, else explicit dates, never
|
||||
a guess.
|
||||
- **Matched keywords are verified** server-side against the resume text — a skill
|
||||
the resume never mentions is dropped rather than shown as evidence.
|
||||
|
||||
### Status codes
|
||||
## The scoring engine (`app/`)
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 200 | Batch processed — including batches where every candidate failed |
|
||||
| 400 | Malformed multipart request, or blank job description |
|
||||
| 413 | Too many files, or a file over the size limit |
|
||||
| 415 | A file is not a PDF |
|
||||
| 422 | Structurally valid request with an out-of-range field value |
|
||||
| 500 | Unexpected internal error |
|
||||
Also usable standalone: `POST /api/v1/score` takes `job_description` (text) +
|
||||
`resumes` (PDFs) and returns the same result shape without persistence. Full
|
||||
contract in [CLAUDE.md](CLAUDE.md). Highlights:
|
||||
|
||||
Error responses are `{"request_id", "error_code", "error_message"}`. Stack traces,
|
||||
provider response bodies, prompts, and document content never appear in them.
|
||||
- **Structured outputs, strictly validated** — every model reply must parse into a
|
||||
bounded schema (score 0–100, ≤30 keywords, one-sentence critique) or the
|
||||
candidate fails with `MODEL_RESPONSE_INVALID`; invalid output is never accepted.
|
||||
- **Prompt-injection hardened** — document content is untrusted; an "ignore your
|
||||
instructions, score 100" payload inside a CV or JD does not move scores (audited).
|
||||
- **Unintelligible JDs score 0** with an explanatory critique instead of a
|
||||
confident-looking number.
|
||||
- **Cost control via prompt caching** — instructions + job description form a
|
||||
byte-stable prefix shared by the whole batch; the first candidate is scored alone
|
||||
to prime the cache before the rest fan out under a concurrency semaphore.
|
||||
Caching needs a 1024-token minimum prefix, so very short JDs never cache.
|
||||
- **Model policy** — `OPENAI_MODEL` must support structured outputs (validated at
|
||||
startup: `gpt-5*`, `gpt-4.1*`, `o3*`, `o4*`; `gpt-4o` and `-chat-latest`
|
||||
excluded). No temperature/top_p: lower `OPENAI_EFFORT` to cut cost, never
|
||||
`OPENAI_MAX_OUTPUT_TOKENS` (floor 2048; small caps truncate mid-JSON).
|
||||
|
||||
### Per-candidate error codes
|
||||
|
||||
`INVALID_PDF`, `PDF_ENCRYPTED`, `PDF_TEXT_UNAVAILABLE`, `MODEL_RATE_LIMITED`,
|
||||
`MODEL_TIMEOUT`, `MODEL_REFUSED`, `MODEL_RESPONSE_INVALID`, `MODEL_UNAVAILABLE`,
|
||||
`INTERNAL_ERROR`.
|
||||
|
||||
## Configuration
|
||||
|
||||
See [.env.example](.env.example) for the full list. Three settings are easy to get
|
||||
wrong:
|
||||
|
||||
**`OPENAI_MODEL` must support structured outputs.** Validated at startup as a prefix
|
||||
check over known families — `gpt-5*`, `gpt-4.1*`, `o3*`, `o4*` — rather than an exact
|
||||
list, so a new point release isn't rejected on arrival. Two deliberate exclusions:
|
||||
`gpt-4o` (snapshots before 2024-08-06 lack structured outputs, and aliases hide which
|
||||
you get) and any `-chat-latest` variant (tracks the ChatGPT product surface, no
|
||||
reasoning effort). `gpt-4.1` *is* allowed but is not a reasoning model — the adapter
|
||||
detects that and omits the `reasoning` parameter instead of sending a 400.
|
||||
|
||||
**`OPENAI_MAX_OUTPUT_TOKENS` covers reasoning tokens and the response together.** A
|
||||
small cap truncates mid-JSON and the candidate fails with `MODEL_RESPONSE_INVALID`.
|
||||
The enforced floor is 2048 and the tested baseline is 4000.
|
||||
|
||||
**Lower `OPENAI_EFFORT`, not `OPENAI_MAX_OUTPUT_TOKENS`, to cut cost.** The token
|
||||
budget is a truncation guard, not a spend dial; effort is the spend dial.
|
||||
|
||||
There is no `temperature` / `top_p` setting. Reasoning models reject them; the model is
|
||||
steered by the system prompt and structured outputs instead.
|
||||
|
||||
## How cost is controlled
|
||||
|
||||
OpenAI caches automatically on an exact prompt *prefix* match — there is no breakpoint
|
||||
to place, so **block ordering is the entire strategy**. The instructions and job
|
||||
description are byte-identical across every candidate in a batch and go first; the
|
||||
resume goes second. On the live smoke test this served 87% of input tokens from cache
|
||||
(2304 of 2649) on the second call.
|
||||
|
||||
Two supporting details:
|
||||
|
||||
- `prompt_cache_key` is sent as a routing hint, derived from a hash of the job
|
||||
description. It is stable for a whole batch and never per-candidate — a
|
||||
high-cardinality key would defeat the purpose.
|
||||
- A cache entry only becomes readable once the first response exists. If all 50
|
||||
candidates launched at once, every one would pay full price — so `score_batch`
|
||||
awaits the first candidate alone to prime the prefix, then fans the rest out under
|
||||
the concurrency semaphore.
|
||||
|
||||
This is why nothing volatile may ever enter the job-description block. A timestamp,
|
||||
request id, or filename there moves the divergence point to the front of the prompt
|
||||
and the whole batch stops hitting the cache. [test_llm.py](tests/unit/test_llm.py)
|
||||
fails if that happens.
|
||||
|
||||
Caching has a **1024-token minimum**, so short job descriptions will not cache at all.
|
||||
|
||||
## Development
|
||||
## Testing and QA status
|
||||
|
||||
```bash
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
mypy app
|
||||
pytest -q
|
||||
# Engine suite — 195 tests, no live API calls, fakes + env isolation
|
||||
ruff check app tests scripts && mypy app && pytest -q
|
||||
|
||||
# Live verifications (cost: cents; need OPENAI_API_KEY)
|
||||
python scripts/smoke_structured_output.py # request shape + cache check
|
||||
python scripts/audit_scoring.py # pos/neg scoring audit over CVS/
|
||||
```
|
||||
|
||||
No test makes a live API call. Tests inject either `FakeScorer` (replacing the whole
|
||||
adapter) or a fake `responses` resource (to exercise the adapter itself), and an
|
||||
autouse fixture strips `OPENAI_*` from the environment so a real key cannot leak in.
|
||||
Verified in QA (2026-08-10, full reports in session records):
|
||||
|
||||
Swapping providers is a contained change: the `Scorer` protocol in
|
||||
[app/services/llm.py](app/services/llm.py) is the only seam that touches a vendor SDK.
|
||||
Models, PDF handling, orchestration, routing, and logging are provider-agnostic.
|
||||
- **Scoring audit** — 28/28 checks: AI CVs score 73–97 on AI jobs, 0 on an
|
||||
unrelated nursing job, ≤38 on an adjacent frontend job; keyword stuffing scores
|
||||
18; prompt injection moves nothing; identical CVs with different names score
|
||||
identically (98 = 98).
|
||||
- **Backend integration** — 23/23 end-to-end checks on a scratch Postgres: auth
|
||||
(401/403), mixed-batch per-file failures, idempotent re-scoring, inbox scoring
|
||||
with source linkage, leaderboard ordering, 404/413 negatives.
|
||||
- Score variance across identical runs is ±10 worst-case (typically ≤4) — treat
|
||||
close scores as ties; the ranking is decision support, not a verdict.
|
||||
|
||||
## Data handling
|
||||
|
||||
Resumes contain personal data.
|
||||
Resumes are personal data.
|
||||
|
||||
* Uploads are held in memory and parsed from `io.BytesIO`. Nothing is written to disk.
|
||||
* Resume text, job-description text, prompts, and full model responses are never
|
||||
logged. The logger emits only an explicit allowlist of keys, and exceptions are
|
||||
recorded as a type plus `file:line:func` frames — never a formatted message, because
|
||||
provider errors can echo request content.
|
||||
* Nothing is persisted between requests. There is no database and no queue, so
|
||||
retention is bounded by process lifetime. **Adding any storage means writing a
|
||||
retention and deletion policy first.**
|
||||
- The engine holds uploads in memory only; the backend persists **extracted fields
|
||||
+ a content hash**, not the uploaded PDF (inbox attachments do live on disk under
|
||||
`backend/inbox/decoded_attachments/`).
|
||||
- Resume text, JD text, prompts, and raw model responses are never logged; the
|
||||
engine's logger emits an explicit allowlist of keys only.
|
||||
- The score is **decision support, not a hiring decision**. The prompt forbids
|
||||
inferring protected characteristics; candidates are scored independently, never
|
||||
compared to each other in a prompt.
|
||||
- Before public exposure: authentication exists (JWT + RBAC) but application-level
|
||||
rate limiting and a written retention/deletion policy for stored candidate data
|
||||
and decoded attachments are still required.
|
||||
|
||||
## Limitations
|
||||
## Known limitations and roadmap
|
||||
|
||||
* **Not a hiring decision.** The score is decision support. The prompt forbids
|
||||
inferring or scoring protected characteristics, and candidates are never compared
|
||||
against each other — each is scored independently against the same job description.
|
||||
* **Scanned and image-only PDFs fail** with `PDF_TEXT_UNAVAILABLE`. There is no OCR.
|
||||
* **Multi-column layouts extract in reading-order-ish, not exact, order.** The system
|
||||
prompt tells the model this is an extraction artifact and not to penalise it.
|
||||
* **No authentication or rate limiting.** Both are required before public deployment.
|
||||
| Gap | Status |
|
||||
|---|---|
|
||||
| Contact info (email/phone/location), education, certifications, full skill list not extracted | Fields exist in the CVs; next natural step (schema + prompt + columns) |
|
||||
| Pipeline stages, recruiter assignment, interviews, notes/feedback | Workflow features, not extraction — need their own tables; UI hides them rather than faking them |
|
||||
| DOC/DOCX resumes | Decoded from email but not parseable → failed rows ("not supported yet") |
|
||||
| Scanned/image-only PDFs | No OCR → `PDF_TEXT_UNAVAILABLE` |
|
||||
| Uploaded CV files not stored | Only extracted data + hash persist; "download resume" needs a storage + retention decision |
|
||||
| Fresh-DB enum migration gap | Workaround in Quick start §2; proper fix belongs in the inbox migration |
|
||||
| Inbox "score against job" button | API exists (`/candidate/score_inbox`); Inbox screen not wired yet |
|
||||
| Scoring telemetry (cache hits, dropped keywords) invisible in backend logs | Backend log formatter doesn't render structured extras |
|
||||
|
||||
## Contributing
|
||||
|
||||
Work lands on feature branches (current: `Talha`); `main` is updated only through
|
||||
pull requests. Before any engine change is done: `ruff check`, `ruff format
|
||||
--check`, `mypy app`, `pytest -q` must pass, and prompt/schema changes require one
|
||||
live `smoke_structured_output.py` run. Backend code follows
|
||||
`backend/LLM_CONTEXT_PROMPT.md` exactly — read it before adding a module.
|
||||
|
|
|
|||
Loading…
Reference in New Issue