T1: FastAPI backend core (models, state machine, approval gate) + compose test runner

- apps/api: psycopg v3 repositories, schema.sql + migration runner, state machine
  implementing the data-model transition table, approval gate with sha256 hash
  match + 24h expiry enforced at send time, EchoTransport pluggable sender,
  LLM calls routed through packages.llm-gateway with mock mode, 47 pytest tests green
- apps/api/Dockerfile.test: python 3.13-slim test image (DinD-safe: migrations
  copied as directory)
- docker-compose.yml: add api-test service on compose network (host port
  publishing is broken in this sandbox; container-to-container networking used)
- Fix: replace masked placeholder password in config.py/conftest.py defaults
This commit is contained in:
hermes 2026-07-30 18:10:59 +00:00
parent 8eb8400bad
commit 17759ef564
21 changed files with 2608 additions and 0 deletions

15
apps/api/Dockerfile.test Normal file
View file

@ -0,0 +1,15 @@
# Test/POC runner image for apps/api
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY pyproject.toml ./
COPY app ./app
COPY schema.sql ./schema.sql
COPY migrations ./migrations
COPY tests ./tests
RUN pip install --no-cache-dir -e ".[dev]"
CMD ["pytest", "-q"]

1
apps/api/app/__init__.py Normal file
View file

@ -0,0 +1 @@
"""jobhunt API package."""

16
apps/api/app/config.py Normal file
View file

@ -0,0 +1,16 @@
"""Configuration — reads env vars, provides defaults."""
from __future__ import annotations
import os
from pathlib import Path
DATABASE_URL = os.environ.get(
"DATABASE_URL",
"postgresql://jobhunt:jobhunt@localhost:5433/jobhunt",
)
# Base directory of the apps/api package (for locating migrations/schema)
BASE_DIR = Path(__file__).resolve().parent.parent
MIGRATIONS_DIR = BASE_DIR / "migrations"
SCHEMA_FILE = BASE_DIR / "schema.sql"

View file

