Bootstrap: README, ADR-0001 (LLM at decision points), data model, API contract, worker task cards
Design foundation for POC. Monorepo: FastAPI+Postgres backend, Vue 3 frontend, Python packages (llm-gateway, artifacts, connectors). Approval gate and token budgets are architectural constraints per measured prototype findings.
This commit is contained in:
commit
b77c8b0044
11 changed files with 310 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
venv/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pdf
|
||||||
|
!.gitkeep
|
||||||
|
.env
|
||||||
48
README.md
Normal file
48
README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Jobhunt Platform
|
||||||
|
|
||||||
|
An AI-assisted job-hunt platform where **you stay in control**. The system discovers jobs, scores them against your profile, drafts application material and prepares sends — but nothing external ever happens without your explicit approval.
|
||||||
|
|
||||||
|
Open source. Self-hosted. Single-user first, multi-user later.
|
||||||
|
|
||||||
|
## Core ideas
|
||||||
|
|
||||||
|
1. **Approval gate, architecturally enforced.** Every external action (send email, submit application) requires a server-side confirmed `Approval` referencing the exact artifact hash. No approval row, no send. This is a hard constraint, not a style guide.
|
||||||
|
2. **State machine owns the flow, LLM answers questions inside it.** Pipeline transitions live in a table. The LLM scores, extracts, critiques and suggests — it never picks the next step. Deterministic orchestration, probabilistic judgment.
|
||||||
|
3. **Per-task model routing with token budgets.** Cheap model for extraction/scoring, strong model for prose review, budgets enforced per task so the bill stays boring.
|
||||||
|
4. **The user drafts, the system reviews.** Measured reality: human-drafted prose outperforms full AI drafts. Default cover-letter flow is user-writes, AI-reviews with tracked suggestions.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web Vue 3 + Vite + Tailwind — tabs: CV editor, Research, Applications (kanban), Application detail
|
||||||
|
apps/api FastAPI + PostgreSQL — REST API, state machine, scheduler, approval enforcement
|
||||||
|
packages/
|
||||||
|
llm-gateway/ Model routing, per-task budgets, structured JSON IO, retry/fallback policy
|
||||||
|
connectors/ Job source adapters -> normalized JobPosting (LinkedIn read-focused, jobindex, paste-a-URL)
|
||||||
|
artifacts/ CV + cover-letter generation: Jinja templates -> PDF (fpdf2), versioning, hashing
|
||||||
|
docs/ ADRs, data model, API contract, worker task cards
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
discovered -> scored -> approved -> drafting -> sent -> interviewing -> offer -> closed
|
||||||
|
\-> rejected (by user) \-> expired
|
||||||
|
```
|
||||||
|
|
||||||
|
External comms are only possible from `approved`/`drafting` states, and only with a matching confirmed `Approval`.
|
||||||
|
|
||||||
|
## Quick start (POC)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # add LLM provider keys
|
||||||
|
docker compose up -d postgres
|
||||||
|
cd apps/api && uv venv .venv && . .venv/bin/activate && uv pip install -e .
|
||||||
|
pytest # backend tests
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
cd apps/web && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
POC scaffolding in progress. See `docs/` for the design.
|
||||||
0
apps/api/.gitkeep
Normal file
0
apps/api/.gitkeep
Normal file
0
apps/web/.gitkeep
Normal file
0
apps/web/.gitkeep
Normal file
25
docs/adr/0001-llm-at-decision-points.md
Normal file
25
docs/adr/0001-llm-at-decision-points.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# ADR-0001: LLM at decision points; state machine owns the flow
|
||||||
|
|
||||||
|
Status: accepted (2026-07-30)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We prototyped this workflow as an autonomous agent setup (Hermes + LLM subagents + cron). Three measured findings:
|
||||||
|
|
||||||
|
1. Free-roaming agents burned ~10x expected token budget in 2 days; silent credential inheritance billed cheap-model work to the paid model (~45M tokens).
|
||||||
|
2. Unapproved external sends caused real harm (personnummer emailed without consent).
|
||||||
|
3. Fully AI-drafted cover letters scored 5-7.5/10 in owner review vs 9/10 for human-drafted + AI-reviewed.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- A deterministic **state machine** (`ApplicationState` transition table) owns pipeline flow. The LLM never chooses the next step.
|
||||||
|
- The LLM is invoked only inside named steps with typed inputs/outputs (JSON schema validated), capped iterations (no agent loops in v1), and per-task token budgets.
|
||||||
|
- **Model routing per task class**: extraction/scoring -> cheap model (GLM-5.2 via ollama-cloud), prose review/critique -> strong model. Every call logs tokens and cost. Fallback chains must never route to a paid provider for cheap-task classes.
|
||||||
|
- **Approval enforcement**: server requires a confirmed `Approval` row whose `artifact_hash` matches the exact bytes of what will be sent. Approvals expire after 24h or on any artifact mutation.
|
||||||
|
- **Draft policy**: cover letters default to user-drafted, AI-reviewed. Full-AI drafting exists as opt-in per application.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Positive: predictable cost, auditable behavior, no unapproved sends possible, quality aligned with measured reality.
|
||||||
|
- Negative: fewer "magical" autonomous behaviors; some flows need explicit user input.
|
||||||
|
- Mitigation: an opt-in per-step "agent mode" may be added later, sandboxed inside a single step with iteration+token caps.
|
||||||
43
docs/api-contract.md
Normal file
43
docs/api-contract.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# API contract (POC)
|
||||||
|
|
||||||
|
Base: `/api`. JSON everywhere. Errors as `{error: {code, message}}` with proper HTTP status.
|
||||||
|
|
||||||
|
## Profile & CV
|
||||||
|
|
||||||
|
- `GET /profile` -> profile object (create default if none)
|
||||||
|
- `PUT /profile` -> update profile fields
|
||||||
|
- `GET /profile/sections` -> list of cv_section ordered by kind, sort_order
|
||||||
|
- `POST /profile/sections` -> create section
|
||||||
|
- `PUT /profile/sections/{id}` -> update
|
||||||
|
- `DELETE /profile/sections/{id}`
|
||||||
|
- `POST /profile/sections/{id}/ai-assist` body `{instruction: str}` -> `{suggestions: [str]}` (LLM, cheap->strong routing per config)
|
||||||
|
- `POST /profile/render-cv` -> `{artifact_id, url}` rendered PDF from current profile + sections (packages/artifacts)
|
||||||
|
|
||||||
|
## Job postings
|
||||||
|
|
||||||
|
- `POST /postings` body `{url}` -> fetch via connectors (manual_url for POC), create job_posting + application(state=discovered)
|
||||||
|
- `GET /postings` -> list
|
||||||
|
- `POST /postings/{id}/score` -> run scoring rubric vs profile -> application.state=scored, returns `{score, rationale}`
|
||||||
|
|
||||||
|
## Applications (kanban)
|
||||||
|
|
||||||
|
- `GET /applications` -> list with posting info joined
|
||||||
|
- `POST /applications/{id}/transition` body `{to}` -> guarded per data-model.md table; 409 if illegal or guard fails
|
||||||
|
- `POST /applications/{id}/artifacts` multipart upload or `{kind, content}` -> creates artifact with content_hash
|
||||||
|
- `POST /applications/{id}/artifacts/cover-letter` body `{letter_text}` -> stores user draft, returns artifact + AI critique `{comments: [{quote, suggestion, severity}]}` (LLM review, does not rewrite)
|
||||||
|
- `GET /applications/{id}/artifacts` -> list
|
||||||
|
|
||||||
|
## Approval & outbox (the gate)
|
||||||
|
|
||||||
|
- `POST /applications/{id}/approvals` body `{action, artifact_id}` -> creates pending approval (expires_at = now+24h)
|
||||||
|
- `POST /approvals/{id}/confirm` -> user confirms; verified artifact.content_hash == approval.artifact_hash or 409
|
||||||
|
- `POST /approvals/{id}/reject`
|
||||||
|
- `POST /outbox/send` body `{approval_id, payload}` -> **fails 409 unless** approval confirmed, unexpired, hash match. On success: outbox.status=sent (POC: log/echo transport pluggable; real SMTP later)
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
|
||||||
|
- `GET /telemetry/tasks` -> task_run list (tokens, model, cost) — shows users their burn
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
- `GET /health` -> `{status: "ok"}`
|
||||||
129
docs/data-model.md
Normal file
129
docs/data-model.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# Data model (POC)
|
||||||
|
|
||||||
|
PostgreSQL. Plain SQL DDL in `apps/api/schema.sql`, managed with a simple numeric migration runner (`apps/api/migrations/`). No ORM (owner preference: no SQLAlchemy/Alembic). Access via `psycopg` (v3) repositories in `apps/api/db/`.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
```sql
|
||||||
|
profile (
|
||||||
|
id uuid pk,
|
||||||
|
full_name text not null,
|
||||||
|
email text not null,
|
||||||
|
phone text,
|
||||||
|
location text,
|
||||||
|
headline text,
|
||||||
|
summary text,
|
||||||
|
languages jsonb not null default '[]', -- [{code, level}]
|
||||||
|
hard_rules jsonb not null default '{}', -- e.g. {"no_remote_only": false, "locations": ["Malmo","Copenhagen"]}
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
)
|
||||||
|
|
||||||
|
cv_section (
|
||||||
|
id uuid pk,
|
||||||
|
profile_id uuid not null references profile(id) on delete cascade,
|
||||||
|
kind text not null check (kind in ('experience','education','skills','projects','other')),
|
||||||
|
title text not null,
|
||||||
|
org text, -- company/school
|
||||||
|
location text,
|
||||||
|
start_date date,
|
||||||
|
end_date date, -- null = current
|
||||||
|
bullets jsonb not null default '[]',
|
||||||
|
tags text[] not null default '{}', -- stack/keywords for tailoring
|
||||||
|
sort_order int not null default 0,
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
updated_at timestamptz default now()
|
||||||
|
)
|
||||||
|
|
||||||
|
job_posting (
|
||||||
|
id uuid pk,
|
||||||
|
source text not null, -- 'linkedin' | 'jobindex' | 'manual_url' | ...
|
||||||
|
external_id text, -- source-native id when known
|
||||||
|
url text not null,
|
||||||
|
company text not null,
|
||||||
|
title text not null,
|
||||||
|
location text,
|
||||||
|
description text not null default '',
|
||||||
|
raw jsonb not null default '{}',
|
||||||
|
fetched_at timestamptz default now(),
|
||||||
|
unique (source, url)
|
||||||
|
)
|
||||||
|
|
||||||
|
application (
|
||||||
|
id uuid pk,
|
||||||
|
job_posting_id uuid not null references job_posting(id) on delete cascade,
|
||||||
|
state text not null default 'discovered' check (state in
|
||||||
|
('discovered','scored','approved','rejected','drafting','sent','interviewing','offer','closed','expired')),
|
||||||
|
score numeric, -- 0..100 from scoring rubric
|
||||||
|
score_rationale jsonb, -- rubric breakdown from LLM
|
||||||
|
notes text,
|
||||||
|
state_changed_at timestamptz default now(),
|
||||||
|
created_at timestamptz default now()
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact (
|
||||||
|
id uuid pk,
|
||||||
|
application_id uuid not null references application(id) on delete cascade,
|
||||||
|
kind text not null check (kind in ('cv','cover_letter','email','other')),
|
||||||
|
filename text not null,
|
||||||
|
content_hash text not null, -- sha256 of stored bytes
|
||||||
|
storage_path text not null,
|
||||||
|
version int not null default 1,
|
||||||
|
origin text not null default 'ai_reviewed' check (origin in ('user_drafted','ai_drafted','ai_reviewed')),
|
||||||
|
created_at timestamptz default now()
|
||||||
|
)
|
||||||
|
|
||||||
|
approval (
|
||||||
|
id uuid pk,
|
||||||
|
application_id uuid not null references application(id) on delete cascade,
|
||||||
|
artifact_id uuid not null references artifact(id),
|
||||||
|
artifact_hash text not null, -- must equal artifact.content_hash at send time
|
||||||
|
action text not null check (action in ('send_email','submit_application')),
|
||||||
|
confirmed_by_user boolean not null default false,
|
||||||
|
confirmed_at timestamptz,
|
||||||
|
expires_at timestamptz not null, -- now() + interval '24 hours'
|
||||||
|
created_at timestamptz default now()
|
||||||
|
)
|
||||||
|
|
||||||
|
outbox (
|
||||||
|
id uuid pk,
|
||||||
|
approval_id uuid not null references approval(id),
|
||||||
|
kind text not null default 'email',
|
||||||
|
payload jsonb not null, -- to, subject, body, attachments [artifact ids]
|
||||||
|
status text not null default 'pending' check (status in ('pending','sent','failed','cancelled')),
|
||||||
|
sent_at timestamptz,
|
||||||
|
error text,
|
||||||
|
created_at timestamptz default now()
|
||||||
|
)
|
||||||
|
|
||||||
|
task_run ( -- LLM/token telemetry
|
||||||
|
id uuid pk,
|
||||||
|
task text not null, -- 'score','extract','critique','research', ...
|
||||||
|
model text not null,
|
||||||
|
provider text not null,
|
||||||
|
input_tokens int not null,
|
||||||
|
output_tokens int not null,
|
||||||
|
cost_usd numeric, -- nullable until pricing configured
|
||||||
|
duration_ms int not null,
|
||||||
|
application_id uuid references application(id),
|
||||||
|
created_at timestamptz default now()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transition table (state machine, enforced in code)
|
||||||
|
|
||||||
|
| from | to | guard |
|
||||||
|
|---|---|---|
|
||||||
|
| discovered | scored | scoring task completed |
|
||||||
|
| discovered | rejected | user action |
|
||||||
|
| scored | approved | user action |
|
||||||
|
| scored | rejected | user action |
|
||||||
|
| approved | drafting | user action or artifact created |
|
||||||
|
| drafting | sent | confirmed approval present + hash match |
|
||||||
|
| sent | interviewing | user action |
|
||||||
|
| interviewing | offer | user action |
|
||||||
|
| interviewing | closed | user action |
|
||||||
|
| offer | closed | user action |
|
||||||
|
| scored, approved | expired | posting gone or deadline passed (scheduler) |
|
||||||
|
|
||||||
|
Any transition not listed = 409 Conflict. State changes are user actions or explicit scheduler rules, never LLM decisions.
|
||||||
55
docs/worker-tasks/poc-tasks.md
Normal file
55
docs/worker-tasks/poc-tasks.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Worker tasks for POC (dispatch cards)
|
||||||
|
|
||||||
|
Each card is self-contained. Rules for ALL workers:
|
||||||
|
|
||||||
|
- Work only inside your assigned paths. Do NOT touch other modules or top-level files.
|
||||||
|
- Python 3.13, `uv` for env management. Node via `npm`. No SQLAlchemy, no Alembic, no Terraform (owner rules).
|
||||||
|
- Run your own tests before reporting done. Report what actually ran: exact test command + pass/fail counts.
|
||||||
|
- Output style: plain code, no em dashes anywhere, English.
|
||||||
|
- Do not invent external services. LLM calls go through packages/llm-gateway and must have a `mock` mode defaulting to on when no API key env var is present.
|
||||||
|
|
||||||
|
## T1: apps/api backend core
|
||||||
|
|
||||||
|
Paths: `apps/api/**`, `apps/api/tests/**`
|
||||||
|
|
||||||
|
Build the FastAPI backend per `docs/api-contract.md` + `docs/data-model.md`.
|
||||||
|
|
||||||
|
- PostgreSQL via psycopg (v3), repository pattern in `apps/api/db/`. `schema.sql` + tiny migration runner that applies `migrations/NNN_*.sql` in order, tracked in a `schema_migrations` table.
|
||||||
|
- Connection from env `DATABASE_URL` (default `postgresql://jobhunt:jobhunt@localhost:5433/jobhunt`).
|
||||||
|
- Pydantic schemas for all contract payloads. State machine module `apps/api/statemachine.py` implementing the transition table exactly, guard violations -> 409.
|
||||||
|
- Approval enforcement as described: hash check + expiry at send time. Send transport: pluggable `Transport` interface, default `EchoTransport` that records payload and marks sent (no real email in POC).
|
||||||
|
- LLM usage via `packages.llm_gateway` (add repo root to pythonpath in pyproject). api-assist + critique endpoints must pass through gateway task runner with task names `cv_assist` and `cl_critique`, and must work with gateway mock mode.
|
||||||
|
- Tests with pytest: state machine transitions (happy + 409), approval gate (confirm/hash-match/expiry), at least one API-level happy path test using FastAPI TestClient with a test database (transaction rollback or truncate fixtures; must not require a live server).
|
||||||
|
|
||||||
|
Done = `cd apps/api && . .venv/bin/activate && pytest -q` green locally (they may start postgres via `docker compose up -d postgres` first).
|
||||||
|
|
||||||
|
## T2: packages/artifacts + packages/llm-gateway
|
||||||
|
|
||||||
|
Paths: `packages/artifacts/**`, `packages/llm-gateway/**`, plus one shared file `docker-compose.yml` (add postgres service: postgres:16, user/pass/db jobhunt, port 5433->5432, volume) and root `.env.example`.
|
||||||
|
|
||||||
|
### artifacts
|
||||||
|
- Port the CV-render approach from `/opt/data/home/cv_work/build_cv.py` (read it; it uses fpdf2). Make `render_cv_pdf(profile: dict, sections: list[dict]) -> bytes`. Use Jinja2 for layout data prep, fpdf2 for PDF. Swedish characters must render correctly (unicode font: bundle DejaVu or use a TTF from system; if none available, ensure the test asserts text extraction equals input exactly).
|
||||||
|
- `render_cover_letter(text: str, profile: dict) -> bytes` simple.
|
||||||
|
- `hash_bytes(b: bytes) -> str` (sha256 hex).
|
||||||
|
- Versioning helpers: `next_version(existing: list[int]) -> int`.
|
||||||
|
- Tests: render a sample profile with `å ä ö Å Ä Ö` in name/summary, assert PDF bytes start with `%PDF`, assert content hash stable for same input.
|
||||||
|
|
||||||
|
### llm-gateway
|
||||||
|
- Async-first client with provider config from env: `LLM_PRIMARY_BASE_URL`, `LLM_PRIMARY_KEY` (or `OLLAMA_API_KEY`), `LLM_PRIMARY_MODEL` (default glm-5.2), optional `LLM_FALLBACK_*` (opencode). NO paid-provider fallback may be configured for cheap task classes; add an assertion test that config rejects or warns on that.
|
||||||
|
- `run_task(task: str, prompt: str, schema: dict | None = None) -> dict` returns parsed JSON when schema given (validate with pydantic/jsonschema), writes telemetry row via injected sink (for POC: callable), records tokens+model+duration. Retry policy: max 2 retries on 429/5xx, then fallback provider, then raise.
|
||||||
|
- Mock mode: when no key set, return deterministic canned outputs per task (defined in `mock.py`) so API tests run offline.
|
||||||
|
- Tests for: mock determinism, schema validation failure path, budget guard (per-task max tokens configurable, over-budget -> exception before call).
|
||||||
|
|
||||||
|
## T3: apps/web frontend shell
|
||||||
|
|
||||||
|
Paths: `apps/web/**` (+ root README quick-start adjustment if port assumptions change).
|
||||||
|
|
||||||
|
Vue 3 + Vite + TypeScript + Pinia + vue-router + Tailwind. Tabs per README:
|
||||||
|
1. CV: profile form (name/email/phone/location/headline/summary) + section list editor (add/edit/delete sections with kind select, title/org/dates, bullet list editor with per-bullet AI-assist button calling `POST /profile/sections/{id}/ai-assist`, suggestions shown with accept/dismiss). "Render CV" button -> posts to /profile/render-cv, shows returned URL iframe/download link.
|
||||||
|
2. Research: simple table of postings (GET /postings) with company/title/location/source/fetched_at; "Add by URL" input posting to /postings; "Score" button per row -> shows score+rationale popover.
|
||||||
|
3. Applications: kanban board grouped by state (columns per data-model states), cards show company/title/score; drag between columns -> `POST /applications/{id}/transition`, on 409 revert and show toast with reason.
|
||||||
|
4. Application detail: route `/applications/:id` — posting info, state, artifacts list, cover-letter editor (textarea save -> critique list rendered as cards with severity), approval widget: select artifact + action -> request approval -> "I confirm" button -> send button (enabled only after confirm; show 409 errors).
|
||||||
|
- API base via `import.meta.env.VITE_API_BASE` default `http://localhost:8000/api`.
|
||||||
|
- Tailwind configured locally (no CDN). Vitest smoke tests: router renders tabs; kanban groups by state from fixture; approval widget disables send until confirmed (component test with mocked api module).
|
||||||
|
|
||||||
|
Done = `npm run build` succeeds and `npm run test` green.
|
||||||
0
packages/artifacts/.gitkeep
Normal file
0
packages/artifacts/.gitkeep
Normal file
0
packages/connectors/.gitkeep
Normal file
0
packages/connectors/.gitkeep
Normal file
0
packages/llm-gateway/.gitkeep
Normal file
0
packages/llm-gateway/.gitkeep
Normal file
Loading…
Reference in a new issue