@ -0,0 +1,80 @@
"""Database connection pool and helpers (psycopg v3)."""
from __future__ import annotations
import json
from typing import Any
import psycopg
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from app.config import DATABASE_URL
_pool: ConnectionPool | None = None
def get_pool() -> ConnectionPool:
global _pool
if _pool is None:
_pool = ConnectionPool(
conninfo=DATABASE_URL,
kwargs={"row_factory": dict_row},
min_size=1,
max_size=8,
open=True,
)
return _pool
def close_pool() -> None:
global _pool
if _pool is not None:
_pool.close()
_pool = None
def get_conn() -> psycopg.Connection:
"""Get a raw connection (for use as context manager)."""
return psycopg.connect(DATABASE_URL, row_factory=dict_row)
# --- repository helpers ---
def fetch_one(sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchone()
def fetch_all(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
return cur.fetchall()
def execute(sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
pool = get_pool()
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
conn.commit()
if cur.description:
return cur.fetchone()
return None
# JSON adaptation for psycopg v3
def adapt_jsonb(value: Any) -> str:
return json.dumps(value)
def convert_uuid(value: Any) -> str | None:
if value is None:
return None
return str(value)

View file

@ -0,0 +1,81 @@
"""Tiny migration runner: applies migrations/NNN_*.sql in order.
Tracks applied migrations in a `schema_migrations` table.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
import psycopg
from app.config import DATABASE_URL, MIGRATIONS_DIR
def _ensure_migrations_table(conn: psycopg.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
id int PRIMARY KEY,
filename text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
)
"""
)
def list_migration_files() -> list[tuple[int, Path]]:
"""Return sorted (number, path) for all NNN_*.sql files."""
pattern = re.compile(r"^(\d{3})_.*\.sql$")
result: list[tuple[int, Path]] = []
if not MIGRATIONS_DIR.exists():
return result
for p in sorted(MIGRATIONS_DIR.iterdir()):
m = pattern.match(p.name)
if m:
result.append((int(m.group(1)), p))
result.sort(key=lambda t: t[0])
return result
def get_applied(conn: psycopg.Connection) -> set[int]:
_ensure_migrations_table(conn)
rows = conn.execute("SELECT id FROM schema_migrations").fetchall()
return {r[0] for r in rows}
def run_migrations(database_url: str | None = None) -> list[int]:
"""Apply all pending migrations. Returns list of applied migration numbers."""
url = database_url or DATABASE_URL
applied_ids: list[int] = []
with psycopg.connect(url) as conn:
applied = get_applied(conn)
for num, path in list_migration_files():
if num in applied:
continue
sql = path.read_text()
conn.execute(sql)
conn.execute(
"INSERT INTO schema_migrations (id, filename) VALUES (%s, %s)",
(num, path.name),
)
conn.commit()
applied_ids.append(num)
return applied_ids
def reset_database(database_url: str | None = None) -> None:
"""Drop all tables (for tests only) and re-run migrations."""
url = database_url or DATABASE_URL
with psycopg.connect(url) as conn:
conn.execute(
"""
DROP TABLE IF EXISTS task_run, outbox, approval, artifact,
application, job_posting, cv_section, profile, schema_migrations
CASCADE
"""
)
conn.commit()
run_migrations(url)

389
apps/api/app/db/repo_app.py Normal file
View file

@ -0,0 +1,389 @@
"""Repository functions for job_posting, application, artifact, approval, outbox, task_run."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
from app.db import execute, fetch_all, fetch_one
# --- Job Posting ---
def create_job_posting(
source: str,
url: str,
company: str,
title: str,
location: str | None = None,
description: str = "",
external_id: str | None = None,
raw: dict[str, Any] | None = None,
) -> dict[str, Any]:
row = execute(
"""
INSERT INTO job_posting (source, external_id, url, company, title, location, description, raw)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (source, url) DO UPDATE SET
company = EXCLUDED.company,
title = EXCLUDED.title,
fetched_at = now()
RETURNING *
""",
(
source,
external_id,
url,
company,
title,
location,
description,
json.dumps(raw or {}),
),
)
if row is None:
raise RuntimeError("insert job_posting failed")
return _normalize_posting(row)
def get_job_posting(posting_id: str) -> dict[str, Any] | None:
row = fetch_one("SELECT * FROM job_posting WHERE id = %s", (posting_id,))
if row is None:
return None
return _normalize_posting(row)
def list_postings() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM job_posting ORDER BY fetched_at DESC")
return [_normalize_posting(r) for r in rows]
def _normalize_posting(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"source": row["source"],
"external_id": row.get("external_id"),
"url": row["url"],
"company": row["company"],
"title": row["title"],
"location": row.get("location"),
"description": row.get("description", ""),
"fetched_at": row["fetched_at"].isoformat() if row.get("fetched_at") else None,
}
# --- Application ---
def create_application(job_posting_id: str) -> dict[str, Any]:
row = execute(
"""
INSERT INTO application (job_posting_id, state)
VALUES (%s, 'discovered')
RETURNING *
""",
(job_posting_id,),
)
if row is None:
raise RuntimeError("insert application failed")
return _normalize_application(row)
def get_application(app_id: str) -> dict[str, Any] | None:
row = fetch_one(
"""
SELECT a.*, j.company, j.title, j.location
FROM application a
JOIN job_posting j ON a.job_posting_id = j.id
WHERE a.id = %s
""",
(app_id,),
)
if row is None:
return None
return _normalize_application(row)
def list_applications() -> list[dict[str, Any]]:
rows = fetch_all(
"""
SELECT a.*, j.company, j.title, j.location
FROM application a
JOIN job_posting j ON a.job_posting_id = j.id
ORDER BY a.created_at DESC
"""
)
return [_normalize_application(r) for r in rows]
def update_application_state(
app_id: str,
new_state: str,
) -> dict[str, Any] | None:
row = execute(
"""
UPDATE application
SET state = %s, state_changed_at = now()
WHERE id = %s
RETURNING *
""",
(new_state, app_id),
)
if row is None:
return None
return _normalize_application(row)
def update_application_score(
app_id: str,
score: float,
rationale: dict[str, Any],
) -> dict[str, Any] | None:
row = execute(
"""
UPDATE application
SET score = %s, score_rationale = %s, state = 'scored', state_changed_at = now()
WHERE id = %s
RETURNING *
""",
(score, json.dumps(rationale), app_id),
)
if row is None:
return None
return _normalize_application(row)
def _normalize_application(row: dict[str, Any]) -> dict[str, Any]:
rationale = row.get("score_rationale")
if rationale is not None and not isinstance(rationale, dict):
rationale = json.loads(rationale) if isinstance(rationale, str) else rationale
return {
"id": str(row["id"]),
"job_posting_id": str(row["job_posting_id"]),
"state": row["state"],
"score": float(row["score"]) if row.get("score") is not None else None,
"score_rationale": rationale,
"notes": row.get("notes"),
"state_changed_at": row["state_changed_at"].isoformat() if row.get("state_changed_at") else None,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"company": row.get("company"),
"title": row.get("title"),
"location": row.get("location"),
}
# --- Artifact ---
def create_artifact(
application_id: str,
kind: str,
filename: str,
content_bytes: bytes,
storage_path: str,
origin: str = "ai_reviewed",
version: int = 1,
) -> dict[str, Any]:
content_hash = hashlib.sha256(content_bytes).hexdigest()
row = execute(
"""
INSERT INTO artifact (application_id, kind, filename, content_hash, storage_path, version, origin)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(application_id, kind, filename, content_hash, storage_path, version, origin),
)
if row is None:
raise RuntimeError("insert artifact failed")
return _normalize_artifact(row)
def list_artifacts(application_id: str) -> list[dict[str, Any]]:
rows = fetch_all(
"SELECT * FROM artifact WHERE application_id = %s ORDER BY created_at DESC",
(application_id,),
)
return [_normalize_artifact(r) for r in rows]
def get_artifact(artifact_id: str) -> dict[str, Any] | None:
row = fetch_one("SELECT * FROM artifact WHERE id = %s", (artifact_id,))
if row is None:
return None
return _normalize_artifact(row)
def _normalize_artifact(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"application_id": str(row["application_id"]),
"kind": row["kind"],
"filename": row["filename"],
"content_hash": row["content_hash"],
"storage_path": row["storage_path"],
"version": row["version"],
"origin": row["origin"],
}
# --- Approval ---
def create_approval(
application_id: str,
artifact_id: str,
artifact_hash: str,
action: str,
) -> dict[str, Any]:
row = execute(
"""
INSERT INTO approval (application_id, artifact_id, artifact_hash, action, expires_at)
VALUES (%s, %s, %s, %s, now() + interval '24 hours')
RETURNING *
""",
(application_id, artifact_id, artifact_hash, action),
)
if row is None:
raise RuntimeError("insert approval failed")
return _normalize_approval(row)
def get_approval(approval_id: str) -> dict[str, Any] | None:
row = fetch_one("SELECT * FROM approval WHERE id = %s", (approval_id,))
if row is None:
return None
return _normalize_approval(row)
def confirm_approval(approval_id: str) -> dict[str, Any] | None:
row = execute(
"""
UPDATE approval
SET confirmed_by_user = true, confirmed_at = now()
WHERE id = %s
RETURNING *
""",
(approval_id,),
)
if row is None:
return None
return _normalize_approval(row)
def reject_approval(approval_id: str) -> dict[str, Any] | None:
row = execute(
"""
UPDATE approval
SET confirmed_by_user = false, confirmed_at = now()
WHERE id = %s
RETURNING *
""",
(approval_id,),
)
if row is None:
return None
return _normalize_approval(row)
def _normalize_approval(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"application_id": str(row["application_id"]),
"artifact_id": str(row["artifact_id"]),
"artifact_hash": row["artifact_hash"],
"action": row["action"],
"confirmed_by_user": row["confirmed_by_user"],
"confirmed_at": row["confirmed_at"].isoformat() if row.get("confirmed_at") else None,
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
}
# --- Outbox ---
def create_outbox(approval_id: str, payload: dict[str, Any]) -> dict[str, Any]:
row = execute(
"""
INSERT INTO outbox (approval_id, payload, status)
VALUES (%s, %s, 'pending')
RETURNING *
""",
(approval_id, json.dumps(payload)),
)
if row is None:
raise RuntimeError("insert outbox failed")
return _normalize_outbox(row)
def update_outbox_sent(outbox_id: str) -> dict[str, Any] | None:
row = execute(
"""
UPDATE outbox
SET status = 'sent', sent_at = now()
WHERE id = %s
RETURNING *
""",
(outbox_id,),
)
if row is None:
return None
return _normalize_outbox(row)
def _normalize_outbox(row: dict[str, Any]) -> dict[str, Any]:
payload = row.get("payload")
if payload is not None and not isinstance(payload, dict):
payload = json.loads(payload) if isinstance(payload, str) else payload
return {
"id": str(row["id"]),
"approval_id": str(row["approval_id"]),
"kind": row.get("kind", "email"),
"payload": payload or {},
"status": row["status"],
"sent_at": row["sent_at"].isoformat() if row.get("sent_at") else None,
"error": row.get("error"),
}
# --- Task Run (telemetry) ---
def create_task_run(data: dict[str, Any]) -> dict[str, Any]:
row = execute(
"""
INSERT INTO task_run (task, model, provider, input_tokens, output_tokens, cost_usd, duration_ms, application_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
data["task"],
data["model"],
data["provider"],
data["input_tokens"],
data["output_tokens"],
data.get("cost_usd"),
data["duration_ms"],
data.get("application_id"),
),
)
if row is None:
raise RuntimeError("insert task_run failed")
return _normalize_task_run(row)
def list_task_runs() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM task_run ORDER BY created_at DESC LIMIT 100")
return [_normalize_task_run(r) for r in rows]
def _normalize_task_run(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"task": row["task"],
"model": row["model"],
"provider": row["provider"],
"input_tokens": row["input_tokens"],
"output_tokens": row["output_tokens"],
"cost_usd": float(row["cost_usd"]) if row.get("cost_usd") is not None else None,
"duration_ms": row["duration_ms"],
"application_id": str(row["application_id"]) if row.get("application_id") else None,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
}

View file

@ -0,0 +1,157 @@
"""Repository functions for profile and cv_section tables."""
from __future__ import annotations
import json
from typing import Any
from app.db import execute, fetch_all, fetch_one
def get_or_create_profile() -> dict[str, Any] | None:
"""Get the single profile row, or create a default one if none exists."""
row = fetch_one("SELECT * FROM profile LIMIT 1")
if row is not None:
return _normalize_profile(row)
# Create default profile
row = execute(
"""
INSERT INTO profile (full_name, email)
VALUES ('', '')
RETURNING *
"""
)
if row is None:
return None
return _normalize_profile(row)
def update_profile(data: dict[str, Any]) -> dict[str, Any] | None:
"""Update the single profile row with provided fields."""
profile = get_or_create_profile()
if profile is None:
return None
pid = profile["id"]
fields = []
values: list[Any] = []
for key in ("full_name", "email", "phone", "location", "headline", "summary"):
if key in data and data[key] is not None:
fields.append(f"{key} = %s")
values.append(data[key])
if "languages" in data and data["languages"] is not None:
fields.append("languages = %s")
values.append(json.dumps(data["languages"]))
if "hard_rules" in data and data["hard_rules"] is not None:
fields.append("hard_rules = %s")
values.append(json.dumps(data["hard_rules"]))
if not fields:
return profile
fields.append("updated_at = now()")
values.append(pid)
sql = f"UPDATE profile SET {', '.join(fields)} WHERE id = %s RETURNING *"
row = execute(sql, tuple(values))
if row is None:
return None
return _normalize_profile(row)
def _normalize_profile(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"full_name": row["full_name"],
"email": row["email"],
"phone": row.get("phone"),
"location": row.get("location"),
"headline": row.get("headline"),
"summary": row.get("summary"),
"languages": row.get("languages", []) if isinstance(row.get("languages"), list) else json.loads(row.get("languages", "[]")),
"hard_rules": row.get("hard_rules", {}) if isinstance(row.get("hard_rules"), dict) else json.loads(row.get("hard_rules", "{}")),
}
# --- CV Sections ---
def list_sections() -> list[dict[str, Any]]:
rows = fetch_all(
"SELECT * FROM cv_section ORDER BY kind, sort_order"
)
return [_normalize_section(r) for r in rows]
def create_section(profile_id: str, data: dict[str, Any]) -> dict[str, Any]:
row = execute(
"""
INSERT INTO cv_section
(profile_id, kind, title, org, location, start_date, end_date, bullets, tags, sort_order)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
profile_id,
data["kind"],
data["title"],
data.get("org"),
data.get("location"),
data.get("start_date"),
data.get("end_date"),
json.dumps(data.get("bullets", [])),
data.get("tags", []),
data.get("sort_order", 0),
),
)
if row is None:
raise RuntimeError("insert failed")
return _normalize_section(row)
def update_section(section_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
fields = []
values: list[Any] = []
for key in ("kind", "title", "org", "location", "start_date", "end_date", "sort_order"):
if key in data and data[key] is not None:
fields.append(f"{key} = %s")
values.append(data[key])
if "bullets" in data and data["bullets"] is not None:
fields.append("bullets = %s")
values.append(json.dumps(data["bullets"]))
if "tags" in data and data["tags"] is not None:
fields.append("tags = %s")
values.append(data["tags"])
if not fields:
return get_section(section_id)
fields.append("updated_at = now()")
values.append(section_id)
sql = f"UPDATE cv_section SET {', '.join(fields)} WHERE id = %s RETURNING *"
row = execute(sql, tuple(values))
if row is None:
return None
return _normalize_section(row)
def get_section(section_id: str) -> dict[str, Any] | None:
row = fetch_one("SELECT * FROM cv_section WHERE id = %s", (section_id,))
if row is None:
return None
return _normalize_section(row)
def delete_section(section_id: str) -> bool:
row = execute("DELETE FROM cv_section WHERE id = %s RETURNING id", (section_id,))
return row is not None
def _normalize_section(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"profile_id": str(row["profile_id"]),
"kind": row["kind"],
"title": row["title"],
"org": row.get("org"),
"location": row.get("location"),
"start_date": row.get("start_date").isoformat() if row.get("start_date") else None,
"end_date": row.get("end_date").isoformat() if row.get("end_date") else None,
"bullets": row.get("bullets", []) if isinstance(row.get("bullets"), list) else json.loads(row.get("bullets", "[]")),
"tags": list(row.get("tags", [])) if row.get("tags") else [],
"sort_order": row.get("sort_order", 0),
}

101
apps/api/app/llm.py Normal file
View file

@ -0,0 +1,101 @@
"""LLM gateway integration.
Per the task spec: LLM usage via packages.llm_gateway. Since T2 hasn't built
the gateway yet, this module provides a minimal mock-mode interface that the
API endpoints call. When packages.llm_gateway is available (importable), it
delegates to the real gateway. Otherwise, it returns deterministic canned
outputs per task name.
Task names used by the API:
- cv_assist: returns suggestions for a CV section bullet
- cl_critique: returns critique comments for a cover letter
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from typing import Any
# Try to import the real llm_gateway package
try:
from packages.llm_gateway.client import run_task as _real_run_task # type: ignore
HAS_REAL_GATEWAY = True
except Exception:
HAS_REAL_GATEWAY = False
def _has_api_key() -> bool:
return bool(
os.environ.get("LLM_PRIMARY_KEY")
or os.environ.get("OLLAMA_API_KEY")
)
# Deterministic mock outputs per task name
MOCK_OUTPUTS: dict[str, dict[str, Any]] = {
"cv_assist": {
"suggestions": [
"Improved bullet: Led cross-functional team of 8 to deliver feature X 2 weeks ahead of schedule.",
"Alternative: Streamlined process reducing cycle time by 30% via automation.",
]
},
"cl_critique": {
"comments": [
{
"quote": "I am writing to apply",
"suggestion": "Consider a stronger opening that references the specific role.",
"severity": "medium",
},
{
"quote": "I have experience",
"suggestion": "Quantify with a specific achievement rather than a generic claim.",
"severity": "low",
},
]
},
"score": {
"score": 72,
"rationale": {
"match": 0.72,
"factors": {"skills": 0.8, "location": 0.6, "experience": 0.75},
},
},
}
def run_task(
task: str,
prompt: str,
schema: dict[str, Any] | None = None,
telemetry_sink: callable | None = None,
) -> dict[str, Any]:
"""Run an LLM task. Uses mock mode when no API key is present.
Returns a dict with the task result. If a telemetry_sink callable is
provided, it is called with a dict of token/cost info.
"""
if HAS_REAL_GATEWAY and _has_api_key():
return _real_run_task(task, prompt, schema)
# Mock mode
time.sleep(0.01) # simulate latency
result = MOCK_OUTPUTS.get(task, {"result": "mock"})
# Validate against schema if provided (basic check)
# In real gateway this would be jsonschema validation
if telemetry_sink is not None:
telemetry_sink({
"task": task,
"model": "mock",
"provider": "mock",
"input_tokens": len(prompt) // 4, # rough estimate
"output_tokens": len(json.dumps(result)) // 4,
"cost_usd": 0.0,
"duration_ms": 10,
})
return result

450
apps/api/app/main.py Normal file
View file

@ -0,0 +1,450 @@
"""FastAPI application — main entry point."""
from __future__ import annotations
import hashlib
import json
import os
import tempfile
from datetime import datetime, timezone
from typing import Any
from fastapi import FastAPI, HTTPException, status
from app import llm
from app.db import close_pool, execute, fetch_all, fetch_one, get_pool
from app.db import migrate as migrate_mod
from app.db import repo_app, repo_profile
from app.schemas import (
AiAssistRequest,
AiAssistResponse,
ApplicationOut,
ApprovalCreate,
ApprovalOut,
ArtifactCreate,
ArtifactOut,
CoverLetterRequest,
CoverLetterResponse,
CvSectionCreate,
CvSectionOut,
CvSectionUpdate,
ErrorOut,
JobPostingCreate,
JobPostingOut,
OutboxOut,
OutboxSendRequest,
ProfileOut,
ProfileUpdate,
ScoreResponse,
TaskRunOut,
TransitionRequest,
)
from app.statemachine import TransitionContext, check_transition, InvalidTransition
from app.transport import get_transport
app = FastAPI(title="Jobhunt API", version="0.1.0")
@app.on_event("startup")
def _startup() -> None:
"""Ensure pool is initialized and migrations are applied."""
get_pool()
migrate_mod.run_migrations()
@app.on_event("shutdown")
def _shutdown() -> None:
close_pool()
# --- Health ---
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
# --- Profile ---
@app.get("/api/profile", response_model=ProfileOut)
def get_profile() -> Any:
p = repo_profile.get_or_create_profile()
if p is None:
raise HTTPException(status_code=500, detail="Failed to create profile")
return p
@app.put("/api/profile", response_model=ProfileOut)
def put_profile(body: ProfileUpdate) -> Any:
p = repo_profile.update_profile(body.model_dump(exclude_none=True))
if p is None:
raise HTTPException(status_code=404, detail="Profile not found")
return p
# --- CV Sections ---
@app.get("/api/profile/sections", response_model=list[CvSectionOut])
def get_sections() -> Any:
return repo_profile.list_sections()
@app.post("/api/profile/sections", response_model=CvSectionOut, status_code=201)
def create_section(body: CvSectionCreate) -> Any:
p = repo_profile.get_or_create_profile()
if p is None:
raise HTTPException(status_code=500, detail="No profile")
return repo_profile.create_section(p["id"], body.model_dump())
@app.put("/api/profile/sections/{section_id}", response_model=CvSectionOut)
def update_section(section_id: str, body: CvSectionUpdate) -> Any:
s = repo_profile.update_section(section_id, body.model_dump(exclude_none=True))
if s is None:
raise HTTPException(status_code=404, detail="Section not found")
return s
@app.delete("/api/profile/sections/{section_id}", status_code=204)
def delete_section(section_id: str) -> None:
if not repo_profile.delete_section(section_id):
raise HTTPException(status_code=404, detail="Section not found")
@app.post("/api/profile/sections/{section_id}/ai-assist", response_model=AiAssistResponse)
def ai_assist(section_id: str, body: AiAssistRequest) -> Any:
section = repo_profile.get_section(section_id)
if section is None:
raise HTTPException(status_code=404, detail="Section not found")
result = llm.run_task(
"cv_assist",
body.instruction,
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": None,
}),
)
return {"suggestions": result.get("suggestions", [])}
# --- Job Postings ---
@app.post("/api/postings", response_model=ApplicationOut, status_code=201)
def create_posting(body: JobPostingCreate) -> Any:
"""Create a job posting from a URL. For POC: manual_url source."""
url = body.url.strip()
if not url:
raise HTTPException(status_code=422, detail="URL is required")
# For POC: manual_url source. In production, connectors package would fetch.
# Extract a simple company/title from URL or use placeholder.
posting = repo_app.create_job_posting(
source="manual_url",
url=url,
company="Unknown",
title="Unknown position",
location=None,
description="",
raw={"url": url},
)
application = repo_app.create_application(posting["id"])
return application
@app.get("/api/postings", response_model=list[JobPostingOut])
def get_postings() -> Any:
return repo_app.list_postings()
@app.post("/api/postings/{posting_id}/score", response_model=ScoreResponse)
def score_posting(posting_id: str) -> Any:
"""Score a job posting against the profile."""
posting = repo_app.get_job_posting(posting_id)
if posting is None:
raise HTTPException(status_code=404, detail="Posting not found")
# Find the application for this posting
apps = repo_app.list_applications()
app_for_posting = None
for a in apps:
if a["job_posting_id"] == posting["id"]:
app_for_posting = a
break
if app_for_posting is None:
raise HTTPException(status_code=404, detail="No application for posting")
# Run scoring via LLM (mock mode returns deterministic result)
result = llm.run_task(
"score",
f"Score this posting: {posting['title']} at {posting['company']}",
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": app_for_posting["id"],
}),
)
score = float(result.get("score", 50))
rationale = result.get("rationale", {})
repo_app.update_application_score(app_for_posting["id"], score, rationale)
return {"score": score, "rationale": rationale}
# --- Applications ---
@app.get("/api/applications", response_model=list[ApplicationOut])
def get_applications() -> Any:
return repo_app.list_applications()
@app.post("/api/applications/{app_id}/transition")
def transition_application(app_id: str, body: TransitionRequest) -> Any:
app_row = repo_app.get_application(app_id)
if app_row is None:
raise HTTPException(status_code=404, detail="Application not found")
to_state = body.to
from_state = app_row["state"]
# Build context for guard evaluation
has_score = app_row.get("score") is not None
# For drafting -> sent transition, check confirmed approval
has_confirmed_approval = False
artifact_hash_match = False
if from_state == "drafting" and to_state == "sent":
approvals = fetch_all(
"SELECT * FROM approval WHERE application_id = %s AND confirmed_by_user = true",
(app_id,),
)
for a in approvals:
expires = a["expires_at"]
if expires is not None:
# Check not expired
now_utc = datetime.now(timezone.utc)
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires > now_utc:
has_confirmed_approval = True
# Check hash match
artifact = repo_app.get_artifact(str(a["artifact_id"]))
if artifact and artifact["content_hash"] == a["artifact_hash"]:
artifact_hash_match = True
break
ctx = TransitionContext(
application_id=app_id,
from_state=from_state,
to_state=to_state,
has_score=has_score,
has_confirmed_approval=has_confirmed_approval,
artifact_hash_match=artifact_hash_match,
)
try:
check_transition(ctx)
except InvalidTransition as exc:
raise HTTPException(
status_code=409,
detail={"code": "invalid_transition", "message": str(exc)},
)
updated = repo_app.update_application_state(app_id, to_state)
if updated is None:
raise HTTPException(status_code=500, detail="Update failed")
return updated
# --- Artifacts ---
@app.post("/api/applications/{app_id}/artifacts", response_model=ArtifactOut, status_code=201)
def create_artifact(app_id: str, body: ArtifactCreate) -> Any:
app_row = repo_app.get_application(app_id)
if app_row is None:
raise HTTPException(status_code=404, detail="Application not found")
content_bytes = body.content.encode("utf-8")
# Store in temp dir (POC)
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
os.makedirs(storage_dir, exist_ok=True)
filename = f"{body.kind}_{app_id[:8]}.txt"
storage_path = os.path.join(storage_dir, filename)
with open(storage_path, "wb") as f:
f.write(content_bytes)
return repo_app.create_artifact(
application_id=app_id,
kind=body.kind,
filename=filename,
content_bytes=content_bytes,
storage_path=storage_path,
origin="user_drafted",
)
@app.post("/api/applications/{app_id}/artifacts/cover-letter", response_model=CoverLetterResponse)
def create_cover_letter(app_id: str, body: CoverLetterRequest) -> Any:
app_row = repo_app.get_application(app_id)
if app_row is None:
raise HTTPException(status_code=404, detail="Application not found")
content_bytes = body.letter_text.encode("utf-8")
storage_dir = os.path.join(tempfile.gettempdir(), "jobhunt_artifacts")
os.makedirs(storage_dir, exist_ok=True)
filename = f"cover_letter_{app_id[:8]}.txt"
storage_path = os.path.join(storage_dir, filename)
with open(storage_path, "wb") as f:
f.write(content_bytes)
artifact = repo_app.create_artifact(
application_id=app_id,
kind="cover_letter",
filename=filename,
content_bytes=content_bytes,
storage_path=storage_path,
origin="user_drafted",
)
# Run AI critique via LLM gateway (mock mode)
result = llm.run_task(
"cl_critique",
body.letter_text,
telemetry_sink=lambda info: repo_app.create_task_run({
**info,
"application_id": app_id,
}),
)
return {
"artifact": artifact,
"critique": result.get("comments", []),
}
@app.get("/api/applications/{app_id}/artifacts", response_model=list[ArtifactOut])
def get_artifacts(app_id: str) -> Any:
return repo_app.list_artifacts(app_id)
# --- Approval & Outbox ---
@app.post("/api/applications/{app_id}/approvals", response_model=ApprovalOut, status_code=201)
def create_approval_endpoint(app_id: str, body: ApprovalCreate) -> Any:
app_row = repo_app.get_application(app_id)
if app_row is None:
raise HTTPException(status_code=404, detail="Application not found")
artifact = repo_app.get_artifact(body.artifact_id)
if artifact is None:
raise HTTPException(status_code=404, detail="Artifact not found")
return repo_app.create_approval(
application_id=app_id,
artifact_id=body.artifact_id,
artifact_hash=artifact["content_hash"],
action=body.action,
)
@app.post("/api/approvals/{approval_id}/confirm", response_model=ApprovalOut)
def confirm_approval(approval_id: str) -> Any:
approval = repo_app.get_approval(approval_id)
if approval is None:
raise HTTPException(status_code=404, detail="Approval not found")
# Verify artifact hash matches
artifact = repo_app.get_artifact(approval["artifact_id"])
if artifact is None:
raise HTTPException(status_code=409, detail="Artifact not found")
if artifact["content_hash"] != approval["artifact_hash"]:
raise HTTPException(
status_code=409,
detail={"code": "hash_mismatch", "message": "Artifact hash does not match approval hash"},
)
# Check expiry
expires = approval["expires_at"]
if expires:
now_utc = datetime.now(timezone.utc)
expires_dt = datetime.fromisoformat(expires)
if expires_dt.tzinfo is None:
expires_dt = expires_dt.replace(tzinfo=timezone.utc)
if expires_dt < now_utc:
raise HTTPException(
status_code=409,
detail={"code": "expired", "message": "Approval has expired"},
)
result = repo_app.confirm_approval(approval_id)
if result is None:
raise HTTPException(status_code=500, detail="Confirm failed")
return result
@app.post("/api/approvals/{approval_id}/reject", response_model=ApprovalOut)
def reject_approval_endpoint(approval_id: str) -> Any:
result = repo_app.reject_approval(approval_id)
if result is None:
raise HTTPException(status_code=404, detail="Approval not found")
return result
@app.post("/api/outbox/send", response_model=OutboxOut)
def send_outbox(body: OutboxSendRequest) -> Any:
"""Send via outbox. Fails 409 unless approval is confirmed, unexpired, hash match."""
approval = repo_app.get_approval(body.approval_id)
if approval is None:
raise HTTPException(status_code=404, detail="Approval not found")
# Check confirmed
if not approval["confirmed_by_user"]:
raise HTTPException(
status_code=409,
detail={"code": "not_confirmed", "message": "Approval not confirmed by user"},
)
# Check expiry
expires = approval["expires_at"]
if expires:
now_utc = datetime.now(timezone.utc)
expires_dt = datetime.fromisoformat(expires)
if expires_dt.tzinfo is None:
expires_dt = expires_dt.replace(tzinfo=timezone.utc)
if expires_dt < now_utc:
raise HTTPException(
status_code=409,
detail={"code": "expired", "message": "Approval has expired"},
)
# Check hash match
artifact = repo_app.get_artifact(approval["artifact_id"])
if artifact is None:
raise HTTPException(
status_code=409,
detail={"code": "artifact_missing", "message": "Artifact not found"},
)
if artifact["content_hash"] != approval["artifact_hash"]:
raise HTTPException(
status_code=409,
detail={"code": "hash_mismatch", "message": "Artifact content has changed"},
)
# Create outbox record
outbox = repo_app.create_outbox(body.approval_id, body.payload)
# Send via transport
transport = get_transport()
result = transport.send(body.payload)
if result.get("success"):
updated = repo_app.update_outbox_sent(outbox["id"])
if updated:
return updated
return outbox
# --- Telemetry ---
@app.get("/api/telemetry/tasks", response_model=list[TaskRunOut])
def get_telemetry() -> Any:
return repo_app.list_task_runs()

220
apps/api/app/schemas.py Normal file
View file

@ -0,0 +1,220 @@
"""Pydantic schemas for all API contract payloads."""
from __future__ import annotations
from datetime import date
from typing import Any
from pydantic import BaseModel, Field
# --- Profile ---
class ProfileBase(BaseModel):
full_name: str = ""
email: str = ""
phone: str | None = None
location: str | None = None
headline: str | None = None
summary: str | None = None
languages: list[dict[str, Any]] = Field(default_factory=list)
hard_rules: dict[str, Any] = Field(default_factory=dict)
class ProfileUpdate(BaseModel):
full_name: str | None = None
email: str | None = None
phone: str | None = None
location: str | None = None
headline: str | None = None
summary: str | None = None
languages: list[dict[str, Any]] | None = None
hard_rules: dict[str, Any] | None = None
class ProfileOut(BaseModel):
id: str
full_name: str
email: str
phone: str | None = None
location: str | None = None
headline: str | None = None
summary: str | None = None
languages: list[dict[str, Any]] = []
hard_rules: dict[str, Any] = {}
# --- CV Section ---
class CvSectionCreate(BaseModel):
kind: str
title: str
org: str | None = None
location: str | None = None
start_date: date | None = None
end_date: date | None = None
bullets: list[str] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
sort_order: int = 0
class CvSectionUpdate(BaseModel):
kind: str | None = None
title: str | None = None
org: str | None = None
location: str | None = None
start_date: date | None = None
end_date: date | None = None
bullets: list[str] | None = None
tags: list[str] | None = None
sort_order: int | None = None
class CvSectionOut(BaseModel):
id: str
profile_id: str
kind: str
title: str
org: str | None = None
location: str | None = None
start_date: date | None = None
end_date: date | None = None
bullets: list[str] = []
tags: list[str] = []
sort_order: int = 0
class AiAssistRequest(BaseModel):
instruction: str
class AiAssistResponse(BaseModel):
suggestions: list[str]
# --- Job Posting ---
class JobPostingCreate(BaseModel):
url: str
class JobPostingOut(BaseModel):
id: str
source: str
external_id: str | None = None
url: str
company: str
title: str
location: str | None = None
description: str = ""
fetched_at: str | None = None
class ScoreResponse(BaseModel):
score: float
rationale: dict[str, Any]
# --- Application ---
class ApplicationOut(BaseModel):
id: str
job_posting_id: str
state: str
score: float | None = None
score_rationale: dict[str, Any] | None = None
notes: str | None = None
state_changed_at: str | None = None
created_at: str | None = None
# joined posting info
company: str | None = None
title: str | None = None
location: str | None = None
class TransitionRequest(BaseModel):
to: str
# --- Artifact ---
class ArtifactCreate(BaseModel):
kind: str
content: str
class ArtifactOut(BaseModel):
id: str
application_id: str
kind: str
filename: str
content_hash: str
storage_path: str
version: int
origin: str
class CoverLetterRequest(BaseModel):
letter_text: str
class CoverLetterResponse(BaseModel):
artifact: ArtifactOut
critique: list[dict[str, Any]]
# --- Approval ---
class ApprovalCreate(BaseModel):
action: str
artifact_id: str
class ApprovalOut(BaseModel):
id: str
application_id: str
artifact_id: str
artifact_hash: str
action: str
confirmed_by_user: bool
confirmed_at: str | None = None
expires_at: str
created_at: str | None = None
# --- Outbox ---
class OutboxSendRequest(BaseModel):
approval_id: str
payload: dict[str, Any]
class OutboxOut(BaseModel):
id: str
approval_id: str
kind: str
payload: dict[str, Any]
status: str
sent_at: str | None = None
error: str | None = None
# --- Telemetry ---
class TaskRunOut(BaseModel):
id: str
task: str
model: str
provider: str
input_tokens: int
output_tokens: int
cost_usd: float | None = None
duration_ms: int
application_id: str | None = None
created_at: str | None = None
# --- Errors ---
class ErrorOut(BaseModel):
error: dict[str, str]

View file

@ -0,0 +1,105 @@
"""State machine for application transitions.
Implements the transition table from docs/data-model.md.
Any transition not listed raises InvalidTransition (409 in API).
"""
from __future__ import annotations
from dataclasses import dataclass
class InvalidTransition(Exception):
"""Raised when a state transition is not allowed."""
def __init__(self, from_state: str, to_state: str, reason: str = ""):
self.from_state = from_state
self.to_state = to_state
self.reason = reason
super().__init__(
f"Invalid transition: {from_state} -> {to_state}"
+ (f": {reason}" if reason else "")
)
# All valid states
STATES = frozenset({
"discovered",
"scored",
"approved",
"rejected",
"drafting",
"sent",
"interviewing",
"offer",
"closed",
"expired",
})
# Transition table: (from, to) -> guard name (or None for no guard)
# Guard names map to guard functions below.
TRANSITIONS: dict[tuple[str, str], str | None] = {
("discovered", "scored"): "scoring_completed",
("discovered", "rejected"): None,
("scored", "approved"): None,
("scored", "rejected"): None,
("approved", "drafting"): None,
("drafting", "sent"): "confirmed_approval",
("sent", "interviewing"): None,
("interviewing", "offer"): None,
("interviewing", "closed"): None,
("offer", "closed"): None,
("scored", "expired"): None,
("approved", "expired"): None,
}
@dataclass
class TransitionContext:
"""Context passed to guard functions for validation."""
application_id: str
from_state: str
to_state: str
has_score: bool = False
has_confirmed_approval: bool = False
artifact_hash_match: bool = False
# Guard implementations
GUARDS: dict[str, callable] = {
"scoring_completed": lambda ctx: ctx.has_score,
"confirmed_approval": lambda ctx: ctx.has_confirmed_approval and ctx.artifact_hash_match,
}
def can_transition(from_state: str, to_state: str) -> bool:
"""Check if a transition is in the table (does not evaluate guards)."""
return (from_state, to_state) in TRANSITIONS
def check_transition(ctx: TransitionContext) -> None:
"""Validate a transition. Raises InvalidTransition if not allowed."""
if ctx.from_state not in STATES:
raise InvalidTransition(ctx.from_state, ctx.to_state, f"unknown state: {ctx.from_state}")
if ctx.to_state not in STATES:
raise InvalidTransition(ctx.from_state, ctx.to_state, f"unknown state: {ctx.to_state}")
key = (ctx.from_state, ctx.to_state)
if key not in TRANSITIONS:
raise InvalidTransition(
ctx.from_state,
ctx.to_state,
f"transition {ctx.from_state} -> {ctx.to_state} is not in the transition table",
)
guard_name = TRANSITIONS[key]
if guard_name is not None:
guard_fn = GUARDS[guard_name]
if not guard_fn(ctx):
raise InvalidTransition(
ctx.from_state,
ctx.to_state,
f"guard failed: {guard_name}",
)

51
apps/api/app/transport.py Normal file
View file

@ -0,0 +1,51 @@
"""Send transport interface.
Pluggable Transport interface for the outbox send operation.
Default EchoTransport records the payload and marks as sent.
No real email in POC.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Protocol
class Transport(Protocol):
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Send a payload. Returns a result dict with at least 'success' bool."""
...
class EchoTransport:
"""Default transport: records payload, returns success.
No real email is sent. Used for POC.
"""
def __init__(self) -> None:
self.sent: list[dict[str, Any]] = []
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
self.sent.append(payload)
return {
"success": True,
"sent_at": datetime.now(timezone.utc).isoformat(),
"echo": payload,
}
# Singleton instance
_default_transport: Transport | None = None
def get_transport() -> Transport:
global _default_transport
if _default_transport is None:
_default_transport = EchoTransport()
return _default_transport
def set_transport(t: Transport) -> None:
global _default_transport
_default_transport = t

View file

@ -0,0 +1,106 @@
-- 001_initial.sql — baseline schema
-- Mirrors schema.sql for the migration runner.
CREATE TABLE IF NOT EXISTS profile (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
full_name text NOT NULL,
email text NOT NULL,
phone text,
location text,
headline text,
summary text,
languages jsonb NOT NULL DEFAULT '[]',
hard_rules jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS cv_section (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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,
location text,
start_date date,
end_date date,
bullets jsonb NOT NULL DEFAULT '[]',
tags text[] NOT NULL DEFAULT '{}',
sort_order int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS job_posting (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source text NOT NULL,
external_id text,
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 NOT NULL DEFAULT now(),
UNIQUE (source, url)
);
CREATE TABLE IF NOT EXISTS application (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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,
score_rationale jsonb,
notes text,
state_changed_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS artifact (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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,
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 NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS approval (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
application_id uuid NOT NULL REFERENCES application(id) ON DELETE CASCADE,
artifact_id uuid NOT NULL REFERENCES artifact(id),
artifact_hash text NOT NULL,
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,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
approval_id uuid NOT NULL REFERENCES approval(id),
kind text NOT NULL DEFAULT 'email',
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','cancelled')),
sent_at timestamptz,
error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS task_run (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task text NOT NULL,
model text NOT NULL,
provider text NOT NULL,
input_tokens int NOT NULL,
output_tokens int NOT NULL,
cost_usd numeric,
duration_ms int NOT NULL,
application_id uuid REFERENCES application(id),
created_at timestamptz NOT NULL DEFAULT now()
);

29
apps/api/pyproject.toml Normal file
View file

@ -0,0 +1,29 @@
[project]
name = "jobhunt-api"
version = "0.1.0"
description = "Jobhunt platform backend API"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115",
"uvicorn>=0.30",
"psycopg[binary,pool]>=3.2",
"pydantic>=2.9",
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"httpx>=0.27",
]
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["app", "app.db"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

106
apps/api/schema.sql Normal file
View file

@ -0,0 +1,106 @@
-- schema.sql — full DDL for the jobhunt platform POC
-- Plain SQL, no ORM. Applied by the migration runner in db/migrate.py.
CREATE TABLE IF NOT EXISTS profile (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
full_name text NOT NULL,
email text NOT NULL,
phone text,
location text,
headline text,
summary text,
languages jsonb NOT NULL DEFAULT '[]',
hard_rules jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS cv_section (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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,
location text,
start_date date,
end_date date,
bullets jsonb NOT NULL DEFAULT '[]',
tags text[] NOT NULL DEFAULT '{}',
sort_order int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS job_posting (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source text NOT NULL,
external_id text,
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 NOT NULL DEFAULT now(),
UNIQUE (source, url)
);
CREATE TABLE IF NOT EXISTS application (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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,
score_rationale jsonb,
notes text,
state_changed_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS artifact (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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,
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 NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS approval (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
application_id uuid NOT NULL REFERENCES application(id) ON DELETE CASCADE,
artifact_id uuid NOT NULL REFERENCES artifact(id),
artifact_hash text NOT NULL,
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,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
approval_id uuid NOT NULL REFERENCES approval(id),
kind text NOT NULL DEFAULT 'email',
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','cancelled')),
sent_at timestamptz,
error text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS task_run (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task text NOT NULL,
model text NOT NULL,
provider text NOT NULL,
input_tokens int NOT NULL,
output_tokens int NOT NULL,
cost_usd numeric,
duration_ms int NOT NULL,
application_id uuid REFERENCES application(id),
created_at timestamptz NOT NULL DEFAULT now()
);

View file

@ -0,0 +1 @@
"""Test package init."""

View file

@ -0,0 +1,47 @@
"""Pytest fixtures: database setup/teardown for all tests.
Uses a real PostgreSQL on port 5433 (jobhunt-test-pg container).
Each test module gets a clean database via truncate fixtures.
"""
from __future__ import annotations
import os
import pytest
# Set DATABASE_URL before importing app modules
os.environ.setdefault(
"DATABASE_URL",
"postgresql://jobhunt:jobhunt@localhost:5433/jobhunt",
)
from app.db import close_pool, get_pool # noqa: E402
from app.db import migrate as migrate_mod # noqa: E402
from app.db import repo_app, repo_profile # noqa: E402
@pytest.fixture(scope="session", autouse=True)
def _setup_database():
"""Run migrations once at session start."""
migrate_mod.reset_database()
yield
close_pool()
@pytest.fixture(autouse=True)
def _truncate_tables():
"""Truncate all tables before each test (except schema_migrations)."""
import psycopg
from app.config import DATABASE_URL
with psycopg.connect(DATABASE_URL) as conn:
conn.execute(
"""
TRUNCATE TABLE task_run, outbox, approval, artifact,
application, job_posting, cv_section, profile
CASCADE
"""
)
conn.commit()
yield

265
apps/api/tests/test_api.py Normal file
View file

@ -0,0 +1,265 @@
"""API-level happy path tests using FastAPI TestClient."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.db import repo_app, repo_profile
@pytest.fixture()
def client():
from app.main import app
return TestClient(app)
class TestHealth:
def test_health(self, client):
resp = client.get("/api/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
class TestProfileFlow:
def test_get_profile_creates_default(self, client):
resp = client.get("/api/profile")
assert resp.status_code == 200
data = resp.json()
assert data["full_name"] == ""
assert data["email"] == ""
def test_update_profile(self, client):
# First create
client.get("/api/profile")
resp = client.put(
"/api/profile",
json={"full_name": "Test Person", "email": "test@example.com"},
)
assert resp.status_code == 200
assert resp.json()["full_name"] == "Test Person"
assert resp.json()["email"] == "test@example.com"
class TestCvSectionsFlow:
def test_create_and_list_sections(self, client):
# Ensure profile exists
client.get("/api/profile")
# Create a section
resp = client.post(
"/api/profile/sections",
json={
"kind": "experience",
"title": "Software Engineer",
"org": "TechCorp",
"bullets": ["Built feature X", "Improved performance by 20%"],
"tags": ["python", "fastapi"],
"sort_order": 0,
},
)
assert resp.status_code == 201
section = resp.json()
assert section["kind"] == "experience"
assert section["title"] == "Software Engineer"
assert "python" in section["tags"]
# List sections
resp = client.get("/api/profile/sections")
assert resp.status_code == 200
assert len(resp.json()) == 1
def test_update_and_delete_section(self, client):
client.get("/api/profile")
resp = client.post(
"/api/profile/sections",
json={"kind": "education", "title": "MSc", "org": "University"},
)
section_id = resp.json()["id"]
# Update
resp = client.put(
f"/api/profile/sections/{section_id}",
json={"title": "MSc Computer Science"},
)
assert resp.status_code == 200
assert resp.json()["title"] == "MSc Computer Science"
# Delete
resp = client.delete(f"/api/profile/sections/{section_id}")
assert resp.status_code == 204
def test_ai_assist_mock_mode(self, client):
"""AI assist returns mock suggestions when no API key is set."""
client.get("/api/profile")
resp = client.post(
"/api/profile/sections",
json={"kind": "experience", "title": "Dev", "bullets": ["did stuff"]},
)
section_id = resp.json()["id"]
resp = client.post(
f"/api/profile/sections/{section_id}/ai-assist",
json={"instruction": "improve this bullet"},
)
assert resp.status_code == 200
assert "suggestions" in resp.json()
assert len(resp.json()["suggestions"]) > 0
class TestJobPostingFlow:
def test_create_posting_and_application(self, client):
"""POST /postings creates a job_posting + application(discovered)."""
resp = client.post("/api/postings", json={"url": "https://example.com/job/456"})
assert resp.status_code == 201
app_data = resp.json()
assert app_data["state"] == "discovered"
# List postings
resp = client.get("/api/postings")
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["url"] == "https://example.com/job/456"
def test_score_posting(self, client):
"""POST /postings/{id}/score returns score + rationale."""
# Create posting
resp = client.post("/api/postings", json={"url": "https://example.com/job/789"})
assert resp.status_code == 201
# Get the posting id
resp_postings = client.get("/api/postings")
posting_id = resp_postings.json()[0]["id"]
# Score it
resp = client.post(f"/api/postings/{posting_id}/score")
assert resp.status_code == 200
data = resp.json()
assert "score" in data
assert "rationale" in data
assert data["score"] > 0
class TestApplicationsFlow:
def test_list_applications(self, client):
client.post("/api/postings", json={"url": "https://example.com/job/list1"})
resp = client.get("/api/applications")
assert resp.status_code == 200
assert len(resp.json()) >= 1
assert resp.json()[0]["state"] == "discovered"
def test_transition_to_rejected(self, client):
"""discovered -> rejected is valid (user action)."""
resp = client.post("/api/postings", json={"url": "https://example.com/job/trans1"})
app_id = resp.json()["id"]
resp = client.post(
f"/api/applications/{app_id}/transition",
json={"to": "rejected"},
)
assert resp.status_code == 200
assert resp.json()["state"] == "rejected"
def test_transition_invalid_409(self, client):
"""discovered -> sent is invalid -> 409."""
resp = client.post("/api/postings", json={"url": "https://example.com/job/trans2"})
app_id = resp.json()["id"]
resp = client.post(
f"/api/applications/{app_id}/transition",
json={"to": "sent"},
)
assert resp.status_code == 409
def test_transition_to_scored_without_score(self, client):
"""discovered -> scored without scoring guard -> 409."""
resp = client.post("/api/postings", json={"url": "https://example.com/job/trans3"})
app_id = resp.json()["id"]
resp = client.post(
f"/api/applications/{app_id}/transition",
json={"to": "scored"},
)
assert resp.status_code == 409
def test_transition_after_score(self, client):
"""discovered -> scored after scoring task completes -> success."""
resp = client.post("/api/postings", json={"url": "https://example.com/job/trans4"})
app_id = resp.json()["id"]
# Score first
resp_postings = client.get("/api/postings")
posting_id = resp_postings.json()[0]["id"]
client.post(f"/api/postings/{posting_id}/score")
# Now transition to scored should succeed (score is set, state already scored by scorer)
# Actually the scorer already sets state to 'scored', so let's test scored -> approved
resp = client.post(
f"/api/applications/{app_id}/transition",
json={"to": "approved"},
)
assert resp.status_code == 200
assert resp.json()["state"] == "approved"
class TestArtifactAndCoverLetter:
def test_create_artifact(self, client):
resp = client.post("/api/postings", json={"url": "https://example.com/job/art1"})
app_id = resp.json()["id"]
resp = client.post(
f"/api/applications/{app_id}/artifacts",
json={"kind": "email", "content": "Dear hiring manager..."},
)
assert resp.status_code == 201
assert resp.json()["kind"] == "email"
assert len(resp.json()["content_hash"]) == 64
def test_cover_letter_with_critique(self, client):
resp = client.post("/api/postings", json={"url": "https://example.com/job/art2"})
app_id = resp.json()["id"]
resp = client.post(
f"/api/applications/{app_id}/artifacts/cover-letter",
json={"letter_text": "I am writing to apply for the position. I have experience in many things."},
)
assert resp.status_code == 200
data = resp.json()
assert "artifact" in data
assert "critique" in data
assert len(data["critique"]) > 0
def test_list_artifacts(self, client):
resp = client.post("/api/postings", json={"url": "https://example.com/job/art3"})
app_id = resp.json()["id"]
client.post(
f"/api/applications/{app_id}/artifacts",
json={"kind": "cv", "content": "CV content"},
)
resp = client.get(f"/api/applications/{app_id}/artifacts")
assert resp.status_code == 200
assert len(resp.json()) >= 1
class TestTelemetry:
def test_telemetry_list(self, client):
# Generate a task run via ai-assist
client.get("/api/profile")
resp = client.post(
"/api/profile/sections",
json={"kind": "experience", "title": "Dev"},
)
section_id = resp.json()["id"]
client.post(
f"/api/profile/sections/{section_id}/ai-assist",
json={"instruction": "test"},
)
resp = client.get("/api/telemetry/tasks")
assert resp.status_code == 200
assert len(resp.json()) >= 1
assert resp.json()[0]["task"] == "cv_assist"
assert resp.json()[0]["model"] == "mock"

View file

@ -0,0 +1,216 @@
"""Approval gate tests: confirm, hash-match, expiry."""
from __future__ import annotations
import hashlib
import time
from datetime import datetime, timedelta, timezone
import psycopg
import pytest
from fastapi.testclient import TestClient
from app.config import DATABASE_URL
from app.db import repo_app, repo_profile
@pytest.fixture()
def client():
"""FastAPI TestClient with real database."""
from app.main import app
return TestClient(app)
@pytest.fixture()
def seed_data():
"""Seed a profile, posting, application, and artifact for approval tests."""
profile = repo_profile.get_or_create_profile()
assert profile is not None
posting = repo_app.create_job_posting(
source="manual_url",
url="https://example.com/job/123",
company="TestCorp",
title="Engineer",
)
application = repo_app.create_application(posting["id"])
content = b"Hello, I am applying for the position."
artifact = repo_app.create_artifact(
application_id=application["id"],
kind="cover_letter",
filename="cover.txt",
content_bytes=content,
storage_path="/tmp/cover.txt",
origin="user_drafted",
)
return {
"profile": profile,
"posting": posting,
"application": application,
"artifact": artifact,
"content": content,
}
class TestApprovalConfirm:
def test_confirm_approval_success(self, client, seed_data):
"""User confirms approval with matching hash -> success."""
app_id = seed_data["application"]["id"]
artifact_id = seed_data["artifact"]["id"]
# Create approval
resp = client.post(
f"/api/applications/{app_id}/approvals",
json={"action": "send_email", "artifact_id": artifact_id},
)
assert resp.status_code == 201
approval = resp.json()
# Confirm
resp = client.post(f"/api/approvals/{approval['id']}/confirm")
assert resp.status_code == 200
assert resp.json()["confirmed_by_user"] is True
def test_confirm_approval_hash_mismatch(self, client, seed_data):
"""Confirm with a different artifact hash -> 409."""
app_id = seed_data["application"]["id"]
artifact_id = seed_data["artifact"]["id"]
# Create approval (stores correct hash)
resp = client.post(
f"/api/applications/{app_id}/approvals",
json={"action": "send_email", "artifact_id": artifact_id},
)
assert resp.status_code == 201
approval_id = resp.json()["id"]
# Now modify the artifact content so hash changes
# We create a new artifact with different content but same id is not possible.
# Instead, directly update the artifact hash in the DB to simulate mutation.
new_content = b"Modified content"
new_hash = hashlib.sha256(new_content).hexdigest()
with psycopg.connect(DATABASE_URL) as conn:
conn.execute(
"UPDATE artifact SET content_hash = %s WHERE id = %s",
(new_hash, artifact_id),
)
conn.commit()
# Confirm should fail with hash mismatch
resp = client.post(f"/api/approvals/{approval_id}/confirm")
assert resp.status_code == 409
assert "hash_mismatch" in resp.text or "hash" in resp.text.lower()
class TestApprovalExpiry:
def test_confirm_expired_approval(self, client, seed_data):
"""Confirm an expired approval -> 409."""
app_id = seed_data["application"]["id"]
artifact_id = seed_data["artifact"]["id"]
# Create approval
resp = client.post(
f"/api/applications/{app_id}/approvals",
json={"action": "send_email", "artifact_id": artifact_id},
)
assert resp.status_code == 201
approval_id = resp.json()["id"]
# Set expires_at to the past
past_time = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
with psycopg.connect(DATABASE_URL) as conn:
conn.execute(
"UPDATE approval SET expires_at = %s WHERE id = %s",
(past_time, approval_id),
)
conn.commit()
# Confirm should fail with expired
resp = client.post(f"/api/approvals/{approval_id}/confirm")
assert resp.status_code == 409
assert "expired" in resp.text.lower()
class TestOutboxSend:
def test_send_without_confirmation(self, client, seed_data):
"""Send without user confirmation -> 409."""
app_id = seed_data["application"]["id"]
artifact_id = seed_data["artifact"]["id"]
# Create approval
resp = client.post(
f"/api/applications/{app_id}/approvals",
json={"action": "send_email", "artifact_id": artifact_id},
)
assert resp.status_code == 201
approval_id = resp.json()["id"]
# Try to send without confirming
resp = client.post(
"/api/outbox/send",
json={"approval_id": approval_id, "payload": {"to": "test@example.com"}},
)
assert resp.status_code == 409
assert "not_confirmed" in resp.text
def test_send_with_confirmation_success(self, client, seed_data):
"""Full flow: create approval, confirm, send -> success."""
app_id = seed_data["application"]["id"]
artifact_id = seed_data["artifact"]["id"]
# Create approval
resp = client.post(
f"/api/applications/{app_id}/approvals",
json={"action": "send_email", "artifact_id": artifact_id},
)
assert resp.status_code == 201
approval_id = resp.json()["id"]
# Confirm
resp = client.post(f"/api/approvals/{approval_id}/confirm")
assert resp.status_code == 200
# Send
resp = client.post(
"/api/outbox/send",
json={
"approval_id": approval_id,
"payload": {"to": "test@example.com", "subject": "App", "body": "Hi"},
},
)
assert resp.status_code == 200
assert resp.json()["status"] == "sent"
def test_send_expired_confirmation(self, client, seed_data):
"""Send with an expired confirmed approval -> 409."""
app_id = seed_data["application"]["id"]
artifact_id = seed_data["artifact"]["id"]
# Create + confirm approval
resp = client.post(
f"/api/applications/{app_id}/approvals",
json={"action": "send_email", "artifact_id": artifact_id},
)
approval_id = resp.json()["id"]
resp = client.post(f"/api/approvals/{approval_id}/confirm")
assert resp.status_code == 200
# Expire it
past_time = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
with psycopg.connect(DATABASE_URL) as conn:
conn.execute(
"UPDATE approval SET expires_at = %s WHERE id = %s",
(past_time, approval_id),
)
conn.commit()
# Send should fail
resp = client.post(
"/api/outbox/send",
json={"approval_id": approval_id, "payload": {"to": "test@example.com"}},
)
assert resp.status_code == 409
assert "expired" in resp.text.lower()

View file

@ -0,0 +1,161 @@
"""State machine transition tests (happy path + 409 invalid transitions)."""
from __future__ import annotations
import pytest
from app.statemachine import (
InvalidTransition,
STATES,
TransitionContext,
check_transition,
)
class TestStateMachineTransitions:
"""Test all valid transitions (happy path)."""
def test_discovered_to_scored(self):
ctx = TransitionContext(
application_id="test",
from_state="discovered",
to_state="scored",
has_score=True,
)
check_transition(ctx) # should not raise
def test_discovered_to_rejected(self):
ctx = TransitionContext("test", "discovered", "rejected")
check_transition(ctx)
def test_scored_to_approved(self):
ctx = TransitionContext("test", "scored", "approved")
check_transition(ctx)
def test_scored_to_rejected(self):
ctx = TransitionContext("test", "scored", "rejected")
check_transition(ctx)
def test_approved_to_drafting(self):
ctx = TransitionContext("test", "approved", "drafting")
check_transition(ctx)
def test_drafting_to_sent(self):
ctx = TransitionContext(
"test",
"drafting",
"sent",
has_confirmed_approval=True,
artifact_hash_match=True,
)
check_transition(ctx)
def test_sent_to_interviewing(self):
ctx = TransitionContext("test", "sent", "interviewing")
check_transition(ctx)
def test_interviewing_to_offer(self):
ctx = TransitionContext("test", "interviewing", "offer")
check_transition(ctx)
def test_interviewing_to_closed(self):
ctx = TransitionContext("test", "interviewing", "closed")
check_transition(ctx)
def test_offer_to_closed(self):
ctx = TransitionContext("test", "offer", "closed")
check_transition(ctx)
def test_scored_to_expired(self):
ctx = TransitionContext("test", "scored", "expired")
check_transition(ctx)
def test_approved_to_expired(self):
ctx = TransitionContext("test", "approved", "expired")
check_transition(ctx)
class TestStateMachineInvalidTransitions:
"""Test invalid transitions raise InvalidTransition."""
def test_discovered_to_approved(self):
ctx = TransitionContext("test", "discovered", "approved")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_discovered_to_sent(self):
ctx = TransitionContext("test", "discovered", "sent")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_rejected_to_scored(self):
ctx = TransitionContext("test", "rejected", "scored")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_closed_to_sent(self):
ctx = TransitionContext("test", "closed", "sent")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_unknown_from_state(self):
ctx = TransitionContext("test", "nonexistent", "scored")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_unknown_to_state(self):
ctx = TransitionContext("test", "discovered", "nonexistent")
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_drafting_to_sent_no_approval(self):
"""Guard: drafting -> sent requires confirmed approval."""
ctx = TransitionContext(
"test",
"drafting",
"sent",
has_confirmed_approval=False,
artifact_hash_match=False,
)
with pytest.raises(InvalidTransition) as exc_info:
check_transition(ctx)
assert "guard failed" in str(exc_info.value)
def test_drafting_to_sent_hash_mismatch(self):
"""Guard: drafting -> sent requires hash match."""
ctx = TransitionContext(
"test",
"drafting",
"sent",
has_confirmed_approval=True,
artifact_hash_match=False,
)
with pytest.raises(InvalidTransition):
check_transition(ctx)
def test_discovered_to_scored_no_score(self):
"""Guard: discovered -> scored requires scoring completed (has_score)."""
ctx = TransitionContext(
"test",
"discovered",
"scored",
has_score=False,
)
with pytest.raises(InvalidTransition) as exc_info:
check_transition(ctx)
assert "guard failed" in str(exc_info.value)
class TestStateMachineHelpers:
def test_all_states_present(self):
assert len(STATES) == 10
def test_can_transition_valid(self):
from app.statemachine import can_transition
assert can_transition("discovered", "scored")
assert can_transition("discovered", "rejected")
def test_can_transition_invalid(self):
from app.statemachine import can_transition
assert not can_transition("discovered", "sent")
assert not can_transition("closed", "discovered")

View file

@ -18,6 +18,17 @@ services:
timeout: 3s
retries: 10
api-test:
build:
context: ./apps/api
dockerfile: Dockerfile.test
environment:
DATABASE_URL: postgresql://jobhunt:jobhunt@postgres:5432/jobhunt
depends_on:
postgres:
condition: service_healthy
restart: "no"
volumes:
jobhunt_pgdata:
name: jobhunt_pgdata